Compare commits

...
355 Commits
Author SHA1 Message Date
Gani Georgiev 74306c8092 updated go deps 2023-04-25 18:31:27 +03:00
Gani Georgiev a5b3cc0f34 use relative oauth2 path redirect to support subpath proxy deployments 2023-04-25 18:29:36 +03:00
Gani Georgiev 0478f84867 updated /api/oauth2-redirect error messages 2023-04-25 11:52:56 +03:00
Gani Georgiev 0b5e189563 added OAuth2 redirect fallback message to notify the user to go back to the app 2023-04-25 08:36:02 +03:00
Gani Georgiev e61d48fe7b [#2349] fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source 2023-04-24 15:43:23 +03:00
Gani Georgiev cae3315e46 [#2349] fixed view query SELECT DISTINCT parsing 2023-04-24 12:54:57 +03:00
Gani Georgiev a2a170ad82 updated go deps 2023-04-21 10:30:01 +03:00
Gani Georgiev 92dcee7250 skip Cache-Control header for the Admin UI root path 2023-04-21 10:28:28 +03:00
Gani Georgiev bd2521b14b added cache-control header for the admin ui assets 2023-04-21 01:19:57 +03:00
Gani Georgiev 0c63e0e219 added editor loading placeholder 2023-04-21 00:42:41 +03:00
Gani Georgiev d407ca2163 updated ui/dist 2023-04-20 23:59:59 +03:00
Gani Georgiev 5c57be8469 removed tinymce autoresize transition 2023-04-20 23:55:50 +03:00
Gani Georgiev 4b0e614c68 updated changelog 2023-04-20 17:42:38 +03:00
Gani Georgiev 5c95a43042 move relations load to the end of the execution queue t 2023-04-20 17:38:04 +03:00
Gani Georgiev 8240addfab fixed rtl editor menu item typo 2023-04-20 15:36:42 +03:00
Gani Georgiev e1cfe7fc4a [#2327] enabled rtl for the editor field 2023-04-20 15:33:02 +03:00
Gani Georgiev 01f4765c09 added Command+S quick save alias and fixed relations draft restore 2023-04-20 14:38:24 +03:00
Gani Georgiev 818857dea2 [#2325] trigger the related record realtime events on custom record model change 2023-04-20 10:44:20 +03:00
Gani Georgiev fdf4f6d3bd fixed ctrl+s not getting triggered in the editor field 2023-04-19 10:33:24 +03:00
Gani Georgiev 0420a00c18 updated ui/dist 2023-04-18 06:32:01 +03:00
Gani Georgiev b39d607c8a updated changelog and package.json 2023-04-18 06:30:05 +03:00
Gani Georgiev abea28a1a9 fixed minor typos 2023-04-17 22:22:41 +03:00
Gani Georgiev a7d5a0640c allowed specifying non-context auth model for the file token endpoint 2023-04-17 22:05:09 +03:00
Gani Georgiev c937c06688 updated ui/dist 2023-04-17 20:59:04 +03:00
Gani Georgiev d11cec2226 updated cascade delete tooltip 2023-04-17 16:33:26 +03:00
Gani Georgiev c179b4c473 updated oauth2 docs and added create api rule tooltip 2023-04-17 16:29:56 +03:00
Gani Georgiev 00b04db5cf added ctrl+s record form shortcut and updated user external auths panel with the new provider logos 2023-04-17 14:35:49 +03:00
Gani Georgiev 1b8776926e updated protected files visualization 2023-04-17 13:28:41 +03:00
Gani Georgiev 5eb54c7a3d store unsaved record changes in local storage 2023-04-17 12:43:48 +03:00
Gani Georgiev 6127350e91 added eagerRequestDataCache middleware 2023-04-15 14:44:07 +03:00
Gani Georgiev 177230a765 renamed private to protected 2023-04-15 13:27:42 +03:00
Gani Georgiev 8cb0d980ad updated dart record view example and the relation cascade delete label 2023-04-15 11:27:07 +03:00
Gani Georgiev d44c2bb1a9 removed implicit autofocus as it is causing keyboard navigation issues 2023-04-15 11:23:59 +03:00
Gani Georgiev 3423433d01 updated ui/dist 2023-04-15 00:50:10 +03:00
Gani Georgiev 646b6a925b limit the number of displayed thumbs in the records listing 2023-04-15 00:47:49 +03:00
Gani Georgiev 5ddf9cd443 don't skip temp indexes migration if the indexes column is already created 2023-04-15 00:44:19 +03:00
Gani Georgiev 0351f3a1ad updated overlay panel autofocus 2023-04-14 15:28:24 +03:00
Gani Georgiev 53c735d00b added simple loose wildcard search term support in the Admin UI 2023-04-14 14:45:50 +03:00
Gani Georgiev 25769e971a added import.meta.env.BASE_URL prefix for the auth provider logos 2023-04-14 13:32:59 +03:00
Gani Georgiev aba6279feb Merge branch 'master' into develop 2023-04-14 12:58:03 +03:00
Gani Georgiev ac4a961a10 added unique error test for the record update api 2023-04-13 23:04:24 +03:00
Gani Georgiev 8317ae2e6b [#2287] fixed unique field detailed error not being returned on DrySubmit failure 2023-04-13 22:37:10 +03:00
Gani Georgiev b347c8d80c [#2246] added drop files support for the file field 2023-04-13 16:46:50 +03:00
Gani Georgiev af5f808144 updated OAuth2 providers ui 2023-04-13 15:38:12 +03:00
Gani Georgiev a77b62e5bd added extra table name checks in the overwritten dao hooks in case of duplicated ids 2023-04-12 16:46:08 +03:00
Gani Georgiev c77467a6a2 synced with master 2023-04-12 16:31:36 +03:00
Gani Georgiev 35e433f26b updated changelog and ui/dist 2023-04-12 16:23:53 +03:00
Gani Georgiev 59f23d3d23 [#2277] added check for nil dao hooks 2023-04-12 16:09:35 +03:00
Gani Georgiev 6a69a035a7 synced with master 2023-04-12 14:27:17 +03:00
Gani Georgiev b8373b23a4 updated changelog 2023-04-12 10:31:11 +03:00
Gani Georgiev 25c1db40ac [#2272] fixed panic on list.ExistInSliceWithRegex cache 2023-04-12 10:27:22 +03:00
Gani Georgiev 7b48e9d1c1 updated go deps 2023-04-10 23:00:36 +03:00
Gani Georgiev b537085bca updated test scenario name 2023-04-10 22:58:07 +03:00
Gani Georgiev 3e5b021dd8 fixed oauth2SubscriptionRedirect test 2023-04-10 22:51:59 +03:00
Gani Georgiev 151dbafc86 updated ui/dist 2023-04-10 22:33:13 +03:00
Gani Georgiev dc72d5adee [#55] added OAuth2 subscription redirect handler 2023-04-10 22:27:00 +03:00
Gani Georgiev c826514eca synced with master 2023-04-07 11:39:45 +03:00
Gani Georgiev b7407edcae updated go.mod 2023-04-06 20:22:50 +03:00
Gani Georgiev 1420d717e3 [#2231] revert the aws-sdk-go-v2 change 2023-04-06 20:17:22 +03:00
Gani Georgiev f5f8c35a17 [#2221] fixed Admin UI Logs meta field visualization in Firefox 2023-04-05 19:43:41 +03:00
Gani Georgiev 733d7dacdb [#215] updated the admin ui to allow displaying private files 2023-04-05 13:23:22 +03:00
Gani Georgiev cd45854792 [#215] added Admin UI Private file option toggle 2023-04-04 21:38:03 +03:00
Gani Georgiev ba7000125b [#215] enabled Settings.AdminFileToken validations and added more tests 2023-04-04 20:47:03 +03:00
Gani Georgiev 64c3e3b3c5 [#215] added server-side handlers for serving private files 2023-04-04 20:33:35 +03:00
Gani Georgiev 9f76ad234c [#2205] fixed Record.WithUnknownData() typo 2023-04-03 22:01:59 +03:00
Gani Georgiev 8ca439e5f7 updated ui/dist 2023-04-03 21:11:04 +03:00
Gani Georgiev f31a3b133c revert part of the old COALESCE handling to support missing joined relation fields comparison with empty string 2023-04-03 20:27:52 +03:00
Gani Georgiev 850fe353da updated changelog 2023-04-01 13:00:28 +03:00
Gani Georgiev 16a6b32a76 updated go deps 2023-04-01 12:55:42 +03:00
Gani Georgiev a1487c3235 updated the record file handling for the before update hook too 2023-04-01 12:51:00 +03:00
Gani Georgiev 5255515e26 updated ui/dist 2023-04-01 12:38:04 +03:00
Gani Georgiev 0b90ce43c3 Merge branch 'develop' 2023-04-01 12:36:10 +03:00
Gani Georgiev 899b8217e0 fix realtime events firing before files upload completion 2023-04-01 11:47:01 +03:00
Gani Georgiev 69bf9779d9 added Dao.Clone() helper 2023-04-01 11:45:08 +03:00
Gani Georgiev 48d6803d17 check only the existence of the thumb and add ContentType metadata when creating the thumb 2023-03-31 23:06:22 +03:00
Gani Georgiev 216efb95a8 updated Dao.Save godoc 2023-03-30 21:19:14 +03:00
Gani GeorgievandYuxiang Gao 29a264e132 [#1346] upgraded to aws-sdk-v2
Co-authored-by: Yuxiang Gao <yuxiang-gao@outlook.com>
2023-03-30 16:10:13 +03:00
Gani Georgiev 976a9e2f27 updated tinymce 2023-03-30 14:42:28 +03:00
Gani Georgiev 005e77151d removed unused tinymce themes 2023-03-30 14:35:43 +03:00
Gani Georgiev f12467f2b3 updated list api docs 2023-03-30 12:25:33 +03:00
Gani Georgiev fe03dd0789 use backtick in index column preset 2023-03-30 12:17:56 +03:00
Gani Georgiev ff1f99436a prevent closing the datefield options on calendar select and updated collections upsert save handler 2023-03-28 09:06:16 +03:00
Gani Georgiev 3ea02c945d updated changelog and formatting 2023-03-28 07:59:37 +03:00
Gani Georgiev 4c903684d8 added linux cgo target 2023-03-27 21:39:46 +03:00
Gani Georgiev d0239f25ed updated changelog and ui/dist 2023-03-27 19:44:09 +03:00
Gani Georgiev 05adb8c018 update ui deps 2023-03-27 16:17:37 +03:00
Gani Georgiev c901c9ab7d updated go deps 2023-03-27 16:16:09 +03:00
Gani Georgiev c9fba9972a cleanup old remaining temp views 2023-03-27 16:07:47 +03:00
Gani Georgiev 2c40811fac added required field marker 2023-03-27 16:07:20 +03:00
Gani Georgiev b81112f82e minor ui adjustments 2023-03-26 22:49:33 +03:00
Gani Georgiev 0acf8198a4 revert record upsert changes on upload failure 2023-03-26 22:49:18 +03:00
Gani Georgiev 5ece360808 autoselect new field names 2023-03-26 20:32:41 +03:00
Gani Georgiev 3a5d3d521f added ProviderName and ProviderClient fields to core.RecordAuthWithOAuth2Event 2023-03-26 19:32:23 +03:00
Gani Georgiev f024de3cc4 updated ui/dist 2023-03-26 19:19:44 +03:00
Gani Georgiev a4abe9e2cb removed unused AuthProviderConfig.Meta field 2023-03-25 23:34:00 +02:00
Gani Georgiev 5678339af0 added migrate history-sync command 2023-03-25 21:48:19 +02:00
Gani Georgiev e5a22b8bd8 added a flag indicating OAuth2 auth record creation 2023-03-25 15:18:28 +02:00
Gani Georgiev be0ee7f66c fixed generateAppleClientSecret call 2023-03-24 22:48:32 +02:00
Gani Georgiev 78a9f7429c updated ui/dist 2023-03-24 22:30:57 +02:00
Gani Georgiev d8b4daf47f updated rule field styles 2023-03-23 22:59:02 +02:00
Gani Georgiev 7a2360d785 [#2029] enable any id aliased column in a view query to be detected as relation field 2023-03-23 19:51:06 +02:00
Gani Georgiev 67ecebe935 [#1939] removed redundant COALESCE normalizations 2023-03-23 19:30:35 +02:00
Gani Georgiev 5cbdbf6c10 updated ui/dist 2023-03-23 16:15:19 +02:00
Gani Georgiev 0710214701 added new field indicator 2023-03-23 10:42:27 +02:00
Gani Georgiev 9736a45e80 renamed daos.GetTableColumns and daos.GetTableInfo for consistency 2023-03-22 17:15:17 +02:00
Gani Georgiev 923fc26a31 changed types.JsonArray to support generics 2023-03-22 17:12:44 +02:00
Gani Georgiev a79f3a7c56 removed test js file 2023-03-22 16:53:34 +02:00
Gani Georgiev 8430944650 deprecated SchemaField.Unique 2023-03-22 15:47:34 +02:00
Gani Georgiev 9b54fd3516 added debug log for already committed response error 2023-03-22 15:42:35 +02:00
Gani Georgiev 226352f884 replaced indirect expand field.Unique with unique index constraint check 2023-03-21 21:38:28 +02:00
Gani Georgiev 2a682a10b2 preload tinymce 2023-03-21 18:52:13 +02:00
Gani Georgiev b67549209a [#2118] added option to explicitly set the record id from the Admin UI 2023-03-21 17:56:31 +02:00
Gani Georgiev 1126a0e16f updated changelog and ui/dist 2023-03-21 15:55:42 +02:00
Gani Georgiev 17472cb40a minor internal indexes handling adjustments and test 2023-03-21 15:31:20 +02:00
Gani Georgiev 981de64c7f added Index.Build helper method 2023-03-21 14:26:44 +02:00
Gani Georgiev 1b45e23c81 removed unnecessary helper method and updated index parser regex 2023-03-19 22:15:18 +02:00
Gani Georgiev 29621303df updated indexes column migration to load unique fields and custom indexes 2023-03-19 22:13:52 +02:00
Gani Georgiev a0ec5707d1 (no tests) collection indexes scaffoldings 2023-03-19 16:18:33 +02:00
Gani Georgiev 695c20a969 decreased bcrypt round hash for admins to 12 for consistency with the auth records 2023-03-19 16:10:11 +02:00
Gani Georgiev 95bb2eb871 update automigrate templates to check collection indexes 2023-03-19 16:02:29 +02:00
Gani Georgiev 971916c20d removed unnecessary mail layou css class 2023-03-19 10:16:23 +02:00
Gani Georgiev 44f5172db7 added create index sql parser 2023-03-19 10:15:26 +02:00
Gani Georgiev 5fd103481c updated ui/dist 2023-03-19 10:14:44 +02:00
Gani Georgiev 82abd7e0b0 minor collection ui adjustments 2023-03-19 08:17:36 +02:00
Gani Georgiev 361cfb56b7 [#1904] simplified default mail template styles 2023-03-16 22:35:03 +02:00
Gani Georgiev 4d16d0e16e new schema and indexes ui 2023-03-16 19:21:16 +02:00
Gani Georgiev 254e691e92 [#2072] registered RemoveTrailingSlash middleware only for the /api/* routes 2023-03-15 18:09:49 +02:00
Gani Georgiev e735d9d21b synced with master 2023-03-14 02:04:34 +02:00
Gani Georgiev 31b24ecb86 removed eager unique collection name check to allow lazy validation during bulk import 2023-03-14 01:31:30 +02:00
Gani Georgiev 3eb3d0bbdf lowered auth record bcrypt round factor to 12 2023-03-13 15:52:51 +02:00
Gani Georgiev f180af7af7 synced with master 2023-03-12 18:09:16 +02:00
Gani Georgiev cce9116fa7 updated ui/dist 2023-03-12 17:31:24 +02:00
Gani Georgiev 7cfbe49b32 updated changelog 2023-03-12 17:22:50 +02:00
Gani Georgiev b564be06f4 updated RecordsPicker to show proper view colllection relations 2023-03-12 17:12:35 +02:00
Gani Georgiev f5493dd608 updated ui/dist 2023-03-12 17:00:57 +02:00
Gani Georgiev 49227f5436 fixed views relation picker load action and updated the record preview 2023-03-12 16:44:24 +02:00
Gani Georgiev 94d2f296b2 [#2044] fixed view collections import 2023-03-12 16:43:27 +02:00
Gani Georgiev d046811df7 optimized single relation lookups 2023-03-07 23:28:35 +02:00
Gani Georgiev 4768e07c0b added multiple->single value conversion warning 2023-03-06 23:24:50 +02:00
Gani Georgiev 0547202382 use nonconcurrent db when deleting log requests 2023-03-06 23:24:31 +02:00
Gani Georgiev 684f91f7e5 added indirect view update by source collection field change 2023-03-06 20:10:18 +02:00
Gani Georgiev bce4094134 updated changelog 2023-03-06 15:32:46 +02:00
Gani Georgiev 6a60bc1df3 meged apple-oauth2 2023-03-06 15:30:35 +02:00
Gani Georgiev 19b7a7a74f updated ui/dist 2023-03-06 15:29:03 +02:00
Gani Georgiev 8de0e61cf4 synced with master 2023-03-06 15:27:17 +02:00
Gani Georgiev 5344ec83fa normalized values on maxSelect change 2023-03-06 15:20:07 +02:00
Gani Georgiev af34eb544d [#1989] fixed RecordInfo multiple files preview 2023-03-06 13:33:15 +02:00
Gani Georgiev 65aa114103 added google OAuth2 verified_email check 2023-03-05 19:19:11 +02:00
Gani Georgiev 8728161288 sync with latest changes 2023-03-05 16:16:07 +02:00
Gani Georgiev 2420b2804a synced with master 2023-03-05 16:12:51 +02:00
Gani Georgiev 42e288c71a [#1976] added HEAD requests support for the file download action 2023-03-05 15:39:18 +02:00
Gani Georgiev 2791628d96 minor updates to the rule fields ui 2023-03-05 15:18:51 +02:00
Gani Georgiev 01f0af7af6 added errors slide transition 2023-03-04 13:06:24 +02:00
Gani Georgiev e8d61e7b45 simplified rules ui 2023-03-04 13:05:55 +02:00
Gani Georgiev a5ac83c7b0 use jwt.ParseECPrivateKeyFromPEM instead of the custom one 2023-03-02 21:31:27 +02:00
Gani Georgiev a67c14c368 added support for @request.headers.* 2023-03-02 18:56:18 +02:00
Gani Georgiev 19faa0d8e7 updated calendar styles 2023-03-02 16:10:56 +02:00
Gani Georgiev 56a77fd5ff updated migrations comment 2023-03-02 15:17:29 +02:00
Gani Georgiev 07727dbde6 [#1956] normalized _requests.method to UPPERCASE 2023-03-02 15:15:00 +02:00
Gani Georgiev a3d26a73c3 removed unnecessary struct pointer 2023-03-02 14:07:46 +02:00
Gani Georgiev b328827705 added generate-client-secret api test 2023-03-01 23:45:54 +02:00
Gani Georgiev f5e5fae773 added apple oauth2 integration 2023-03-01 23:29:51 +02:00
Gani Georgiev 41f01bab0d fixed collections import message typo 2023-02-28 16:50:25 +02:00
Gani Georgiev dc96a12dc0 updated ui/dist 2023-02-26 11:00:52 +02:00
Gani Georgiev 0bb58eb52c minor UI fixes 2023-02-26 10:47:00 +02:00
Gani Georgiev cf6d325add added model query autocancellation test 2023-02-24 22:07:25 +02:00
Gani Georgiev 578e1c9bc1 [#223] updated the internal redirects to allow easier subpath deployment when behind a reverse proxy 2023-02-24 18:49:46 +02:00
Gani Georgiev 4778fc7a46 added min select relation field option 2023-02-24 16:34:02 +02:00
Gani Georgiev f1a6a82bd3 prevent collectionId relation field change 2023-02-24 14:12:27 +02:00
Gani Georgiev 21b152b58c fixed formatting and typos 2023-02-23 21:51:42 +02:00
Gani Georgiev aa4e405f92 replaced authentik with generic oidc provider 2023-02-23 21:07:00 +02:00
Gani Georgiev e529fe7e2a added --queryTimeout flag 2023-02-23 18:59:23 +02:00
Gani Georgiev 6ab2fa9489 updated default query timeout to 1m 2023-02-23 12:04:16 +02:00
Gani Georgiev 010a396b0e updated dao fail/retry handling 2023-02-22 22:20:19 +02:00
Gani Georgiev 65a148b741 added UploadedFiles to the record create/update events 2023-02-22 22:09:13 +02:00
Gani Georgiev 503c65a767 [#1896] run files upload after record save 2023-02-22 22:06:59 +02:00
Gani Georgiev eec3261d67 added cancelKey to the RecordsList component 2023-02-22 18:02:08 +02:00
Gani Georgiev 0db6c783cd minor ui improvements 2023-02-21 22:24:49 +02:00
Gani Georgiev 4fdc8feafc updated daos.HasTable to check also for views 2023-02-21 20:03:27 +02:00
Gani Georgiev 41c3cc8a90 added select auto fail/retry 2023-02-21 16:54:08 +02:00
Gani Georgiev 0afb09b3bd updated view ui 2023-02-21 16:32:58 +02:00
Gani Georgiev 1075292321 fixed godoc formatting 2023-02-20 13:31:03 +02:00
Gani Georgiev b184ef6c3a fixed daos.SaveView test 2023-02-18 20:31:41 +02:00
Gani Georgiev 5fd7f61656 temporary change the github action's go version to 1.19 2023-02-18 20:06:22 +02:00
Gani Georgiev 851711e231 updated go deps 2023-02-18 20:02:47 +02:00
Gani Georgiev a07f67002f added view collection type 2023-02-18 19:33:42 +02:00
Gani Georgiev 0052e2ab2a synced with master 2023-02-12 14:11:12 +02:00
Gani Georgiev 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
546 changed files with 37962 additions and 8463 deletions
+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.
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.19.4'
go-version: '>=1.20.3'
# This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts.
@@ -42,6 +42,6 @@ jobs:
with:
distribution: goreleaser
version: latest
args: release --rm-dist
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+25 -2
View File
@@ -7,7 +7,20 @@ before:
- go mod tidy
builds:
- main: ./examples/base
- id: build_cgo
main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
- id: build_noncgo
main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
@@ -33,7 +46,17 @@ release:
draft: true
archives:
- format: zip
- id: archive_cgo
builds: [build_cgo]
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}_cgo'
format: zip
files:
- LICENSE*
- CHANGELOG*
- id: archive_noncgo
builds: [build_noncgo]
format: zip
files:
- LICENSE*
- CHANGELOG*
+553 -5
View File
@@ -1,3 +1,551 @@
## v0.15.2
- Fixed View query `SELECT DISTINCT` identifiers parsing ([#2349-5706019](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5706019)).
- Fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source ([#2349-5707675](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5707675)).
- Added OAuth2 redirect fallback message to notify the user to go back to the app in case the browser window is not auto closed.
## v0.15.1
- Trigger the related `Record` model realtime subscription events on [custom model struct](https://pocketbase.io/docs/custom-models/) save ([#2325](https://github.com/pocketbase/pocketbase/discussions/2325)).
- Fixed `Ctrl + S` in the `editor` field not propagating the quick save shortcut to the parent form.
- Added `⌘ + S` alias for the record quick save shortcut (_I have no Mac device to test it but it should work based on [`e.metaKey` docs](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey)_).
- Enabled RTL for the TinyMCE editor ([#2327](https://github.com/pocketbase/pocketbase/issues/2327)).
- Reduced the record form vertical layout shifts and slightly improved the rendering speed when loading multiple `relation` fields.
- Enabled Admin UI assets cache.
## v0.15.0
- Simplified the OAuth2 authentication flow in a single "all in one" call ([#55](https://github.com/pocketbase/pocketbase/issues/55)).
Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
The manual code-token exchange flow is still supported but the SDK methods is renamed to `authWithOAuth2Code()` (_to minimize the breaking changes the JS SDK has a function overload that will proxy the existing `authWithOauth2` calls to `authWithOAuth2Code`_).
For more details and example, you could check https://pocketbase.io/docs/authentication/#oauth2-integration.
- Added support for protected files ([#215](https://github.com/pocketbase/pocketbase/issues/215)).
Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
It works with a short lived (~5min) file token passed as query param with the file url.
For more details and example, you could check https://pocketbase.io/docs/files-handling/#private-files.
- **!** Fixed typo in `Record.WithUnkownData()` -> `Record.WithUnknownData()`.
- Added simple loose wildcard search term support in the Admin UI.
- Added auto "draft" to allow restoring previous record state in case of accidental reload or power outage.
- Added `Ctrl + S` shortcut to save the record changes without closing the panel.
- Added "drop files" support for the file upload field.
- Refreshed the OAuth2 Admin UI.
## v0.14.5
- Added checks for `nil` hooks in `forms.RecordUpsert` when used with custom `Dao` ([#2277](https://github.com/pocketbase/pocketbase/issues/2277)).
- Fixed unique detailed field error not returned on record create failure ([#2287](https://github.com/pocketbase/pocketbase/discussions/2287)).
## v0.14.4
- Fixed concurrent map write pannic on `list.ExistInSliceWithRegex()` cache ([#2272](https://github.com/pocketbase/pocketbase/issues/2272)).
## v0.14.3
- Fixed Admin UI Logs `meta` visualization in Firefox ([#2221](https://github.com/pocketbase/pocketbase/issues/2221)).
- Downgraded to v1 of the `aws/aws-sdk-go` package since v2 has compatibility issues with GCS ([#2231](https://github.com/pocketbase/pocketbase/issues/2231)).
- Upgraded the GitHub action to use [min Go 1.20.3](https://github.com/golang/go/issues?q=milestone%3AGo1.20.3+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `net/http` security fixes.
## v0.14.2
- Reverted part of the old `COALESCE` handling as a fallback to support empty string comparison with missing joined relation fields.
## v0.14.1
- Fixed realtime events firing before the files upload completion.
- Updated the underlying S3 lib to use `aws-sdk-go-v2` ([#1346](https://github.com/pocketbase/pocketbase/pull/1346); thanks @yuxiang-gao).
- Updated TinyMCE to v6.4.1.
- Updated the godoc of `Dao.Save*` methods.
## v0.14.0
- Added _experimental_ Apple OAuth2 integration.
- Added `@request.headers.*` filter rule support.
- Added support for advanced unique constraints and indexes management ([#345](https://github.com/pocketbase/pocketbase/issues/345), [#544](https://github.com/pocketbase/pocketbase/issues/544))
- Simplified the collections fields UI to allow easier and quicker scaffolding of the data schema.
- Deprecated `SchemaField.Unique`. Unique constraints are now managed via indexes.
The `Unique` field is a no-op and will be removed in future version.
- Removed the `COALESCE` wrapping from some of the generated filter conditions to make better use of the indexes ([#1939](https://github.com/pocketbase/pocketbase/issues/1939)).
- Detect `id` aliased view columns as single `relation` fields ([#2029](https://github.com/pocketbase/pocketbase/discussions/2029)).
- Optimized single relation lookups.
- Normalized record values on `maxSelect` field option change (`select`, `file`, `relation`).
When changing **from single to multiple** all already inserted single values are converted to an array.
When changing **from multiple to single** only the last item of the already inserted array items is kept.
- Changed the cost/round factor of bcrypt hash generation from 13 to 12 since several users complained about the slow authWithPassword responses on lower spec hardware.
_The change will affect only new users. Depending on the demand, we might make it configurable from the auth options._
- Simplified the default mail template styles to allow more control over the template layout ([#1904](https://github.com/pocketbase/pocketbase/issues/1904)).
- Added option to explicitly set the record id from the Admin UI ([#2118](https://github.com/pocketbase/pocketbase/issues/2118)).
- Added `migrate history-sync` command to clean `_migrations` history table from deleted migration files references.
- Added new fields to the `core.RecordAuthWithOAuth2Event` struct:
```
IsNewRecord bool, // boolean field indicating whether the OAuth2 action created a new auth record
ProviderName string, // the name of the OAuth2 provider (eg. "google")
ProviderClient auth.Provider, // the loaded Provider client instance
```
- Added CGO linux target for the prebuilt executable.
- **!** Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
- **!** Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
- **!** Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
If you have previously used `types.JsonArray`, you'll have to update it to `types.JsonArray[any]`.
- **!** Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
- **!** Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
- Other minor UI improvements.
## v0.13.4
- Removed eager unique collection name check to support lazy validation during bulk import.
## v0.13.3
- Fixed view collections import ([#2044](https://github.com/pocketbase/pocketbase/issues/2044)).
- Updated the records picker Admin UI to show properly view collection relations.
## v0.13.2
- Fixed Admin UI js error when selecting multiple `file` field as `relation` "Display fields" ([#1989](https://github.com/pocketbase/pocketbase/issues/1989)).
## v0.13.1
- Added `HEAD` request method support for the `/api/files/:collection/:recordId/:filename` route ([#1976](https://github.com/pocketbase/pocketbase/discussions/1976)).
## v0.13.0
- Added new "View" collection type allowing you to create a read-only collection from a custom SQL `SELECT` statement. It supports:
- aggregations (`COUNT()`, `MIN()`, `MAX()`, `GROUP BY`, etc.)
- column and table aliases
- CTEs and subquery expressions
- auto `relation` fields association
- `file` fields proxying (up to 5 linked relations, eg. view1->view2->...->base)
- `filter`, `sort` and `expand`
- List and View API rules
- Added auto fail/retry (default to 8 attempts) for the `SELECT` queries to gracefully handle the `database is locked` errors ([#1795](https://github.com/pocketbase/pocketbase/discussions/1795#discussioncomment-4882169)).
_The default max attempts can be accessed or changed via `Dao.MaxLockRetries`._
- Added default max query execution timeout (30s).
_The default timeout can be accessed or changed via `Dao.ModelQueryTimeout`._
_For the prebuilt executables it can be also changed via the `--queryTimeout=10` flag._
- Added support for `dao.RecordQuery(collection)` to scan directly the `One()` and `All()` results in `*models.Record` or `[]*models.Record` without the need of explicit `NullStringMap`.
- Added support to overwrite the default file serve headers if an explicit response header is set.
- Added file thumbs when visualizing `relation` display file fields.
- Added "Min select" `relation` field option.
- Enabled `process.env` in JS migrations to allow accessing `os.Environ()`.
- Added `UploadedFiles` field to the `RecordCreateEvent` and `RecordUpdateEvent` event structs.
- **!** Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
- **!** Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
- **!** Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
If you are sending custom emails, you'll have to replace:
```go
message := &mailer.Message{
...
// (old) To: mail.Address{Address: "to@example.com"}
To: []mail.Address{{Address: "to@example.com", Name: "Some optional name"}},
// (old) Cc: []string{"cc@example.com"}
Cc: []mail.Address{{Address: "cc@example.com", Name: "Some optional name"}},
// (old) Bcc: []string{"bcc@example.com"}
Bcc: []mail.Address{{Address: "bcc@example.com", Name: "Some optional name"}},
...
}
```
- **!** Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
_If you've previously used Authentik, make sure to rename the provider key in your code to `oidc`._
_To enable more than one OIDC provider you can use the additional `oidc2` and `oidc3` provider keys._
- **!** Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
- Updated the internal redirects to allow easier subpath deployment when behind a reverse proxy.
- Other minor Admin UI improvements.
## v0.12.3
- Fixed "Toggle column" reactivity when navigating between collections ([#1836](https://github.com/pocketbase/pocketbase/pull/1836)).
- Logged the current datetime on server start ([#1822](https://github.com/pocketbase/pocketbase/issues/1822)).
## v0.12.2
- Fixed the "Clear" button of the datepicker component not clearing the value ([#1730](https://github.com/pocketbase/pocketbase/discussions/1730)).
- Increased slightly the fields contrast ([#1742](https://github.com/pocketbase/pocketbase/issues/1742)).
- Auto close the multi-select dropdown if "Max select" is reached.
## v0.12.1
- Fixed js error on empty relation save.
- Fixed `overlay-active` css class not being removed on nested overlay panel close ([#1718](https://github.com/pocketbase/pocketbase/issues/1718)).
- Added the collection name in the page title ([#1711](https://github.com/pocketbase/pocketbase/issues/1711)).
## v0.12.0
- Refactored the relation picker UI to allow server-side search, sort, create, update and delete of relation records ([#976](https://github.com/pocketbase/pocketbase/issues/976)).
- Added new `RelationOptions.DisplayFields` option to specify custom relation field(s) visualization in the Admin UI.
- Added Authentik OAuth2 provider ([#1377](https://github.com/pocketbase/pocketbase/pull/1377); thanks @pr0ton11).
- Added LiveChat OAuth2 provider ([#1573](https://github.com/pocketbase/pocketbase/pull/1573); thanks @mariosant).
- Added Gitea OAuth2 provider ([#1643](https://github.com/pocketbase/pocketbase/pull/1643); thanks @hlanderdev).
- Added PDF file previews ([#1548](https://github.com/pocketbase/pocketbase/pull/1548); thanks @mjadobson).
- Added video and audio file previews.
- Added rich text editor (`editor`) field for HTML content based on TinyMCE ([#370](https://github.com/pocketbase/pocketbase/issues/370)).
_Currently the new field doesn't have any configuration options or validations but this may change in the future depending on how devs ended up using it._
- Added "Duplicate" Collection and Record options in the Admin UI ([#1656](https://github.com/pocketbase/pocketbase/issues/1656)).
- Added `filesystem.GetFile()` helper to read files through the FileSystem abstraction ([#1578](https://github.com/pocketbase/pocketbase/pull/1578); thanks @avarabyeu).
- Added new auth event hooks for finer control and more advanced auth scenarios handling:
```go
// auth record
OnRecordBeforeAuthWithPasswordRequest()
OnRecordAfterAuthWithPasswordRequest()
OnRecordBeforeAuthWithOAuth2Request()
OnRecordAfterAuthWithOAuth2Request()
OnRecordBeforeAuthRefreshRequest()
OnRecordAfterAuthRefreshRequest()
// admin
OnAdminBeforeAuthWithPasswordRequest()
OnAdminAfterAuthWithPasswordRequest()
OnAdminBeforeAuthRefreshRequest()
OnAdminAfterAuthRefreshRequest()
OnAdminBeforeRequestPasswordResetRequest()
OnAdminAfterRequestPasswordResetRequest()
OnAdminBeforeConfirmPasswordResetRequest()
OnAdminAfterConfirmPasswordResetRequest()
```
- Added `models.Record.CleanCopy()` helper that creates a new record copy with only the latest data state of the existing one and all other options reset to their defaults.
- Added new helper `apis.RecordAuthResponse(app, httpContext, record, meta)` to return a standard Record auth API response ([#1623](https://github.com/pocketbase/pocketbase/issues/1623)).
- Refactored `models.Record` expand and data change operations to be concurrent safe.
- Refactored all `forms` Submit interceptors to use a generic data type as their payload.
- Added several `store.Store` helpers:
```go
store.Reset(newData map[string]T)
store.Length() int
store.GetAll() map[string]T
```
- Added "tags" support for all Record and Model related event hooks.
The "tags" allow registering event handlers that will be called only on matching table name(s) or colleciton id(s)/name(s).
For example:
```go
app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error {
// called only on "articles" record creation
log.Println(e.Record)
return nil
})
```
For all those event hooks `*hook.Hook` was replaced with `*hooks.TaggedHook`, but the hook methods signatures are the same so it should behave as it was previously if no tags were specified.
- **!** Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
In order to support seamlessly both `application/json` and `multipart/form-data`
requests, the following normalization rules are applied if the `json` field is a
**plain string value**:
- "true" is converted to the json `true`
- "false" is converted to the json `false`
- "null" is converted to the json `null`
- "[1,2,3]" is converted to the json `[1,2,3]`
- "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
- numeric strings are converted to json number
- double quoted strings are left as they are (aka. without normalizations)
- any other string (empty string too) is double quoted
Additionally, the "Nonempty" `json` field constraint now checks for `null`, `[]`, `{}` and `""` (empty string).
- Added `aria-label` to some of the buttons in the Admin UI for better accessibility ([#1702](https://github.com/pocketbase/pocketbase/pull/1702); thanks @ndarilek).
- Updated the filename extension checks in the Admin UI to be case-insensitive ([#1707](https://github.com/pocketbase/pocketbase/pull/1707); thanks @hungcrush).
- Other minor improvements (more detailed API file upload errors, UI optimizations, docs improvements, etc.)
## v0.11.4
- Fixed cascade delete for rel records with the same id as the main record ([#1689](https://github.com/pocketbase/pocketbase/issues/1689)).
## v0.11.3
- Fix realtime API panic on concurrent clients iteration ([#1628](https://github.com/pocketbase/pocketbase/issues/1628))
- `app.SubscriptionsBroker().Clients()` now returns a shallow copy of the underlying map.
- Added `Discard()` and `IsDiscarded()` helper methods to the `subscriptions.Client` interface.
- Slow clients should no longer "block" the main action completion.
## v0.11.2
- Fixed `fs.DeleteByPrefix()` hang on invalid S3 settings ([#1575](https://github.com/pocketbase/pocketbase/discussions/1575#discussioncomment-4661089)).
- Updated file(s) delete to run in the background on record/collection delete to avoid blocking the delete model transaction.
_Currently the cascade files delete operation is treated as "non-critical" and in case of an error it is just logged during debug._
_This will be improved in the near future with the planned async job queue implementation._
## v0.11.1
- Unescaped path parameter values ([#1552](https://github.com/pocketbase/pocketbase/issues/1552)).
## v0.11.0
- Added `+` and `-` body field modifiers for `number`, `files`, `select` and `relation` fields.
```js
{
// oldValue + 2
"someNumber+": 2,
// oldValue + ["id1", "id2"] - ["id3"]
"someRelation+": ["id1", "id2"],
"someRelation-": ["id3"],
// delete single file by its name (file fields supports only the "-" modifier!)
"someFile-": "filename.png",
}
```
_Note1: `@request.data.someField` will contain the final resolved value._
_Note2: The old index (`"field.0":null`) and filename (`"field.filename.png":null`) based suffixed syntax for deleting files is still supported._
- ! Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
If you want a "at least one of" type of condition, you can prefix the operator with `?`.
```js
// for each someRelA.someRelB record require the "status" field to be "active"
someRelA.someRelB.status = "active"
// OR for "at least one of" condition
someRelA.someRelB.status ?= "active"
```
_**Note: Previously the behavior for multi-valued fields was as the "at least one of" type.
The release comes with system db migration that will update your existing API rules (if needed) to preserve the compatibility.
If you have multi-select or multi-relation filter checks in your client-side code and want to preserve the old behavior, you'll have to prefix with `?` your operators.**_
- Added support for querying `@request.data.someRelField.*` relation fields.
```js
// example submitted data: {"someRel": "REL_RECORD_ID"}
@request.data.someRel.status = "active"
```
- Added `:isset` modifier for the static request data fields.
```js
// prevent changing the "role" field
@request.data.role:isset = false
```
- Added `:length` modifier for the arrayable request data and collection fields (`select`, `file`, `relation`).
```js
// example submitted data: {"someSelectField": ["val1", "val2"]}
@request.data.someSelectField:length = 2
// check existing record field length
someSelectField:length = 2
```
- Added `:each` modifier support for the multi-`select` request data and collection field.
```js
// check if all selected rows has "pb_" prefix
roles:each ~ 'pb_%'
```
- Improved the Admin UI filters autocomplete.
- Added `@random` sort key for `RANDOM()` sorted list results.
- Added Strava OAuth2 provider ([#1443](https://github.com/pocketbase/pocketbase/pull/1443); thanks @szsascha).
- Added Gitee OAuth2 provider ([#1448](https://github.com/pocketbase/pocketbase/pull/1448); thanks @yuxiang-gao).
- Added IME status check to the textarea keydown handler ([#1370](https://github.com/pocketbase/pocketbase/pull/1370); thanks @tenthree).
- Added `filesystem.NewFileFromBytes()` helper ([#1420](https://github.com/pocketbase/pocketbase/pull/1420); thanks @dschissler).
- Added support for reordering uploaded multiple files.
- Added `webp` to the default images mime type presets list ([#1469](https://github.com/pocketbase/pocketbase/pull/1469); thanks @khairulhaaziq).
- Added the OAuth2 refresh token to the auth meta response ([#1487](https://github.com/pocketbase/pocketbase/issues/1487)).
- Fixed the text wrapping in the Admin UI listing searchbar ([#1416](https://github.com/pocketbase/pocketbase/issues/1416)).
- Fixed number field value output in the records listing ([#1447](https://github.com/pocketbase/pocketbase/issues/1447)).
- Fixed duplicated settings view pages caused by uncompleted transitions ([#1498](https://github.com/pocketbase/pocketbase/issues/1498)).
- Allowed sending `Authorization` header with the `/auth-with-password` record and admin login requests ([#1494](https://github.com/pocketbase/pocketbase/discussions/1494)).
- `migrate down` now reverts migrations in the applied order.
- Added additional list-bucket check in the S3 config test API.
- Other minor improvements.
## v0.10.4
- Fixed `Record.MergeExpand` panic when the main model expand map is not initialized ([#1365](https://github.com/pocketbase/pocketbase/issues/1365)).
## v0.10.3
- ! Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
- Fixed request verification docs api url ([#1332](https://github.com/pocketbase/pocketbase/pull/1332); thanks @JoyMajumdar2001)
- Excluded `collectionId` and `collectionName` from the displayable relation props list ([1322](https://github.com/pocketbase/pocketbase/issues/1322); thanks @dhall2).
## v0.10.2
- Fixed nested multiple expands with shared path ([#586](https://github.com/pocketbase/pocketbase/issues/586#issuecomment-1357784227)).
A new helper method `models.Record.MergeExpand(map[string]any)` was also added to simplify the expand handling and unit testing.
## v0.10.1
- Fixed nested transactions deadlock when authenticating with OAuth2 ([#1291](https://github.com/pocketbase/pocketbase/issues/1291)).
## v0.10.0
- Added `/api/health` endpoint (thanks @MarvinJWendt).
- Added support for SMTP `LOGIN` auth for Microsoft/Outlook and other providers that don't support the `PLAIN` auth method ([#1217](https://github.com/pocketbase/pocketbase/discussions/1217#discussioncomment-4387970)).
- Reduced memory consumption (you can expect ~20% less allocated memory).
- Added support for split (concurrent and nonconcurrent) DB connections pool increasing even further the concurrent throughput without blocking reads on heavy write load.
- Improved record references delete performance.
- Removed the unnecessary parenthesis in the generated filter SQL query, reducing the "_parse stack overflow_" errors.
- Fixed `~` expressions backslash literal escaping ([#1231](https://github.com/pocketbase/pocketbase/discussions/1231)).
- Refactored the `core.app.Bootstrap()` to be called before starting the cobra commands ([#1267](https://github.com/pocketbase/pocketbase/discussions/1267)).
- ! Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
```go
DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns
DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns
LogsMaxOpenConns int // default to core.DefaultLogsMaxOpenConns
LogsMaxIdleConns int // default to core.DefaultLogsMaxIdleConns
```
- Added new helper method `core.App.IsBootstrapped()` to check the current app bootstrap state.
- ! Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
- ! Removed `rest.UploadedFile` struct (see below `filesystem.File`).
- Added generic file resource struct that allows loading and uploading file content from
different sources (at the moment multipart/form-data requests and from the local filesystem).
```
filesystem.File{}
filesystem.NewFileFromPath(path)
filesystem.NewFileFromMultipart(multipartHeader)
filesystem/System.UploadFile(file)
```
- Refactored `forms.RecordUpsert` to allow more easily loading and removing files programmatically.
```
forms.RecordUpsert.AddFiles(key, filesystem.File...) // add new filesystem.File to the form for upload
forms.RecordUpsert.RemoveFiles(key, filenames...) // marks the filenames for deletion
```
- Trigger the `password` validators if any of the others password change fields is set.
## v0.9.2
- Fixed field column name conflict on record deletion ([#1220](https://github.com/pocketbase/pocketbase/discussions/1220)).
@@ -13,7 +561,7 @@
- Replaced `c.QueryString()` with `c.QueryParams().Encode()` to allow loading middleware modified query parameters in the default crud actions ([#1210](https://github.com/pocketbase/pocketbase/discussions/1210)).
- Fixed the datetime field not triggerering the `onChange` event on manual field edit and added a "Clear" button ([#1219](https://github.com/pocketbase/pocketbase/issues/1219)).
- Fixed the datetime field not triggering the `onChange` event on manual field edit and added a "Clear" button ([#1219](https://github.com/pocketbase/pocketbase/issues/1219)).
- Updated the GitHub goreleaser action to use go 1.19.4 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.19.4+label%3ACherryPickApproved).
@@ -73,10 +621,10 @@
```
- Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)).
This allow writting custom migration scripts such as programmatically creating collections,
This allow writing custom migration scripts such as programmatically creating collections,
initializing default settings, running data imports, etc., with a JavaScript API very similar to the Go one (_more documentation will be available soon_).
The `migrate` command is available by default for the prebult executable,
The `migrate` command is available by default for the prebuilt executable,
but if you use PocketBase as framework you need register it manually:
```go
migrationsDir := "" // default to "pb_migrations" (for js) and "migrations" (for go)
@@ -176,7 +724,7 @@ In addition to the `Users` and `profiles` merge, this release comes with several
- The `json` field type now supports filtering and sorting [#423](https://github.com/pocketbase/pocketbase/issues/423#issuecomment-1258302125).
- The `relation` field now allows unlimitted `maxSelect` (aka. without upper limit).
- The `relation` field now allows unlimited `maxSelect` (aka. without upper limit).
- Added support for combined email/username + password authentication (see below `authWithPassword()`).
@@ -672,7 +1220,7 @@ Please check the individual SDK package changelog and apply the necessary change
- Added option to return serialized custom `models.Record` fields data:
```go
func (m *Record) UnknownData() map[string]any
func (m *Record) WithUnkownData(state bool)
func (m *Record) WithUnknownData(state bool)
```
- Deleted `model.User`. Now the user data is stored as an auth `models.Record`.
+1 -1
View File
@@ -1,5 +1,5 @@
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
and associated documentation files (the "Software"), to deal in the Software without restriction,
+26 -4
View File
@@ -90,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.**
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.
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
@@ -110,7 +132,7 @@ 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**.
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
+134 -53
View File
@@ -18,7 +18,7 @@ func bindAdminApi(app core.App, rg *echo.Group) {
api := adminApi{app: app}
subGroup := rg.Group("/admins", ActivityLogger(app))
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/auth-with-password", api.authWithPassword)
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
@@ -39,11 +39,10 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.AdminAuthEvent{
HttpContext: c,
Admin: admin,
Token: token,
}
event := new(core.AdminAuthEvent)
event.HttpContext = c
event.Admin = admin
event.Token = token
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
return e.HttpContext.JSON(200, map[string]any{
@@ -59,21 +58,55 @@ func (api *adminApi) authRefresh(c echo.Context) error {
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) authWithPassword(c echo.Context) error {
form := forms.NewAdminLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
admin, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
event := new(core.AdminAuthWithPasswordEvent)
event.HttpContext = c
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 {
@@ -86,15 +119,40 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show the result
// (prevents admins enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
event := new(core.AdminRequestPasswordResetEvent)
event.HttpContext = c
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.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 {
@@ -103,12 +161,30 @@ func (api *adminApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
_, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to set new password.", submitErr)
event := new(core.AdminConfirmPasswordResetEvent)
event.HttpContext = c
_, 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 c.NoContent(http.StatusNoContent)
return submitErr
}
func (api *adminApi) list(c echo.Context) error {
@@ -126,11 +202,10 @@ func (api *adminApi) list(c echo.Context) error {
return NewBadRequestError("", err)
}
event := &core.AdminsListEvent{
HttpContext: c,
Admins: admins,
Result: result,
}
event := new(core.AdminsListEvent)
event.HttpContext = c
event.Admins = admins
event.Result = result
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result)
@@ -148,10 +223,9 @@ func (api *adminApi) view(c echo.Context) error {
return NewNotFoundError("", err)
}
event := &core.AdminViewEvent{
HttpContext: c,
Admin: admin,
}
event := new(core.AdminViewEvent)
event.HttpContext = c
event.Admin = admin
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -168,16 +242,17 @@ func (api *adminApi) create(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminCreateEvent{
HttpContext: c,
Admin: admin,
}
event := new(core.AdminCreateEvent)
event.HttpContext = c
event.Admin = admin
// create the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
event.Admin = m
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if err := next(); err != nil {
if err := next(e.Admin); err != nil {
return NewBadRequestError("Failed to create admin.", err)
}
@@ -187,7 +262,9 @@ func (api *adminApi) create(c echo.Context) error {
})
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
@@ -211,16 +288,17 @@ func (api *adminApi) update(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminUpdateEvent{
HttpContext: c,
Admin: admin,
}
event := new(core.AdminUpdateEvent)
event.HttpContext = c
event.Admin = admin
// update the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
event.Admin = m
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if err := next(); err != nil {
if err := next(e.Admin); err != nil {
return NewBadRequestError("Failed to update admin.", err)
}
@@ -230,7 +308,9 @@ func (api *adminApi) update(c echo.Context) error {
})
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
@@ -247,10 +327,9 @@ func (api *adminApi) delete(c echo.Context) error {
return NewNotFoundError("", err)
}
event := &core.AdminDeleteEvent{
HttpContext: c,
Admin: admin,
}
event := new(core.AdminDeleteEvent)
event.HttpContext = c
event.Admin = admin
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
@@ -261,7 +340,9 @@ func (api *adminApi) delete(c echo.Context) error {
})
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
+41 -19
View File
@@ -14,7 +14,7 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAdminAuthWithEmail(t *testing.T) {
func TestAdminAuthWithPassword(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
@@ -39,6 +39,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "wrong password",
@@ -47,17 +50,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid email/password (already authorized)",
Method: http.MethodPost,
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
},
{
Name: "valid email/password (guest)",
@@ -70,7 +65,28 @@ func TestAdminAuthWithEmail(t *testing.T) {
`"token":`,
},
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,
},
},
}
@@ -114,10 +130,12 @@ func TestAdminRequestPasswordReset(t *testing.T) {
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeAdminResetPasswordSend": 1,
"OnMailerAfterAdminResetPasswordSend": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeAdminResetPasswordSend": 1,
"OnMailerAfterAdminResetPasswordSend": 1,
"OnAdminBeforeRequestPasswordResetRequest": 1,
"OnAdminAfterRequestPasswordResetRequest": 1,
},
},
{
@@ -200,8 +218,10 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnAdminBeforeConfirmPasswordResetRequest": 1,
"OnAdminAfterConfirmPasswordResetRequest": 1,
},
},
}
@@ -253,7 +273,9 @@ func TestAdminRefresh(t *testing.T) {
`"token":`,
},
ExpectedEvents: map[string]int{
"OnAdminAuthRequest": 1,
"OnAdminAuthRequest": 1,
"OnAdminBeforeAuthRefreshRequest": 1,
"OnAdminAfterAuthRefreshRequest": 1,
},
},
}
+39 -10
View File
@@ -26,11 +26,18 @@ func InitApi(app core.App) (*echo.Echo, error) {
e := echo.New()
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
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
Skipper: func(c echo.Context) bool {
// ignore Admin UI route(s)
return strings.HasPrefix(c.Request().URL.Path, trailedAdminPath)
// enable by default only for the API routes
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
},
}))
e.Use(middleware.Recover())
@@ -40,6 +47,9 @@ func InitApi(app core.App) (*echo.Echo, error) {
// custom error handler
e.HTTPErrorHandler = func(c echo.Context, err error) {
if c.Response().Committed {
if app.IsDebug() {
log.Println("HTTPErrorHandler response was already committed:", err)
}
return
}
@@ -64,10 +74,9 @@ func InitApi(app core.App) (*echo.Echo, error) {
apiErr = NewBadRequestError("", err)
}
event := &core.ApiErrorEvent{
HttpContext: c,
Error: apiErr,
}
event := new(core.ApiErrorEvent)
event.HttpContext = c
event.Error = apiErr
// send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
@@ -100,6 +109,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
bindFileApi(app, api)
bindRealtimeApi(app, api)
bindLogsApi(app, api)
bindHealthApi(app, api)
// trigger the custom BeforeServe hook for the created api router
// allowing users to further adjust its options or register new routes
@@ -111,6 +121,10 @@ func InitApi(app core.App) (*echo.Echo, error) {
return nil, err
}
// note: it is after the OnBeforeServe hook to ensure that the implicit
// cache is after any user custom defined middlewares
e.Use(eagerRequestDataCache(app))
// catch all any route
api.Any("/*", func(c echo.Context) error {
return echo.ErrNotFound
@@ -156,7 +170,7 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
e.GET(
strings.TrimRight(trailedAdminPath, "/"),
func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath)
return c.Redirect(http.StatusTemporaryRedirect, strings.TrimLeft(trailedAdminPath, "/"))
},
)
@@ -166,6 +180,7 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
trailedAdminPath+"*",
echo.StaticDirectoryHandler(ui.DistDirFS, false),
installerRedirect(app),
uiCacheControl(),
middleware.Gzip(),
)
@@ -185,6 +200,20 @@ func updateTotalAdminsCache(app core.App) error {
return nil
}
func uiCacheControl() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// add default Cache-Control header for all Admin UI resources
// (ignoring the root admin path)
if c.Request().URL.Path != trailedAdminPath {
c.Response().Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
return next(c)
}
}
}
// installerRedirect redirects the user to the installer admin UI page
// when the application needs some preliminary configurations to be done.
func installerRedirect(app core.App) echo.MiddlewareFunc {
@@ -217,12 +246,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
if totalAdmins == 0 && !hasInstallerParam {
// redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"?installer#")
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
// redirect to the home page
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/")
// clear the installer param
return c.Redirect(http.StatusTemporaryRedirect, "?")
}
return next(c)
+172
View File
@@ -2,12 +2,15 @@ package apis_test
import (
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests"
"github.com/spf13/cast"
)
func Test404(t *testing.T) {
@@ -66,6 +69,22 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
ExpectedStatus: 200,
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",
Method: http.MethodGet,
@@ -120,3 +139,156 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
scenario.Test(t)
}
}
func TestRemoveTrailingSlashMiddleware(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "non /api/* route (exact match)",
Method: http.MethodGet,
Url: "/custom",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "non /api/* route (with trailing slash)",
Method: http.MethodGet,
Url: "/custom/",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "/api/* route (exact match)",
Method: http.MethodGet,
Url: "/api/custom",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "/api/* route (with trailing slash)",
Method: http.MethodGet,
Url: "/api/custom/",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestEagerRequestDataCache(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "[UNKNOWN] unsupported eager cached request method",
Method: "UNKNOWN",
Url: "/custom",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: "UNKNOWN",
Path: "/custom",
Handler: func(c echo.Context) error {
data := &struct {
Name string `json:"name"`
}{}
if err := c.Bind(data); err != nil {
return err
}
// since the unknown method is not eager cache support
// it should fail reading the json body twice
r := apis.RequestData(c)
if v := cast.ToString(r.Data["name"]); v != "" {
t.Fatalf("Expected empty request data body, got, %v", r.Data)
}
return c.String(200, data.Name)
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
// supported eager cache request methods
supportedMethods := []string{"POST", "PUT", "PATCH", "DELETE"}
for _, m := range supportedMethods {
scenarios = append(
scenarios,
tests.ApiScenario{
Name: fmt.Sprintf("[%s] valid cached json body request", m),
Method: http.MethodPost,
Url: "/custom",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodPost,
Path: "/custom",
Handler: func(c echo.Context) error {
data := &struct {
Name string `json:"name"`
}{}
if err := c.Bind(data); err != nil {
return err
}
// try to read the body again
r := apis.RequestData(c)
if v := cast.ToString(r.Data["name"]); v != "test123" {
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
}
return c.String(200, data.Name)
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
)
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+48 -41
View File
@@ -1,6 +1,7 @@
package apis
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
@@ -42,11 +43,10 @@ func (api *collectionApi) list(c echo.Context) error {
return NewBadRequestError("", err)
}
event := &core.CollectionsListEvent{
HttpContext: c,
Collections: collections,
Result: result,
}
event := new(core.CollectionsListEvent)
event.HttpContext = c
event.Collections = collections
event.Result = result
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result)
@@ -59,10 +59,9 @@ func (api *collectionApi) view(c echo.Context) error {
return NewNotFoundError("", err)
}
event := &core.CollectionViewEvent{
HttpContext: c,
Collection: collection,
}
event := new(core.CollectionViewEvent)
event.HttpContext = c
event.Collection = collection
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -79,16 +78,17 @@ func (api *collectionApi) create(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionCreateEvent{
HttpContext: c,
Collection: collection,
}
event := new(core.CollectionCreateEvent)
event.HttpContext = c
event.Collection = collection
// create the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(m *models.Collection) error {
event.Collection = m
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if err := next(); err != nil {
if err := next(e.Collection); err != nil {
return NewBadRequestError("Failed to create the collection.", err)
}
@@ -98,7 +98,9 @@ func (api *collectionApi) create(c echo.Context) error {
})
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
@@ -117,16 +119,17 @@ func (api *collectionApi) update(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionUpdateEvent{
HttpContext: c,
Collection: collection,
}
event := new(core.CollectionUpdateEvent)
event.HttpContext = c
event.Collection = collection
// update the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(m *models.Collection) error {
event.Collection = m
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if err := next(); err != nil {
if err := next(e.Collection); err != nil {
return NewBadRequestError("Failed to update the collection.", err)
}
@@ -136,7 +139,9 @@ func (api *collectionApi) update(c echo.Context) error {
})
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
@@ -148,21 +153,22 @@ func (api *collectionApi) delete(c echo.Context) error {
return NewNotFoundError("", err)
}
event := &core.CollectionDeleteEvent{
HttpContext: c,
Collection: collection,
}
event := new(core.CollectionDeleteEvent)
event.HttpContext = c
event.Collection = collection
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
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
@@ -176,18 +182,17 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionsImportEvent{
HttpContext: c,
Collections: form.Collections,
}
event := new(core.CollectionsImportEvent)
event.HttpContext = c
event.Collections = form.Collections
// import collections
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
form.Collections = e.Collections // ensures that the form always has the latest changes
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func(imports []*models.Collection) error {
event.Collections = imports
if err := next(); err != nil {
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
if err := next(e.Collections); err != nil {
return NewBadRequestError("Failed to import the submitted collections.", err)
}
@@ -197,7 +202,9 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
})
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
+378 -19
View File
@@ -6,10 +6,13 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestCollectionsList(t *testing.T) {
@@ -42,7 +45,7 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":7`,
`"totalItems":10`,
`"items":[{`,
`"id":"_pb_users_auth_"`,
`"id":"v851q4r790rhknl"`,
@@ -51,6 +54,7 @@ func TestCollectionsList(t *testing.T) {
`"id":"sz5l5z67tg7gku0"`,
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"9n89pl5vkct6330"`,
`"type":"auth"`,
`"type":"base"`,
},
@@ -69,10 +73,10 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
`"totalItems":7`,
`"totalItems":10`,
`"items":[{`,
`"id":"4d1blo5cuycfaca"`,
`"id":"wzlqyes4orhoygb"`,
`"id":"kpv709sk2lqbqk8"`,
`"id":"9n89pl5vkct6330"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -99,12 +103,13 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":4`,
`"totalItems":5`,
`"items":[{`,
`"id":"wsmn24bux7wo113"`,
`"id":"sz5l5z67tg7gku0"`,
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"9n89pl5vkct6330"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -226,10 +231,11 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodDelete,
Url: "/api/collections/demo1",
Url: "/api/collections/demo5",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
@@ -238,16 +244,17 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "wsmn24bux7wo113")
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
{
Name: "authorized as admin + using the collection id",
Method: http.MethodDelete,
Url: "/api/collections/wsmn24bux7wo113",
Url: "/api/collections/9n89pl5vkct6330",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
@@ -256,7 +263,7 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "wsmn24bux7wo113")
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
{
@@ -285,6 +292,22 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
},
},
{
Name: "authorized as admin + deleting a view",
Method: http.MethodDelete,
Url: "/api/collections/view2",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -513,6 +536,133 @@ func TestCollectionCreate(t *testing.T) {
`"options":{"minPasswordLength":{"code":"validation_required"`,
},
},
// view
// -----------------------------------------------------------
{
Name: "trying to create view collection with invalid options",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"view",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options":{"query": "invalid"}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"options":{"query":{"code":"validation_invalid_view_query`,
},
},
{
Name: "creating view collection",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"view",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options": {
"query": "select 1 as id from _admins"
}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"view"`,
`"schema":[]`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
},
// indexes
// -----------------------------------------------------------
{
Name: "creating base collection with invalid indexes",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"test"}
],
"indexes": [
"create index idx_test1 on new (test)",
"create index idx_test2 on new (missing)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"indexes":{"1":{"code":"`,
},
ExpectedEvents: map[string]int{
"OnCollectionBeforeCreateRequest": 1,
"OnModelBeforeCreate": 1,
},
},
{
Name: "creating base collection with valid indexes (+ random table name)",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"test"}
],
"indexes": [
"create index idx_test1 on new (test)",
"create index idx_test2 on anything (id, test)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"base"`,
`"indexes":[`,
`idx_test1`,
`idx_test2`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
}
expected := []string{"idx_test1", "idx_test2"}
for name := range indexes {
if !list.ExistInSlice(name, expected) {
t.Fatalf("Missing index %q", name)
}
}
},
},
}
for _, scenario := range scenarios {
@@ -653,7 +803,7 @@ func TestCollectionUpdate(t *testing.T) {
{
Name: "updating base collection with reserved auth fields",
Method: http.MethodPatch,
Url: "/api/collections/demo1",
Url: "/api/collections/demo4",
Body: strings.NewReader(`{
"schema":[
{"type":"text","name":"email"},
@@ -674,7 +824,7 @@ func TestCollectionUpdate(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"demo1"`,
`"name":"demo4"`,
`"type":"base"`,
`"schema":[{`,
`"email"`,
@@ -742,6 +892,204 @@ func TestCollectionUpdate(t *testing.T) {
`"options":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required"`,
},
},
// rel field change displayFields propagation
// -----------------------------------------------------------
{
Name: "renaming a display field should also update the referenced displayFields value",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Body: strings.NewReader(`{
"schema":[
{
"id": "w5z2x0nq",
"type": "text",
"name": "title_change"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"title_change"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
options := relField.Options.(*schema.RelationOptions)
expectedDisplayFields := []string{"title_change", "id"}
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
}
},
},
{
Name: "deleting a display field should also update the referenced displayFields value",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Body: strings.NewReader(`{
"schema":[
{
"type": "text",
"name": "new_field"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new_field"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
options := relField.Options.(*schema.RelationOptions)
expectedDisplayFields := []string{"id"}
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
}
},
},
// view
// -----------------------------------------------------------
{
Name: "trying to update view collection with invalid options",
Method: http.MethodPatch,
Url: "/api/collections/view1",
Body: strings.NewReader(`{
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options":{"query": "invalid"}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"options":{"query":{"code":"validation_invalid_view_query`,
},
},
{
Name: "updating view collection",
Method: http.MethodPatch,
Url: "/api/collections/view2",
Body: strings.NewReader(`{
"name":"view2_update",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options": {
"query": "select 2 as id, created, updated, email from _admins"
}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"view2_update"`,
`"type":"view"`,
`"schema":[{`,
`"name":"email"`,
},
NotExpectedContent: []string{
// base model fields are not part of the schema
`"name":"id"`,
`"name":"created"`,
`"name":"updated"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
},
// indexes
// -----------------------------------------------------------
{
Name: "updating base collection with invalid indexes",
Method: http.MethodPatch,
Url: "/api/collections/demo2",
Body: strings.NewReader(`{
"indexes": [
"create unique idx_test1 on demo1 (text)",
"create index idx_test2 on demo2 (id, title)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"indexes":{"0":{"code":"`,
},
},
{
Name: "updating base collection with valid indexes (+ random table name)",
Method: http.MethodPatch,
Url: "/api/collections/demo2",
Body: strings.NewReader(`{
"indexes": [
"create unique index idx_test1 on demo2 (title)",
"create index idx_test2 on anything (active)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"demo2"`,
`"indexes":[`,
"CREATE UNIQUE INDEX `idx_test1`",
"CREATE INDEX `idx_test2`",
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
}
expected := []string{"idx_test1", "idx_test2"}
for name := range indexes {
if !list.ExistInSlice(name, expected) {
t.Fatalf("Missing index %q", name)
}
}
},
},
}
for _, scenario := range scenarios {
@@ -749,7 +1097,9 @@ func TestCollectionUpdate(t *testing.T) {
}
}
func TestCollectionImport(t *testing.T) {
func TestCollectionsImport(t *testing.T) {
totalCollections := 10
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -786,7 +1136,7 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 7
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -807,14 +1157,14 @@ func TestCollectionImport(t *testing.T) {
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 6,
"OnModelBeforeDelete": 4,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 7
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -856,7 +1206,7 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 7
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -886,6 +1236,9 @@ func TestCollectionImport(t *testing.T) {
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
},
{
@@ -909,10 +1262,16 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 10
expected := totalCollections + 3
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
indexes, err := app.Dao().TableIndexes("import2")
if err != nil || indexes["idx_test"] == "" {
t.Fatalf("Missing index %s (%v)", "idx_test", err)
}
},
},
{
@@ -996,8 +1355,8 @@ func TestCollectionImport(t *testing.T) {
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 5,
"OnModelAfterDelete": 5,
"OnModelBeforeDelete": 8,
"OnModelAfterDelete": 8,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
+162 -16
View File
@@ -1,11 +1,23 @@
package apis
import (
"errors"
"fmt"
"log"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
@@ -16,6 +28,8 @@ func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{app: app}
subGroup := rg.Group("/files", ActivityLogger(app))
subGroup.POST("/token", api.fileToken)
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
}
@@ -23,6 +37,37 @@ type fileApi struct {
app core.App
}
func (api *fileApi) fileToken(c echo.Context) error {
event := new(core.FileTokenEvent)
event.HttpContext = c
if admin, _ := c.Get(ContextAdminKey).(*models.Admin); admin != nil {
event.Model = admin
event.Token, _ = tokens.NewAdminFileToken(api.app, admin)
} else if record, _ := c.Get(ContextAuthRecordKey).(*models.Record); record != nil {
event.Model = record
event.Token, _ = tokens.NewRecordFileToken(api.app, record)
}
handlerErr := api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
if e.Model == nil || e.Token == "" {
return NewBadRequestError("Failed to generate file token.", nil)
}
return e.HttpContext.JSON(http.StatusOK, map[string]string{
"token": e.Token,
})
})
if handlerErr == nil {
if err := api.app.OnFileAfterTokenRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *fileApi) download(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
@@ -45,7 +90,33 @@ func (api *fileApi) download(c echo.Context) error {
if fileField == nil {
return NewNotFoundError("", nil)
}
options, _ := fileField.Options.(*schema.FileOptions)
options, ok := fileField.Options.(*schema.FileOptions)
if !ok {
return NewBadRequestError("", errors.New("Failed to load file options."))
}
// check whether the request is authorized to view the protected file
if options.Protected {
token := c.QueryParam("token")
adminOrAuthRecord, _ := api.findAdminOrAuthRecordByFileToken(token)
if !api.canAccessRecord(adminOrAuthRecord, record, record.Collection().ViewRule) {
return NewForbiddenError("Insufficient permissions to access the file resource.", nil)
}
}
baseFilesPath := record.BaseFilesPath()
// fetch the original view file field related record
if collection.IsView() {
fileRecord, err := api.app.Dao().FindRecordByViewFile(collection.Id, fileField.Name, filename)
if err != nil {
return NewNotFoundError("", fmt.Errorf("Failed to fetch view file field record: %w", err))
}
baseFilesPath = fileRecord.BaseFilesPath()
}
fs, err := api.app.NewFilesystem()
if err != nil {
@@ -53,7 +124,7 @@ func (api *fileApi) download(c echo.Context) error {
}
defer fs.Close()
originalPath := record.BaseFilesPath() + "/" + filename
originalPath := baseFilesPath + "/" + filename
servedPath := originalPath
servedName := filename
@@ -70,13 +141,10 @@ func (api *fileApi) download(c echo.Context) error {
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
// add thumb size as file suffix
servedName = thumbSize + "_" + filename
servedPath = record.BaseFilesPath() + "/thumbs_" + filename + "/" + servedName
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
// check if the thumb exists:
// - if doesn't exist - create a new thumb with the specified thumb size
// - if exists - compare last modified dates to determine whether the thumb should be recreated
tAttrs, tAttrsErr := fs.Attributes(servedPath)
if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) {
// create a new thumb if it doesn exists
if exists, _ := fs.Exists(servedPath); !exists {
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
servedPath = originalPath // fallback to the original
}
@@ -84,14 +152,18 @@ func (api *fileApi) download(c echo.Context) error {
}
}
event := &core.FileDownloadEvent{
HttpContext: c,
Record: record,
Collection: collection,
FileField: fileField,
ServedPath: servedPath,
ServedName: servedName,
}
event := new(core.FileDownloadEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
event.FileField = fileField
event.ServedPath = servedPath
event.ServedName = servedName
// clickjacking shouldn't be a concern when serving uploaded files,
// so it safe to unset the global X-Frame-Options to allow files embedding
// (note: it is out of the hook to allow users to customize the behavior)
c.Response().Header().Del("X-Frame-Options")
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
res := e.HttpContext.Response()
@@ -103,3 +175,77 @@ func (api *fileApi) download(c echo.Context) error {
return nil
})
}
func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.Model, error) {
fileToken = strings.TrimSpace(fileToken)
if fileToken == "" {
return nil, errors.New("missing file token")
}
claims, _ := security.ParseUnverifiedJWT(strings.TrimSpace(fileToken))
tokenType := cast.ToString(claims["type"])
switch tokenType {
case tokens.TypeAdmin:
admin, err := api.app.Dao().FindAdminByToken(
fileToken,
api.app.Settings().AdminFileToken.Secret,
)
if err == nil && admin != nil {
return admin, nil
}
case tokens.TypeAuthRecord:
record, err := api.app.Dao().FindAuthRecordByToken(
fileToken,
api.app.Settings().RecordFileToken.Secret,
)
if err == nil && record != nil {
return record, nil
}
}
return nil, errors.New("missing or invalid file token")
}
// @todo move to a helper and maybe combine with the realtime checks when refactoring the realtime service
func (api *fileApi) canAccessRecord(adminOrAuthRecord models.Model, record *models.Record, accessRule *string) bool {
admin, _ := adminOrAuthRecord.(*models.Admin)
if admin != nil {
// admins can access everything
return true
}
if accessRule == nil {
// only admins can access this record
return false
}
ruleFunc := func(q *dbx.SelectQuery) error {
if *accessRule == "" {
return nil // empty public rule
}
// mock request data
requestData := &models.RequestData{
Method: "GET",
}
requestData.AuthRecord, _ = adminOrAuthRecord.(*models.Record)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
if err == nil && foundRecord != nil {
return true
}
return false
}
+203
View File
@@ -8,9 +8,85 @@ import (
"runtime"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestFileToken(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/files/token",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
},
},
{
Name: "unauthorized with model and token via hook",
Method: http.MethodPost,
Url: "/api/files/token",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error {
record, _ := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
e.Model = record
e.Token = "test"
return nil
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"test"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
{
Name: "auth record",
Method: http.MethodPost,
Url: "/api/files/token",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
{
Name: "admin",
Method: http.MethodPost,
Url: "/api/files/token",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestFileDownload(t *testing.T) {
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
@@ -176,9 +252,136 @@ func TestFileDownload(t *testing.T) {
"OnFileDownloadRequest": 1,
},
},
// protected file access checks
{
Name: "protected file - expired token",
Method: http.MethodGet,
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - admin with expired file token",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - admin with valid file token",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - guest without view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - guest with view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock public view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - auth record without view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock restricted user view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("@request.auth.verified = true")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - auth record with view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock user view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("@request.auth.verified = false")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file in view (view's View API rule failure)",
Method: http.MethodGet,
Url: "/api/files/view1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file in view (view's View API rule success)",
Method: http.MethodGet,
Url: "/api/files/view1/84nmscqy84lsi1t/test_d61b33QdDU.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
ExpectedStatus: 200,
ExpectedContent: []string{"test"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
}
for _, scenario := range scenarios {
// clone for the HEAD test (the same as the original scenario but without body)
head := scenario
head.Method = http.MethodHead
head.Name = ("(HEAD) " + scenario.Name)
head.ExpectedContent = nil
head.Test(t)
// regular request test
scenario.Test(t)
}
}
+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)
}
}
+25 -18
View File
@@ -59,9 +59,12 @@ func RequireGuestOnly() echo.MiddlewareFunc {
// specifying their names.
//
// Example:
// apis.RequireRecordAuth()
//
// apis.RequireRecordAuth()
//
// Or:
// apis.RequireRecordAuth("users", "supervisors")
//
// apis.RequireRecordAuth("users", "supervisors")
//
// To restrict the auth record only to the loaded context collection,
// use [apis.RequireSameContextRecordAuth()] instead.
@@ -83,7 +86,6 @@ func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
}
}
//
// RequireSameContextRecordAuth middleware requires a request to have
// a valid record Authorization header.
//
@@ -261,7 +263,7 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
}
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
return NewBadRequestError("Invalid collection type.", nil)
return NewBadRequestError("Unsupported collection type.", nil)
}
c.Set(ContextCollectionKey, collection)
@@ -319,7 +321,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
model := &models.Request{
Url: httpRequest.URL.RequestURI(),
Method: strings.ToLower(httpRequest.Method),
Method: strings.ToUpper(httpRequest.Method),
Status: status,
Auth: requestAuth,
UserIp: realUserIp(httpRequest, ip),
@@ -333,19 +335,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
model.RefreshUpdated()
routine.FireAndForget(func() {
attempts := 1
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)
}
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
log.Println("Log save failed:", err)
}
// Delete old request logs
@@ -394,3 +385,19 @@ func realUserIp(r *http.Request, fallbackIp string) string {
return fallbackIp
}
// eagerRequestDataCache ensures that the request data is cached in the request
// context to allow reading for example the json request body data more than once.
func eagerRequestDataCache(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
switch c.Request().Method {
// currently we are eagerly caching only the requests with body
case "POST", "PUT", "PATCH", "DELETE":
RequestData(c)
}
return next(c)
}
}
}
+68 -22
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v5"
@@ -15,6 +16,7 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
@@ -43,10 +45,14 @@ func (api *realtimeApi) connect(c echo.Context) error {
client := subscriptions.NewDefaultClient()
api.app.SubscriptionsBroker().Register(client)
defer func() {
api.app.OnRealtimeDisconnectRequest().Trigger(&core.RealtimeDisconnectEvent{
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())
}()
@@ -210,7 +216,9 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
for _, client := range api.app.SubscriptionsBroker().Clients() {
clientModel, _ := client.Get(contextKey).(models.Model)
if clientModel != nil && clientModel.GetId() == newModel.GetId() {
if clientModel != nil &&
clientModel.TableName() == newModel.TableName() &&
clientModel.GetId() == newModel.GetId() {
client.Set(contextKey, newModel)
}
}
@@ -222,7 +230,9 @@ func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel model
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
for _, client := range api.app.SubscriptionsBroker().Clients() {
clientModel, _ := client.Get(contextKey).(models.Model)
if clientModel != nil && clientModel.GetId() == model.GetId() {
if clientModel != nil &&
clientModel.TableName() == model.TableName() &&
clientModel.GetId() == model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id())
}
}
@@ -233,7 +243,7 @@ func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model mo
func (api *realtimeApi) bindEvents() {
// update the clients that has admin or auth record association
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
if record := api.resolveRecord(e.Model); record != nil && record.Collection().IsAuth() {
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
}
@@ -246,8 +256,8 @@ func (api *realtimeApi) bindEvents() {
// remove the client(s) associated to the deleted admin or auth record
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
if collection := api.resolveRecordCollection(e.Model); collection != nil && collection.IsAuth() {
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, e.Model)
}
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
@@ -258,27 +268,60 @@ func (api *realtimeApi) bindEvents() {
})
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("create", record)
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return nil
})
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("update", record)
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return nil
})
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("delete", record)
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return nil
})
}
// resolveRecord converts *if possible* the provided model interface to a Record.
// This is usually helpful if the provided model is a custom Record model struct.
func (api *realtimeApi) resolveRecord(model models.Model) (record *models.Record) {
record, _ = model.(*models.Record)
// check if it is custom Record model struct (ignore "private" tables)
if record == nil && !strings.HasPrefix(model.TableName(), "_") {
record, _ = api.app.Dao().FindRecordById(model.TableName(), model.GetId())
}
return record
}
// resolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
// This is usually helpful if the provided model is a custom Record model struct.
func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection *models.Collection) {
if record, ok := model.(*models.Record); ok {
collection = record.Collection()
} else if !strings.HasPrefix(model.TableName(), "_") {
// check if it is custom Record model struct (ignore "private" tables)
collection, _ = api.app.Dao().FindCollectionByNameOrId(model.TableName())
}
return collection
}
// canAccessRecord checks if the subscription client has access to the specified record model.
func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
admin, _ := client.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
@@ -296,7 +339,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
return nil // empty public rule
}
// emulate request data
// mock request data
requestData := &models.RequestData{
Method: "GET",
}
@@ -337,12 +380,9 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return nil // no subscribers
}
// remove the expand from the broadcasted record because we don't
// know if the clients have access to view the expanded records
cleanRecord := *record
cleanRecord.SetExpand(nil)
cleanRecord.WithUnkownData(false)
cleanRecord.IgnoreEmailVisibility(false)
// create a clean record copy without expand and unknown fields
// because we don't know if the clients have permissions to view them
cleanRecord := record.CleanCopy()
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
@@ -356,7 +396,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
data := &recordData{
Action: action,
Record: &cleanRecord,
Record: cleanRecord,
}
dataBytes, err := json.Marshal(data)
@@ -370,6 +410,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
encodedData := string(dataBytes)
for _, client := range clients {
client := client
for subscription, rule := range subscriptionRuleMap {
if !client.HasSubscription(subscription) {
continue
@@ -398,7 +440,11 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
}
}
client.Channel() <- msg
routine.FireAndForget(func() {
if !client.IsDiscarded() {
client.Channel() <- msg
}
})
}
}
+111 -4
View File
@@ -7,8 +7,10 @@ import (
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
@@ -253,7 +255,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = authRecord
testApp.OnModelAfterDelete().Trigger(e)
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
@@ -282,7 +287,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
}
authRecord2.SetEmail("new@example.com")
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = authRecord2
testApp.OnModelAfterUpdate().Trigger(e)
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord.Email() != authRecord2.Email() {
@@ -305,7 +313,10 @@ func TestRealtimeAdminDeleteEvent(t *testing.T) {
client.Set(apis.ContextAdminKey, admin)
testApp.SubscriptionsBroker().Register(client)
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = admin
testApp.OnModelAfterDelete().Trigger(e)
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
@@ -334,10 +345,106 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
}
admin2.Email = "new@example.com"
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin2})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = admin2
testApp.OnModelAfterUpdate().Trigger(e)
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if clientAdmin.Email != admin2.Email {
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
}
}
// Custom auth record model struct
// -------------------------------------------------------------------
var _ models.Model = (*CustomUser)(nil)
type CustomUser struct {
models.BaseModel
Email string `db:"email" json:"email"`
}
func (m *CustomUser) TableName() string {
return "users" // the name of your collection
}
func findCustomUserByEmail(dao *daos.Dao, email string) (*CustomUser, error) {
model := &CustomUser{}
err := dao.ModelQuery(model).
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord as CustomUser
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
if err != nil {
t.Fatal(err)
}
// delete the custom user (should unset the client auth record)
if err := testApp.Dao().Delete(customUser); err != nil {
t.Fatal(err)
}
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
}
}
func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord as CustomUser
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
if err != nil {
t.Fatal(err)
}
// change its email
customUser.Email = "new@example.com"
if err := testApp.Dao().Save(customUser); err != nil {
t.Fatal(err)
}
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord.Email() != customUser.Email {
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
}
}
+189 -104
View File
@@ -1,11 +1,11 @@
package apis
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
@@ -14,11 +14,11 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"golang.org/x/oauth2"
)
@@ -27,16 +27,19 @@ import (
func bindRecordAuthApi(app core.App, rg *echo.Group) {
api := recordAuthApi{app: app}
// global oauth2 subscription redirect handler
rg.GET("/oauth2-redirect", api.oauth2SubscriptionRedirect)
// common collection record related routes
subGroup := rg.Group(
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app, models.CollectionTypeAuth),
)
subGroup.GET("/auth-methods", api.authMethods)
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2) // allow anyone so that we can link the OAuth2 profile with the authenticated record
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
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)
@@ -51,60 +54,28 @@ type recordAuthApi struct {
app core.App
}
func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record, meta any) error {
token, tokenErr := tokens.NewRecordAuthToken(api.app, authRecord)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.RecordAuthEvent{
HttpContext: c,
Record: authRecord,
Token: token,
Meta: meta,
}
return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
// allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true)
// expand record relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
// create a copy of the cached request data and adjust it to the current auth record
requestData := *RequestData(e.HttpContext)
requestData.Admin = nil
requestData.AuthRecord = e.Record
failed := api.app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(api.app.Dao(), &requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
result := map[string]any{
"token": e.Token,
"record": e.Record,
}
if e.Meta != nil {
result["meta"] = e.Meta
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
func (api *recordAuthApi) authRefresh(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewNotFoundError("Missing auth record context.", nil)
}
return api.authResponse(c, record, nil)
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 {
@@ -163,6 +134,16 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
codeVerifier := security.RandomString(43)
codeChallenge := security.S256Challenge(codeVerifier)
codeChallengeMethod := "S256"
urlOpts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
}
if name == auth.NameApple {
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
}
result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
@@ -171,9 +152,8 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
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
urlOpts...,
) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
})
}
@@ -202,10 +182,17 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
event := new(core.RecordAuthWithOAuth2Event)
event.HttpContext = c
event.Collection = collection
event.ProviderName = form.Provider
event.IsNewRecord = false
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := RequestData(c)
requestData.Data = form.CreateData
event.IsNewRecord = true
createRuleFunc := func(q *dbx.SelectQuery) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -237,11 +224,36 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
return nil
})
})
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
return func(data *forms.RecordOAuth2LoginData) error {
event.Record = data.Record
event.OAuth2User = data.OAuth2User
event.ProviderClient = data.ProviderClient
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
data.Record = e.Record
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 api.authResponse(c, record, authData)
return submitErr
}
func (api *recordAuthApi) authWithPassword(c echo.Context) error {
@@ -255,12 +267,33 @@ func (api *recordAuthApi) authWithPassword(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
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 api.authResponse(c, record, nil)
return submitErr
}
func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
@@ -283,11 +316,11 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while validating the form.", err)
}
event := &core.RecordRequestPasswordResetEvent{
HttpContext: c,
}
event := new(core.RecordRequestPasswordResetEvent)
event.HttpContext = c
event.Collection = collection
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -305,7 +338,9 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event)
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
@@ -329,11 +364,11 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmPasswordResetEvent{
HttpContext: c,
}
event := new(core.RecordConfirmPasswordResetEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -348,7 +383,9 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event)
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -369,11 +406,11 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while validating the form.", err)
}
event := &core.RecordRequestVerificationEvent{
HttpContext: c,
}
event := new(core.RecordRequestVerificationEvent)
event.HttpContext = c
event.Collection = collection
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -391,7 +428,9 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterRequestVerificationRequest().Trigger(event)
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
@@ -415,11 +454,11 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmVerificationEvent{
HttpContext: c,
}
event := new(core.RecordConfirmVerificationEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -434,13 +473,20 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event)
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)
@@ -451,12 +497,12 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.RecordRequestEmailChangeEvent{
HttpContext: c,
Record: record,
}
event := new(core.RecordRequestEmailChangeEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
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 {
@@ -486,11 +532,11 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmEmailChangeEvent{
HttpContext: c,
}
event := new(core.RecordConfirmEmailChangeEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -505,7 +551,9 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event)
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -532,11 +580,11 @@ func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
}
event := &core.RecordListExternalAuthsEvent{
HttpContext: c,
Record: record,
ExternalAuths: externalAuths,
}
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)
@@ -565,11 +613,11 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
return NewNotFoundError("Missing external auth provider relation.", err)
}
event := &core.RecordUnlinkExternalAuthEvent{
HttpContext: c,
Record: record,
ExternalAuth: externalAuth,
}
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 {
@@ -585,3 +633,40 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
return handlerErr
}
// -------------------------------------------------------------------
const oauth2SubscriptionTopic = "@oauth2"
func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
state := c.QueryParam("state")
code := c.QueryParam("code")
if code == "" || state == "" {
return NewBadRequestError("Invalid OAuth2 redirect parameters.", nil)
}
client, err := api.app.SubscriptionsBroker().ClientById(state)
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
return NewNotFoundError("Missing or invalid OAuth2 subscription client.", err)
}
data := map[string]string{
"state": state,
"code": code,
}
encodedData, err := json.Marshal(data)
if err != nil {
return NewBadRequestError("Failed to marshalize OAuth2 redirect data.", err)
}
msg := subscriptions.Message{
Name: oauth2SubscriptionTopic,
Data: string(encodedData),
}
client.Channel() <- msg
return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
}
+196 -23
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"context"
"net/http"
"strings"
"testing"
@@ -9,6 +10,7 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -66,26 +68,6 @@ func TestRecordAuthMethodsList(t *testing.T) {
func TestRecordAuthWithPassword(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "authenticated record",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authenticated admin",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "invalid body format",
Method: http.MethodPost,
@@ -120,6 +102,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid username and invalid password",
@@ -133,6 +118,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid username and valid password in restricted collection",
@@ -146,6 +134,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid username and valid password in allowed collection",
@@ -163,7 +154,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"email":"test2@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
@@ -180,6 +173,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid email and invalid password",
@@ -193,6 +189,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid email and valid password in restricted collection",
@@ -206,6 +205,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid email and valid password in allowed collection",
@@ -223,7 +225,59 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
// with already authenticated record or admin
{
Name: "authenticated record",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
Body: strings.NewReader(`{
"identity":"test@example.com",
"password":"1234567890"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"record":{`,
`"token":"`,
`"id":"4q1xlclmfloku33"`,
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
{
Name: "authenticated admin",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Body: strings.NewReader(`{
"identity":"test@example.com",
"password":"1234567890"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"record":{`,
`"token":"`,
`"id":"4q1xlclmfloku33"`,
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
}
@@ -294,7 +348,9 @@ func TestRecordAuthRefresh(t *testing.T) {
`"missing":`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthRefreshRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordAfterAuthRefreshRequest": 1,
},
},
}
@@ -1090,3 +1146,120 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
scenario.Test(t)
}
}
func TestRecordAuthOAuth2Redirect(t *testing.T) {
c1 := subscriptions.NewDefaultClient()
c2 := subscriptions.NewDefaultClient()
c2.Subscribe("@oauth2")
c3 := subscriptions.NewDefaultClient()
c3.Subscribe("test1", "@oauth2")
c4 := subscriptions.NewDefaultClient()
c4.Subscribe("test1", "test2")
c5 := subscriptions.NewDefaultClient()
c5.Subscribe("@oauth2")
c5.Discard()
beforeTestFunc := func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(c1)
app.SubscriptionsBroker().Register(c2)
app.SubscriptionsBroker().Register(c3)
app.SubscriptionsBroker().Register(c4)
app.SubscriptionsBroker().Register(c5)
}
scenarios := []tests.ApiScenario{
{
Name: "no state query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "no code query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?state=" + c3.Id(),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing client",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=missing",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "discarded client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c5.Id(),
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "client without @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c4.Id(),
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c3.Id(),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
beforeTestFunc(t, app, e)
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
go func() {
defer cancelFunc()
L:
for {
select {
case <-c1.Channel():
t.Error("Unexpected c1 message")
break L
case <-c2.Channel():
t.Error("Unexpected c2 message")
break L
case msg := <-c3.Channel():
if msg.Name != "@oauth2" {
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
}
expectedParams := []string{`"state"`, `"code"`}
for _, p := range expectedParams {
if !strings.Contains(msg.Data, p) {
t.Errorf("Couldn't find %s in \n%v", p, msg.Data)
}
}
break L
case <-c4.Channel():
t.Error("Unexpected c4 message")
break L
case <-c5.Channel():
t.Error("Unexpected c5 message")
break L
case <-ctx.Done():
t.Error("Context timeout reached")
break L
}
}
}()
},
ExpectedStatus: http.StatusTemporaryRedirect,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+74 -50
View File
@@ -26,14 +26,13 @@ func bindRecordCrudApi(app core.App, rg *echo.Group) {
subGroup := rg.Group(
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app),
)
subGroup.GET("/records", api.list)
subGroup.POST("/records", api.create)
subGroup.GET("/records/:id", api.view)
subGroup.PATCH("/records/:id", api.update)
subGroup.DELETE("/records/:id", api.delete)
subGroup.GET("/records", api.list, LoadCollectionContext(app))
subGroup.GET("/records/:id", api.view, LoadCollectionContext(app))
subGroup.POST("/records", api.create, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
subGroup.PATCH("/records/:id", api.update, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
subGroup.DELETE("/records/:id", api.delete, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
}
type recordApi struct {
@@ -73,22 +72,18 @@ func (api *recordApi) list(c echo.Context) error {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
records := []*models.Record{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
if err != nil {
return NewBadRequestError("Invalid filter parameters.", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
result.Items = records
event := &core.RecordsListEvent{
HttpContext: c,
Collection: collection,
Records: records,
Result: result,
}
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() {
@@ -135,10 +130,10 @@ func (api *recordApi) view(c echo.Context) error {
return NewNotFoundError("", fetchErr)
}
event := &core.RecordViewEvent{
HttpContext: c,
Record: record,
}
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() {
@@ -166,6 +161,20 @@ func (api *recordApi) create(c echo.Context) error {
// 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
@@ -181,13 +190,6 @@ func (api *recordApi) create(c echo.Context) error {
return nil
}
testRecord := models.NewRecord(collection)
testForm := forms.NewRecordUpsert(api.app, testRecord)
testForm.SetFullManageAccess(true)
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
if err != nil {
@@ -211,16 +213,19 @@ func (api *recordApi) create(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordCreateEvent{
HttpContext: c,
Record: record,
}
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) forms.InterceptorNextFunc {
return func() error {
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(); err != nil {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to create record.", err)
}
@@ -234,7 +239,9 @@ func (api *recordApi) create(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterCreateRequest().Trigger(event)
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -258,6 +265,16 @@ func (api *recordApi) update(c echo.Context) error {
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)
@@ -285,16 +302,19 @@ func (api *recordApi) update(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordUpdateEvent{
HttpContext: c,
Record: record,
}
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) forms.InterceptorNextFunc {
return func() error {
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(); err != nil {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to update record.", err)
}
@@ -308,7 +328,9 @@ func (api *recordApi) update(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterUpdateRequest().Trigger(event)
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -350,10 +372,10 @@ func (api *recordApi) delete(c echo.Context) error {
return NewNotFoundError("", fetchErr)
}
event := &core.RecordDeleteEvent{
HttpContext: c,
Record: record,
}
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
@@ -365,7 +387,9 @@ func (api *recordApi) delete(c echo.Context) error {
})
if handlerErr == nil {
api.app.OnRecordAfterDeleteRequest().Trigger(event)
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
+322 -6
View File
@@ -2,10 +2,12 @@ package apis_test
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/models"
@@ -209,8 +211,52 @@ func TestRecordCrudList(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: ":rule modifer",
Method: http.MethodGet,
Url: "/api/collections/demo5/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":1`,
`"items":[{`,
`"id":"qjeql998mtp1azp"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "multi-match - at least one of",
Method: http.MethodGet,
Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":1`,
`"items":[{`,
`"id":"qzaqccwrmva4o1n"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "multi-match - all",
Method: http.MethodGet,
Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":0`,
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
// auth collection checks
// auth collection
// -----------------------------------------------------------
{
Name: "check email visibility as guest",
@@ -357,6 +403,63 @@ func TestRecordCrudList(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
// view collection
// -----------------------------------------------------------
{
Name: "public view records",
Method: http.MethodGet,
Url: "/api/collections/view2/records?filter=state=false",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":2`,
`"items":[{`,
`"id":"al1h9ijdeojtsjy"`,
`"id":"imy661ixudk5izi"`,
},
NotExpectedContent: []string{
`"created"`,
`"updated"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "guest that doesn't match the view collection list rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":0`,
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "authenticated record that matches the view collection list rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records",
RequestHeaders: map[string]string{
// users, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":1`,
`"items":[{`,
`"id":"84nmscqy84lsi1t"`,
`"bool":true`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
}
for _, scenario := range scenarios {
@@ -485,7 +588,7 @@ func TestRecordCrudView(t *testing.T) {
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
// auth collection checks
// auth collection
// -----------------------------------------------------------
{
Name: "check email visibility as guest",
@@ -583,6 +686,49 @@ func TestRecordCrudView(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
// view collection
// -----------------------------------------------------------
{
Name: "public view record",
Method: http.MethodGet,
Url: "/api/collections/view2/records/84nmscqy84lsi1t",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"84nmscqy84lsi1t"`,
`"state":true`,
`"file_many":["`,
`"rel_many":["`,
},
NotExpectedContent: []string{
`"created"`,
`"updated"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "guest that doesn't match the view collection view rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records/84nmscqy84lsi1t",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authenticated record that matches the view collection view rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records/84nmscqy84lsi1t",
RequestHeaders: map[string]string{
// users, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"84nmscqy84lsi1t"`,
`"bool":true`,
`"text":"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
}
for _, scenario := range scenarios {
@@ -644,6 +790,13 @@ func TestRecordCrudDelete(t *testing.T) {
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to delete a view collection record",
Method: http.MethodDelete,
Url: "/api/collections/view1/records/imy661ixudk5izi",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection record delete",
Method: http.MethodDelete,
@@ -684,18 +837,19 @@ func TestRecordCrudDelete(t *testing.T) {
},
},
{
Name: "authenticated record that does match the collection delete rule",
Name: "authenticated record that match the collection delete rule",
Method: http.MethodDelete,
Url: "/api/collections/users/records/4q1xlclmfloku33",
RequestHeaders: map[string]string{
// users, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelAfterDelete": 3, // +2 because of the external auths
"OnModelBeforeDelete": 3, // +2 because of the external auths
"OnModelAfterUpdate": 1,
"OnModelBeforeDelete": 1,
"OnModelBeforeUpdate": 1,
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
@@ -716,6 +870,25 @@ func TestRecordCrudDelete(t *testing.T) {
}
},
},
{
Name: "@request :isset (rule failure check)",
Method: http.MethodDelete,
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "@request :isset (rule pass check)",
Method: http.MethodDelete,
Url: "/api/collections/demo5/records/la4y2w4o98acwuj?test=1",
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
},
// cascade delete checks
// -----------------------------------------------------------
@@ -730,7 +903,7 @@ func TestRecordCrudDelete(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnModelBeforeUpdate": 1, // self_rel_many update of test1 record
"OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
},
},
@@ -758,6 +931,7 @@ func TestRecordCrudDelete(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 2,
@@ -818,6 +992,14 @@ func TestRecordCrudCreate(t *testing.T) {
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to create a new view collection record",
Method: http.MethodPost,
Url: "/api/collections/view1/records",
Body: strings.NewReader(`{"text":"new"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit nil body",
Method: http.MethodPost,
@@ -991,6 +1173,20 @@ func TestRecordCrudCreate(t *testing.T) {
"OnModelAfterCreate": 1,
},
},
{
Name: "unique field error check",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{
"title":"test2"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"title":{`,
`"code":"validation_not_unique"`,
},
},
// ID checks
// -----------------------------------------------------------
@@ -1092,6 +1288,63 @@ func TestRecordCrudCreate(t *testing.T) {
},
},
// fields modifier checks
// -----------------------------------------------------------
{
Name: "trying to delete a record while being part of a non-cascade required relation",
Method: http.MethodDelete,
Url: "/api/collections/demo3/records/7nwo8tuiatetxdm",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
},
},
// check whether if @request.data modifer fields are properly resolved
// -----------------------------------------------------------
{
Name: "@request.data.field with compute modifers (rule failure check)",
Method: http.MethodPost,
Url: "/api/collections/demo5/records",
Body: strings.NewReader(`{
"total":1,
"total+":4,
"total-":1
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
},
{
Name: "@request.data.field with compute modifers (rule pass check)",
Method: http.MethodPost,
Url: "/api/collections/demo5/records",
Body: strings.NewReader(`{
"total":1,
"total+":3,
"total-":1
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"`,
`"collectionName":"demo5"`,
`"total":3`,
},
ExpectedEvents: map[string]int{
"OnModelAfterCreate": 1,
"OnModelBeforeCreate": 1,
"OnRecordAfterCreateRequest": 1,
"OnRecordBeforeCreateRequest": 1,
},
},
// auth records
// -----------------------------------------------------------
{
@@ -1304,6 +1557,14 @@ func TestRecordCrudUpdate(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to update a view collection record",
Method: http.MethodPatch,
Url: "/api/collections/view1/records/imy661ixudk5izi",
Body: strings.NewReader(`{"text":"new"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit nil body",
Method: http.MethodPatch,
@@ -1500,6 +1761,61 @@ func TestRecordCrudUpdate(t *testing.T) {
`"id":{"code":"validation_in_invalid"`,
},
},
{
Name: "unique field error check",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/llvuca81nly1qls",
Body: strings.NewReader(`{
"title":"test2"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"title":{`,
`"code":"validation_not_unique"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
},
},
// check whether if @request.data modifer fields are properly resolved
// -----------------------------------------------------------
{
Name: "@request.data.field with compute modifers (rule failure check)",
Method: http.MethodPatch,
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
Body: strings.NewReader(`{
"total+":3,
"total-":1
}`),
ExpectedStatus: 404,
ExpectedContent: []string{
`"data":{}`,
},
},
{
Name: "@request.data.field with compute modifers (rule pass check)",
Method: http.MethodPatch,
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
Body: strings.NewReader(`{
"total+":2,
"total-":1
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"la4y2w4o98acwuj"`,
`"collectionName":"demo5"`,
`"total":3`,
},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
},
// auth records
// -----------------------------------------------------------
+69 -14
View File
@@ -2,24 +2,23 @@ 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"
// Deprecated: Will be removed after v0.9. Use apis.RequestData(c) instead.
func GetRequestData(c echo.Context) *models.RequestData {
return RequestData(c)
}
// RequestData exports cached common request data fields
// (query, body, logged auth state, etc.) from the provided context.
func RequestData(c echo.Context) *models.RequestData {
@@ -31,9 +30,18 @@ func RequestData(c echo.Context) *models.RequestData {
}
result := &models.RequestData{
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
Headers: map[string]any{},
}
// extract the first value of all headers and normalizes the keys
// ("X-Token" is converted to "x_token")
for k, v := range c.Request().Header {
if len(v) > 0 {
result.Headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0]
}
}
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
@@ -46,18 +54,65 @@ func RequestData(c echo.Context) *models.RequestData {
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
// - 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
// - 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)
+119
View File
@@ -17,6 +17,7 @@ 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)
req.Header.Set("X-Token-Test", "123")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
@@ -38,6 +39,12 @@ func TestRequestData(t *testing.T) {
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
}
rawHeaders, _ := json.Marshal(result.Headers)
expectedHeaders := `{"content_type":"application/json","x_token_test":"123"}`
if v := string(rawHeaders); v != expectedHeaders {
t.Fatalf("Expected Query %v, got %v", expectedHeaders, v)
}
rawQuery, _ := json.Marshal(result.Query)
expectedQuery := `{"test":"123"}`
if v := string(rawQuery); v != expectedQuery {
@@ -59,6 +66,118 @@ func TestRequestData(t *testing.T) {
}
}
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)
+50 -17
View File
@@ -1,12 +1,15 @@
package apis
import (
"fmt"
"log"
"net/http"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -19,6 +22,7 @@ func bindSettingsApi(app core.App, rg *echo.Group) {
subGroup.PATCH("", api.set)
subGroup.POST("/test/s3", api.testS3)
subGroup.POST("/test/email", api.testEmail)
subGroup.POST("/apple/generate-client-secret", api.generateAppleClientSecret)
}
type settingsApi struct {
@@ -31,10 +35,9 @@ func (api *settingsApi) list(c echo.Context) error {
return NewBadRequestError("", err)
}
event := &core.SettingsListEvent{
HttpContext: c,
RedactedSettings: settings,
}
event := new(core.SettingsListEvent)
event.HttpContext = c
event.RedactedSettings = settings
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
@@ -49,17 +52,17 @@ func (api *settingsApi) set(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.SettingsUpdateEvent{
HttpContext: c,
OldSettings: api.app.Settings(),
NewSettings: form.Settings,
}
event := new(core.SettingsUpdateEvent)
event.HttpContext = c
event.OldSettings = api.app.Settings()
// update the settings
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
event.NewSettings = s
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := next(); err != nil {
if err := next(e.NewSettings); err != nil {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
@@ -74,7 +77,9 @@ func (api *settingsApi) set(c echo.Context) error {
})
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
@@ -91,14 +96,17 @@ func (api *settingsApi) testS3(c echo.Context) error {
}
defer fs.Close()
testFileKey := "pb_test_" + security.PseudorandomString(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 {
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
}
if err := fs.Delete(testFileKey); err != nil {
return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
// test prefix deletion (ensures that both bucket list and delete works)
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
}
return c.NoContent(http.StatusNoContent)
@@ -114,8 +122,8 @@ func (api *settingsApi) testEmail(c echo.Context) error {
// send
if err := form.Submit(); err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
// form error
return NewBadRequestError("Failed to send the test email.", fErr)
}
@@ -125,3 +133,28 @@ func (api *settingsApi) testEmail(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
func (api *settingsApi) generateAppleClientSecret(c echo.Context) error {
form := forms.NewAppleClientSecretCreate(api.app)
// load request
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
// generate
secret, err := form.Submit()
if err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
return NewBadRequestError("Invalid client secret data.", fErr)
}
// secret generation error
return NewBadRequestError("Failed to generate client secret. Raw error: \n"+err.Error(), nil)
}
return c.JSON(http.StatusOK, map[string]any{
"secret": secret,
})
}
+167 -6
View File
@@ -1,6 +1,12 @@
package apis_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"net/http"
"strings"
"testing"
@@ -43,10 +49,12 @@ func TestSettingsList(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -58,6 +66,14 @@ func TestSettingsList(t *testing.T) {
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -111,10 +127,12 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -125,6 +143,14 @@ func TestSettingsSet(t *testing.T) {
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -166,10 +192,12 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -181,6 +209,14 @@ func TestSettingsSet(t *testing.T) {
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
@@ -303,8 +339,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[verification] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
@@ -334,8 +374,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[password-reset] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
@@ -365,8 +409,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[email-change] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
@@ -386,3 +434,116 @@ func TestSettingsTestEmail(t *testing.T) {
scenario.Test(t)
}
}
func TestGenerateAppleClientSecret(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
privatePem := pem.EncodeToMemory(
&pem.Block{
Type: "PRIVATE KEY",
Bytes: encodedKey,
},
)
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (invalid body)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (empty json)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"clientId":{"code":"validation_required"`,
`"teamId":{"code":"validation_required"`,
`"keyId":{"code":"validation_required"`,
`"privateKey":{"code":"validation_required"`,
`"duration":{"code":"validation_required"`,
},
},
{
Name: "authorized as admin (invalid data)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{
"clientId": "",
"teamId": "123456789",
"keyId": "123456789",
"privateKey": "invalid",
"duration": -1
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"clientId":{"code":"validation_required"`,
`"teamId":{"code":"validation_length_invalid"`,
`"keyId":{"code":"validation_length_invalid"`,
`"privateKey":{"code":"validation_match_invalid"`,
`"duration":{"code":"validation_min_greater_equal_than_required"`,
},
},
{
Name: "authorized as admin (valid data)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(fmt.Sprintf(`{
"clientId": "123",
"teamId": "1234567890",
"keyId": "1234567891",
"privateKey": %q,
"duration": 1
}`, privatePem)),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"secret":"`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+16 -5
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
@@ -78,7 +79,8 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
ReadTimeout: 60 * time.Second,
ReadTimeout: 5 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
// WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router,
Addr: mainAddr,
@@ -89,11 +91,20 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
if httpsAddr != "" {
schema = "https"
}
regular := color.New()
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Printf("> Server started at: %s\n", color.CyanString("%s://%s", schema, serverConfig.Addr))
regular.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
regular.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, serverConfig.Addr),
)
regular := color.New()
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
}
var serveErr error
+321 -38
View File
@@ -16,6 +16,11 @@ import (
// App defines the main PocketBase 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() *dbx.DB
@@ -26,6 +31,11 @@ type App interface {
// trying to access the request logs table will result in error.
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() *dbx.DB
@@ -68,8 +78,14 @@ type App interface {
// RefreshSettings reinitializes and reloads the stored application settings.
RefreshSettings() error
// IsBootstrapped checks if the application was initialized
// (aka. whether Bootstrap() was called).
IsBootstrapped() bool
// 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
// ResetBootstrapState takes care for releasing initialized app resources
@@ -108,27 +124,51 @@ type App interface {
// OnModelBeforeCreate hook is triggered before inserting a new
// 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
// 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
// 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
// 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
// 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
// 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
@@ -150,33 +190,57 @@ type App interface {
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
//
// 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]
// OnMailerAfterRecordResetPasswordSend hook is triggered after
// an auth record password reset email was successfully sent.
OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
//
// 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]
// OnMailerBeforeRecordVerificationSend hook is triggered right before
// sending a verification email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
//
// 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]
// OnMailerAfterRecordVerificationSend hook is triggered after a
// verification email was successfully sent to an auth record.
OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
//
// 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]
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
// sending a confirmation new address email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
//
// 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]
// OnMailerAfterRecordChangeEmailSend hook is triggered after a
// verification email was successfully sent to an auth record.
OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
//
// 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
@@ -241,7 +305,26 @@ type App interface {
//
// Could be used to validate or modify the file response before
// returning it to the client.
OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent]
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
// OnFileBeforeTokenRequest hook is triggered before each file
// token API request.
//
// If no token or model was submitted, e.Model and e.Token will be empty,
// allowing you to implement your own custom model file auth implementation.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
// OnFileAfterTokenRequest hook is triggered after each
// successful file token API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
// ---------------------------------------------------------------
// Admin API event hooks
@@ -297,6 +380,50 @@ type App interface {
// authenticated admin data and token.
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
// OnAdminBeforeAuthWithPasswordRequest hook is triggered before each Admin
// 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 Admin identity (by assigning [AdminAuthWithPasswordEvent.Admin]).
OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
// 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 additionally validate the request data or implement
// completely different auth refresh behavior (returning [hook.StopPropagation]).
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
// OnAdminAfterAuthRefreshRequest hook is triggered after each
// 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
// completely different persistence behavior (returning [hook.StopPropagation]).
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
// successful confirm password reset API request.
OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
// ---------------------------------------------------------------
// Record Auth API event hooks
// ---------------------------------------------------------------
@@ -306,89 +433,213 @@ type App interface {
//
// Could be used to additionally validate or modify the authenticated
// record data and token.
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
//
// 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]).
OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
//
// 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.
OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
//
// 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
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
//
// 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]
// OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
// successful confirm password reset API request.
OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
//
// 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]
// OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
// request verification API request (after request data load and before sending the verification email).
//
// Could be used to additionally validate the loaded request data or implement
// completely different verification behavior (returning [hook.StopPropagation]).
OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
//
// 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.
OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
//
// 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
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
//
// 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.
OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
// OnRecordAfterConfirmVerificationRequest hook is triggered after each
// successful confirm verification API request.
OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
//
// 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]).
OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
//
// 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.
OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
//
// 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]).
OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
//
// 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.
OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
//
// 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.
OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API record external auth unlink request.
OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// ---------------------------------------------------------------
// Record CRUD API event hooks
@@ -397,45 +648,77 @@ type App interface {
// 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.
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.
//
// 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
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
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
// 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
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
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
// 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
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
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
// 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
+382 -148
View File
@@ -17,25 +17,35 @@ import (
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
const (
DefaultDataMaxOpenConns int = 120
DefaultDataMaxIdleConns int = 20
DefaultLogsMaxOpenConns int = 10
DefaultLogsMaxIdleConns int = 2
)
var _ App = (*BaseApp)(nil)
// BaseApp implements core.App and defines the base PocketBase app structure.
type BaseApp struct {
// configurable parameters
isDebug bool
dataDir string
encryptionEnv string
isDebug bool
dataDir string
encryptionEnv string
dataMaxOpenConns int
dataMaxIdleConns int
logsMaxOpenConns int
logsMaxIdleConns int
// internals
cache *store.Store[any]
settings *settings.Settings
db *dbx.DB
dao *daos.Dao
logsDB *dbx.DB
logsDao *daos.Dao
subscriptionsBroker *subscriptions.Broker
@@ -78,21 +88,37 @@ type BaseApp struct {
onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent]
// file api event hooks
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
onFileBeforeTokenRequest *hook.Hook[*FileTokenEvent]
onFileAfterTokenRequest *hook.Hook[*FileTokenEvent]
// admin api event hooks
onAdminsListRequest *hook.Hook[*AdminsListEvent]
onAdminViewRequest *hook.Hook[*AdminViewEvent]
onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent]
onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent]
onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent]
onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent]
onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
onAdminsListRequest *hook.Hook[*AdminsListEvent]
onAdminViewRequest *hook.Hook[*AdminViewEvent]
onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent]
onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent]
onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent]
onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent]
onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
onAdminBeforeAuthWithPasswordRequest *hook.Hook[*AdminAuthWithPasswordEvent]
onAdminAfterAuthWithPasswordRequest *hook.Hook[*AdminAuthWithPasswordEvent]
onAdminBeforeAuthRefreshRequest *hook.Hook[*AdminAuthRefreshEvent]
onAdminAfterAuthRefreshRequest *hook.Hook[*AdminAuthRefreshEvent]
onAdminBeforeRequestPasswordResetRequest *hook.Hook[*AdminRequestPasswordResetEvent]
onAdminAfterRequestPasswordResetRequest *hook.Hook[*AdminRequestPasswordResetEvent]
onAdminBeforeConfirmPasswordResetRequest *hook.Hook[*AdminConfirmPasswordResetEvent]
onAdminAfterConfirmPasswordResetRequest *hook.Hook[*AdminConfirmPasswordResetEvent]
// record auth API event hooks
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
onRecordBeforeAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordEvent]
onRecordAfterAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordEvent]
onRecordBeforeAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2Event]
onRecordAfterAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2Event]
onRecordBeforeAuthRefreshRequest *hook.Hook[*RecordAuthRefreshEvent]
onRecordAfterAuthRefreshRequest *hook.Hook[*RecordAuthRefreshEvent]
onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
@@ -132,15 +158,30 @@ type BaseApp struct {
onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent]
}
// BaseAppConfig defines a BaseApp configuration option
type BaseAppConfig struct {
DataDir string
EncryptionEnv string
IsDebug bool
DataMaxOpenConns int // default to 500
DataMaxIdleConns int // default 20
LogsMaxOpenConns int // default to 100
LogsMaxIdleConns int // default to 5
}
// NewBaseApp creates and returns a new BaseApp instance
// configured with the provided arguments.
//
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
func NewBaseApp(config *BaseAppConfig) *BaseApp {
app := &BaseApp{
dataDir: dataDir,
isDebug: isDebug,
encryptionEnv: encryptionEnv,
dataDir: config.DataDir,
isDebug: config.IsDebug,
encryptionEnv: config.EncryptionEnv,
dataMaxOpenConns: config.DataMaxOpenConns,
dataMaxIdleConns: config.DataMaxIdleConns,
logsMaxOpenConns: config.LogsMaxOpenConns,
logsMaxIdleConns: config.LogsMaxIdleConns,
cache: store.New[any](nil),
settings: settings.New(),
subscriptionsBroker: subscriptions.NewBroker(),
@@ -184,21 +225,37 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{},
// file API event hooks
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
onFileBeforeTokenRequest: &hook.Hook[*FileTokenEvent]{},
onFileAfterTokenRequest: &hook.Hook[*FileTokenEvent]{},
// admin API event hooks
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
onAdminViewRequest: &hook.Hook[*AdminViewEvent]{},
onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{},
onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{},
onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
onAdminViewRequest: &hook.Hook[*AdminViewEvent]{},
onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{},
onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{},
onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
onAdminBeforeAuthWithPasswordRequest: &hook.Hook[*AdminAuthWithPasswordEvent]{},
onAdminAfterAuthWithPasswordRequest: &hook.Hook[*AdminAuthWithPasswordEvent]{},
onAdminBeforeAuthRefreshRequest: &hook.Hook[*AdminAuthRefreshEvent]{},
onAdminAfterAuthRefreshRequest: &hook.Hook[*AdminAuthRefreshEvent]{},
onAdminBeforeRequestPasswordResetRequest: &hook.Hook[*AdminRequestPasswordResetEvent]{},
onAdminAfterRequestPasswordResetRequest: &hook.Hook[*AdminRequestPasswordResetEvent]{},
onAdminBeforeConfirmPasswordResetRequest: &hook.Hook[*AdminConfirmPasswordResetEvent]{},
onAdminAfterConfirmPasswordResetRequest: &hook.Hook[*AdminConfirmPasswordResetEvent]{},
// record auth API event hooks
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
onRecordBeforeAuthWithPasswordRequest: &hook.Hook[*RecordAuthWithPasswordEvent]{},
onRecordAfterAuthWithPasswordRequest: &hook.Hook[*RecordAuthWithPasswordEvent]{},
onRecordBeforeAuthWithOAuth2Request: &hook.Hook[*RecordAuthWithOAuth2Event]{},
onRecordAfterAuthWithOAuth2Request: &hook.Hook[*RecordAuthWithOAuth2Event]{},
onRecordBeforeAuthRefreshRequest: &hook.Hook[*RecordAuthRefreshEvent]{},
onRecordAfterAuthRefreshRequest: &hook.Hook[*RecordAuthRefreshEvent]{},
onRecordBeforeRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
onRecordAfterRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
onRecordBeforeConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
@@ -243,8 +300,16 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
return app
}
// IsBootstrapped checks if the application was initialized
// (aka. whether Bootstrap() was called).
func (app *BaseApp) IsBootstrapped() bool {
return app.dao != nil && app.logsDao != nil && app.settings != nil
}
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.)
// (aka. create data dir, open db connections, load settings, etc.).
//
// It will call ResetBootstrapState() if the application was already bootstrapped.
func (app *BaseApp) Bootstrap() error {
event := &BootstrapEvent{app}
@@ -283,14 +348,20 @@ func (app *BaseApp) Bootstrap() error {
// ResetBootstrapState takes care for releasing initialized app resources
// (eg. closing db connections).
func (app *BaseApp) ResetBootstrapState() error {
if app.db != nil {
if err := app.db.Close(); err != nil {
if app.Dao() != nil {
if err := app.Dao().ConcurrentDB().(*dbx.DB).Close(); err != nil {
return err
}
if err := app.Dao().NonconcurrentDB().(*dbx.DB).Close(); err != nil {
return err
}
}
if app.logsDB != nil {
if err := app.logsDB.Close(); err != nil {
if app.LogsDao() != nil {
if err := app.LogsDao().ConcurrentDB().(*dbx.DB).Close(); err != nil {
return err
}
if err := app.LogsDao().NonconcurrentDB().(*dbx.DB).Close(); err != nil {
return err
}
}
@@ -302,9 +373,23 @@ func (app *BaseApp) ResetBootstrapState() error {
return nil
}
// Deprecated:
// This method may get removed in the near future.
// It is recommended to access the 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.
func (app *BaseApp) DB() *dbx.DB {
return app.db
if app.Dao() == nil {
return nil
}
db, ok := app.Dao().DB().(*dbx.DB)
if !ok {
return nil
}
return db
}
// Dao returns the default app Dao instance.
@@ -312,9 +397,23 @@ func (app *BaseApp) Dao() *daos.Dao {
return app.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.
func (app *BaseApp) LogsDB() *dbx.DB {
return app.logsDB
if app.LogsDao() == nil {
return nil
}
db, ok := app.LogsDao().DB().(*dbx.DB)
if !ok {
return nil
}
return db
}
// LogsDao returns the app logs Dao instance.
@@ -358,13 +457,14 @@ func (app *BaseApp) SubscriptionsBroker() *subscriptions.Broker {
// based on the current app settings.
func (app *BaseApp) NewMailClient() mailer.Mailer {
if app.Settings().Smtp.Enabled {
return mailer.NewSmtpClient(
app.Settings().Smtp.Host,
app.Settings().Smtp.Port,
app.Settings().Smtp.Username,
app.Settings().Smtp.Password,
app.Settings().Smtp.Tls,
)
return &mailer.SmtpClient{
Host: app.Settings().Smtp.Host,
Port: app.Settings().Smtp.Port,
Username: app.Settings().Smtp.Username,
Password: app.Settings().Smtp.Password,
Tls: app.Settings().Smtp.Tls,
AuthMethod: app.Settings().Smtp.AuthMethod,
}
}
return &mailer.Sendmail{}
@@ -376,7 +476,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
// NB! Make sure to call `Close()` on the returned result
// after you are done working with it.
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
if app.settings.S3.Enabled {
if app.settings != nil && app.settings.S3.Enabled {
return filesystem.NewS3(
app.settings.S3.Bucket,
app.settings.S3.Region,
@@ -445,28 +545,28 @@ func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
// Dao event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnModelBeforeCreate() *hook.Hook[*ModelEvent] {
return app.onModelBeforeCreate
func (app *BaseApp) OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelBeforeCreate, tags...)
}
func (app *BaseApp) OnModelAfterCreate() *hook.Hook[*ModelEvent] {
return app.onModelAfterCreate
func (app *BaseApp) OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterCreate, tags...)
}
func (app *BaseApp) OnModelBeforeUpdate() *hook.Hook[*ModelEvent] {
return app.onModelBeforeUpdate
func (app *BaseApp) OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelBeforeUpdate, tags...)
}
func (app *BaseApp) OnModelAfterUpdate() *hook.Hook[*ModelEvent] {
return app.onModelAfterUpdate
func (app *BaseApp) OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterUpdate, tags...)
}
func (app *BaseApp) OnModelBeforeDelete() *hook.Hook[*ModelEvent] {
return app.onModelBeforeDelete
func (app *BaseApp) OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelBeforeDelete, tags...)
}
func (app *BaseApp) OnModelAfterDelete() *hook.Hook[*ModelEvent] {
return app.onModelAfterDelete
func (app *BaseApp) OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterDelete, tags...)
}
// -------------------------------------------------------------------
@@ -481,28 +581,28 @@ func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdmi
return app.onMailerAfterAdminResetPasswordSend
}
func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordResetPasswordSend
func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerBeforeRecordResetPasswordSend, tags...)
}
func (app *BaseApp) OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordResetPasswordSend
func (app *BaseApp) OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerAfterRecordResetPasswordSend, tags...)
}
func (app *BaseApp) OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordVerificationSend
func (app *BaseApp) OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerBeforeRecordVerificationSend, tags...)
}
func (app *BaseApp) OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordVerificationSend
func (app *BaseApp) OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerAfterRecordVerificationSend, tags...)
}
func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordChangeEmailSend
func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerBeforeRecordChangeEmailSend, tags...)
}
func (app *BaseApp) OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordChangeEmailSend
func (app *BaseApp) OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerAfterRecordChangeEmailSend, tags...)
}
// -------------------------------------------------------------------
@@ -553,8 +653,16 @@ func (app *BaseApp) OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEve
// File API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent] {
return app.onFileDownloadRequest
func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent] {
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
}
func (app *BaseApp) OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
return hook.NewTaggedHook(app.onFileBeforeTokenRequest, tags...)
}
func (app *BaseApp) OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
return hook.NewTaggedHook(app.onFileAfterTokenRequest, tags...)
}
// -------------------------------------------------------------------
@@ -597,108 +705,164 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
return app.onAdminAuthRequest
}
func (app *BaseApp) OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
return app.onAdminBeforeAuthWithPasswordRequest
}
func (app *BaseApp) OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
return app.onAdminAfterAuthWithPasswordRequest
}
func (app *BaseApp) OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
return app.onAdminBeforeAuthRefreshRequest
}
func (app *BaseApp) OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
return app.onAdminAfterAuthRefreshRequest
}
func (app *BaseApp) OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent] {
return app.onAdminBeforeRequestPasswordResetRequest
}
func (app *BaseApp) OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent] {
return app.onAdminAfterRequestPasswordResetRequest
}
func (app *BaseApp) OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent] {
return app.onAdminBeforeConfirmPasswordResetRequest
}
func (app *BaseApp) OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent] {
return app.onAdminAfterConfirmPasswordResetRequest
}
// -------------------------------------------------------------------
// Record auth API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
return app.onRecordAuthRequest
func (app *BaseApp) OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent] {
return hook.NewTaggedHook(app.onRecordAuthRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
return app.onRecordBeforeRequestPasswordResetRequest
func (app *BaseApp) OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
return hook.NewTaggedHook(app.onRecordBeforeAuthWithPasswordRequest, tags...)
}
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
return app.onRecordAfterRequestPasswordResetRequest
func (app *BaseApp) OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
return hook.NewTaggedHook(app.onRecordAfterAuthWithPasswordRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
return app.onRecordBeforeConfirmPasswordResetRequest
func (app *BaseApp) OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
return hook.NewTaggedHook(app.onRecordBeforeAuthWithOAuth2Request, tags...)
}
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
return app.onRecordAfterConfirmPasswordResetRequest
func (app *BaseApp) OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
return hook.NewTaggedHook(app.onRecordAfterAuthWithOAuth2Request, tags...)
}
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
return app.onRecordBeforeRequestVerificationRequest
func (app *BaseApp) OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
return hook.NewTaggedHook(app.onRecordBeforeAuthRefreshRequest, tags...)
}
func (app *BaseApp) OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
return app.onRecordAfterRequestVerificationRequest
func (app *BaseApp) OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
return hook.NewTaggedHook(app.onRecordAfterAuthRefreshRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
return app.onRecordBeforeConfirmVerificationRequest
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
return hook.NewTaggedHook(app.onRecordBeforeRequestPasswordResetRequest, tags...)
}
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
return app.onRecordAfterConfirmVerificationRequest
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
return hook.NewTaggedHook(app.onRecordAfterRequestPasswordResetRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
return app.onRecordBeforeRequestEmailChangeRequest
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
return hook.NewTaggedHook(app.onRecordBeforeConfirmPasswordResetRequest, tags...)
}
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
return app.onRecordAfterRequestEmailChangeRequest
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
return hook.NewTaggedHook(app.onRecordAfterConfirmPasswordResetRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
return app.onRecordBeforeConfirmEmailChangeRequest
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
return hook.NewTaggedHook(app.onRecordBeforeRequestVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
return app.onRecordAfterConfirmEmailChangeRequest
func (app *BaseApp) OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
return hook.NewTaggedHook(app.onRecordAfterRequestVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] {
return app.onRecordListExternalAuthsRequest
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
return hook.NewTaggedHook(app.onRecordBeforeConfirmVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
return app.onRecordBeforeUnlinkExternalAuthRequest
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
return hook.NewTaggedHook(app.onRecordAfterConfirmVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
return app.onRecordAfterUnlinkExternalAuthRequest
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent] {
return hook.NewTaggedHook(app.onRecordBeforeRequestEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent] {
return hook.NewTaggedHook(app.onRecordAfterRequestEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent] {
return hook.NewTaggedHook(app.onRecordBeforeConfirmEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent] {
return hook.NewTaggedHook(app.onRecordAfterConfirmEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent] {
return hook.NewTaggedHook(app.onRecordListExternalAuthsRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent] {
return hook.NewTaggedHook(app.onRecordBeforeUnlinkExternalAuthRequest, tags...)
}
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent] {
return hook.NewTaggedHook(app.onRecordAfterUnlinkExternalAuthRequest, tags...)
}
// -------------------------------------------------------------------
// Record CRUD API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] {
return app.onRecordsListRequest
func (app *BaseApp) OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent] {
return hook.NewTaggedHook(app.onRecordsListRequest, tags...)
}
func (app *BaseApp) OnRecordViewRequest() *hook.Hook[*RecordViewEvent] {
return app.onRecordViewRequest
func (app *BaseApp) OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent] {
return hook.NewTaggedHook(app.onRecordViewRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] {
return app.onRecordBeforeCreateRequest
func (app *BaseApp) OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
return hook.NewTaggedHook(app.onRecordBeforeCreateRequest, tags...)
}
func (app *BaseApp) OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] {
return app.onRecordAfterCreateRequest
func (app *BaseApp) OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
return hook.NewTaggedHook(app.onRecordAfterCreateRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] {
return app.onRecordBeforeUpdateRequest
func (app *BaseApp) OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
return hook.NewTaggedHook(app.onRecordBeforeUpdateRequest, tags...)
}
func (app *BaseApp) OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] {
return app.onRecordAfterUpdateRequest
func (app *BaseApp) OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
return hook.NewTaggedHook(app.onRecordAfterUpdateRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] {
return app.onRecordBeforeDeleteRequest
func (app *BaseApp) OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
return hook.NewTaggedHook(app.onRecordBeforeDeleteRequest, tags...)
}
func (app *BaseApp) OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] {
return app.onRecordAfterDeleteRequest
func (app *BaseApp) OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
return hook.NewTaggedHook(app.onRecordAfterDeleteRequest, tags...)
}
// -------------------------------------------------------------------
@@ -750,71 +914,132 @@ func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImp
// -------------------------------------------------------------------
func (app *BaseApp) initLogsDB() error {
var connectErr error
app.logsDB, connectErr = connectDB(filepath.Join(app.DataDir(), "logs.db"))
if connectErr != nil {
return connectErr
maxOpenConns := DefaultLogsMaxOpenConns
maxIdleConns := DefaultLogsMaxIdleConns
if app.logsMaxOpenConns > 0 {
maxOpenConns = app.logsMaxOpenConns
}
if app.logsMaxIdleConns > 0 {
maxIdleConns = app.logsMaxIdleConns
}
app.logsDao = daos.New(app.logsDB)
concurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
if err != nil {
return err
}
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
if err != nil {
return err
}
nonconcurrentDB.DB().SetMaxOpenConns(1)
nonconcurrentDB.DB().SetMaxIdleConns(1)
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
return nil
}
func (app *BaseApp) initDataDB() error {
var connectErr error
app.db, connectErr = connectDB(filepath.Join(app.DataDir(), "data.db"))
if connectErr != nil {
return connectErr
maxOpenConns := DefaultDataMaxOpenConns
maxIdleConns := DefaultDataMaxIdleConns
if app.dataMaxOpenConns > 0 {
maxOpenConns = app.dataMaxOpenConns
}
if app.dataMaxIdleConns > 0 {
maxIdleConns = app.dataMaxIdleConns
}
concurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
if err != nil {
return err
}
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
if err != nil {
return err
}
nonconcurrentDB.DB().SetMaxOpenConns(1)
nonconcurrentDB.DB().SetMaxIdleConns(1)
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
if app.IsDebug() {
app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
}
app.dao = app.createDaoWithHooks(app.db)
app.dao = app.createDaoWithHooks(concurrentDB, nonconcurrentDB)
return nil
}
func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
dao := daos.New(db)
func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m})
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeCreate().Trigger(e)
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
if err := app.OnModelAfterCreate().Trigger(e); err != nil && app.isDebug {
log.Println(err)
}
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeUpdate().Trigger(&ModelEvent{eventDao, m})
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeUpdate().Trigger(e)
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
if err := app.OnModelAfterUpdate().Trigger(e); err != nil && app.isDebug {
log.Println(err)
}
}
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeDelete().Trigger(&ModelEvent{eventDao, m})
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeDelete().Trigger(e)
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
if err := app.OnModelAfterDelete().Trigger(e); err != nil && app.isDebug {
log.Println(err)
}
}
@@ -832,20 +1057,29 @@ func (app *BaseApp) registerDefaultHooks() {
failed := fs.DeletePrefix(prefix)
if len(failed) > 0 {
return errors.New("Failed to delete the files at " + prefix)
return errors.New("failed to delete the files at " + prefix)
}
return nil
}
// delete storage files from deleted Collection, Records, etc.
// try to delete the storage files from deleted Collection, Records, etc. model
app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
if err := deletePrefix(m.BaseFilesPath()); err != nil && app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
}
prefix := m.BaseFilesPath()
// run in the background for "optimistic" delete to avoid
// blocking the delete transaction
//
// @todo consider creating a bg process queue so that the
// call could be "retried" in case of a failure.
routine.FireAndForget(func() {
if err := deletePrefix(prefix); err != nil && app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
}
})
}
return nil
+39 -243
View File
@@ -11,7 +11,11 @@ func TestNewBaseApp(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(testDataDir, "test_env", true)
app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "test_env",
IsDebug: true,
})
if app.dataDir != testDataDir {
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/"
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()
if app.IsBootstrapped() {
t.Fatal("Didn't expect the application to be bootstrapped.")
}
// bootstrap
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
if !app.IsBootstrapped() {
t.Fatal("Expected the application to be bootstrapped.")
}
if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() {
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/"
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()
if err := app.Bootstrap(); err != nil {
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() {
t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao)
}
if app.logsDB != app.LogsDB() {
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDB)
if app.dao.ConcurrentDB() != app.DB() {
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.dao.ConcurrentDB())
}
if 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() {
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 {
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.onMailerBeforeRecordResetPasswordSend != app.OnMailerBeforeRecordResetPasswordSend() || app.OnMailerBeforeRecordResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordResetPasswordSend(), app.onMailerBeforeRecordResetPasswordSend)
}
if app.onMailerAfterRecordResetPasswordSend != app.OnMailerAfterRecordResetPasswordSend() || app.OnMailerAfterRecordResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordResetPasswordSend(), app.onMailerAfterRecordResetPasswordSend)
}
if app.onMailerBeforeRecordVerificationSend != app.OnMailerBeforeRecordVerificationSend() || app.OnMailerBeforeRecordVerificationSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordVerificationSend(), app.onMailerBeforeRecordVerificationSend)
}
if app.onMailerAfterRecordVerificationSend != app.OnMailerAfterRecordVerificationSend() || app.OnMailerAfterRecordVerificationSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordVerificationSend(), app.onMailerAfterRecordVerificationSend)
}
if app.onMailerBeforeRecordChangeEmailSend != app.OnMailerBeforeRecordChangeEmailSend() || app.OnMailerBeforeRecordChangeEmailSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordChangeEmailSend(), app.onMailerBeforeRecordChangeEmailSend)
}
if app.onMailerAfterRecordChangeEmailSend != app.OnMailerAfterRecordChangeEmailSend() || app.OnMailerAfterRecordChangeEmailSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordChangeEmailSend(), app.onMailerAfterRecordChangeEmailSend)
}
if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil {
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.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.onRecordAuthRequest != app.OnRecordAuthRequest() || app.OnRecordAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest)
}
if app.onRecordListExternalAuthsRequest != app.OnRecordListExternalAuthsRequest() || app.OnRecordListExternalAuthsRequest() == nil {
t.Fatalf("Getter app.OnRecordListExternalAuthsRequest does not match or nil (%v vs %v)", app.OnRecordListExternalAuthsRequest(), app.onRecordListExternalAuthsRequest)
}
if app.onRecordBeforeUnlinkExternalAuthRequest != app.OnRecordBeforeUnlinkExternalAuthRequest() || app.OnRecordBeforeUnlinkExternalAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUnlinkExternalAuthRequest(), app.onRecordBeforeUnlinkExternalAuthRequest)
}
if app.onRecordAfterUnlinkExternalAuthRequest != app.OnRecordAfterUnlinkExternalAuthRequest() || app.OnRecordAfterUnlinkExternalAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordAfterUnlinkExternalAuthRequest(), app.onRecordAfterUnlinkExternalAuthRequest)
}
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
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) {
const testDataDir = "./pb_base_app_test_data_dir/"
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()
if val, ok := client1.(*mailer.Sendmail); !ok {
@@ -419,7 +211,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(testDataDir, "pb_test_env", false)
app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
// local
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
}
+8 -23
View File
@@ -3,35 +3,20 @@
package core
import (
"fmt"
"time"
"github.com/pocketbase/dbx"
_ "github.com/mattn/go-sqlite3"
)
func connectDB(dbPath string) (*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
pragmas := "_busy_timeout=10000&_journal_mode=WAL&_foreign_keys=1&_synchronous=NORMAL"
db, openErr := dbx.MustOpen("sqlite3", fmt.Sprintf("%s?%s", dbPath, pragmas))
if openErr != nil {
return nil, openErr
db, err := dbx.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
// use a fixed connection pool to limit the SQLITE_BUSY errors
// and reduce the open file descriptors
// (the limits are arbitrary and may change in the future)
db.DB().SetMaxOpenConns(800)
db.DB().SetMaxIdleConns(30)
db.DB().SetConnMaxIdleTime(5 * time.Minute)
if err := initPragmas(db); err != nil {
db.Close()
return nil, err
}
// additional pragmas not supported through the dsn string
_, err := db.NewQuery(`
pragma journal_size_limit = 100000000;
`).Execute()
return db, err
return db, nil
}
+5 -17
View File
@@ -3,32 +3,20 @@
package core
import (
"fmt"
"time"
"github.com/pocketbase/dbx"
_ "modernc.org/sqlite"
)
func connectDB(dbPath string) (*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
pragmas := "_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_pragma=journal_size_limit(100000000)"
db, err := dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas))
db, err := dbx.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
// use a fixed connection pool to limit the SQLITE_BUSY errors and
// reduce the open file descriptors
// (the limits are arbitrary and may change in the future)
//
// @see https://gitlab.com/cznic/sqlite/-/issues/115
db.DB().SetMaxOpenConns(800)
db.DB().SetMaxIdleConns(30)
db.DB().SetConnMaxIdleTime(5 * time.Minute)
if err := initPragmas(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
+152 -14
View File
@@ -1,17 +1,62 @@
package core
import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/labstack/echo/v5"
)
var (
_ hook.Tagger = (*BaseModelEvent)(nil)
_ hook.Tagger = (*BaseCollectionEvent)(nil)
)
type BaseModelEvent struct {
Model models.Model
}
func (e *BaseModelEvent) Tags() []string {
if e.Model == nil {
return nil
}
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
return []string{r.Collection().Id, r.Collection().Name}
}
return []string{e.Model.TableName()}
}
type BaseCollectionEvent struct {
Collection *models.Collection
}
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
// -------------------------------------------------------------------
@@ -35,8 +80,9 @@ type ApiErrorEvent struct {
// -------------------------------------------------------------------
type ModelEvent struct {
Dao *daos.Dao
Model models.Model
BaseModelEvent
Dao *daos.Dao
}
// -------------------------------------------------------------------
@@ -44,6 +90,8 @@ type ModelEvent struct {
// -------------------------------------------------------------------
type MailerRecordEvent struct {
BaseCollectionEvent
MailClient mailer.Mailer
Message *mailer.Message
Record *models.Record
@@ -103,28 +151,39 @@ type SettingsUpdateEvent struct {
// -------------------------------------------------------------------
type RecordsListEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
Records []*models.Record
Result *search.Result
}
type RecordViewEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordCreateEvent struct {
HttpContext echo.Context
Record *models.Record
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
UploadedFiles map[string][]*filesystem.File
}
type RecordUpdateEvent struct {
HttpContext echo.Context
Record *models.Record
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
UploadedFiles map[string][]*filesystem.File
}
type RecordDeleteEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
@@ -134,49 +193,94 @@ type RecordDeleteEvent struct {
// -------------------------------------------------------------------
type RecordAuthEvent struct {
BaseCollectionEvent
HttpContext echo.Context
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
ProviderName string
ProviderClient auth.Provider
Record *models.Record
OAuth2User *auth.AuthUser
IsNewRecord bool
}
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
@@ -218,6 +322,28 @@ type AdminAuthEvent struct {
Token string
}
type AdminAuthWithPasswordEvent struct {
HttpContext echo.Context
Admin *models.Admin
Identity string
Password string
}
type AdminAuthRefreshEvent struct {
HttpContext echo.Context
Admin *models.Admin
}
type AdminRequestPasswordResetEvent struct {
HttpContext echo.Context
Admin *models.Admin
}
type AdminConfirmPasswordResetEvent struct {
HttpContext echo.Context
Admin *models.Admin
}
// -------------------------------------------------------------------
// Collection API events data
// -------------------------------------------------------------------
@@ -229,23 +355,27 @@ type CollectionsListEvent struct {
}
type CollectionViewEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
}
type CollectionCreateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
}
type CollectionUpdateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
}
type CollectionDeleteEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
}
type CollectionsImportEvent struct {
@@ -257,9 +387,17 @@ type CollectionsImportEvent struct {
// File API events data
// -------------------------------------------------------------------
type FileTokenEvent struct {
BaseModelEvent
HttpContext echo.Context
Token string
}
type FileDownloadEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
Record *models.Record
FileField *schema.SchemaField
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)
}
}
}
}
+2 -2
View File
@@ -97,8 +97,8 @@ func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
AndWhere(dbx.HashExp{"email": email}).
Limit(1)
if len(excludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...))
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
+90 -96
View File
@@ -4,32 +4,44 @@
package daos
import (
"context"
"errors"
"strings"
"sync"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"golang.org/x/sync/semaphore"
)
const DefaultMaxFailRetries = 5
// New creates a new Dao instance with the provided db builder.
// New creates a new Dao instance with the provided db builder
// (for both async and sync db operations).
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{
db: db,
concurrentDB: concurrentDB,
nonconcurrentDB: nonconcurrentDB,
MaxLockRetries: 8,
ModelQueryTimeout: 30 * time.Second,
}
}
// Dao handles various db operations.
// Think of Dao as a repository and service layer in one.
type Dao struct {
db dbx.Builder
sem *semaphore.Weighted
mux sync.RWMutex
// 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
AfterCreateFunc func(eventDao *Dao, m models.Model)
@@ -39,56 +51,47 @@ type Dao struct {
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 {
return dao.db
return dao.ConcurrentDB()
}
// Block acquires a lock and blocks all other go routines that uses
// the Dao instance until dao.Continue() is called, effectively making
// the concurrent requests to perform synchronous db operations.
// ConcurrentDB returns the dao concurrent (aka. multiple open connections)
// db builder (*dbx.DB or *dbx.TX).
//
// This method should be used only as a last resort and as a workaround
// for the SQLITE_BUSY error when mixing read&write in a transaction.
//
// Example:
//
// func someLongRunningTransaction() error {
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// defer cancel()
// if err := app.Dao().Block(ctx); err != nil {
// return err
// }
// defer app.Dao().Continue()
//
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
// // some long running read&write transaction...
// })
// }
func (dao *Dao) Block(ctx context.Context) error {
if dao.sem == nil {
dao.mux.Lock()
dao.sem = semaphore.NewWeighted(1)
dao.mux.Unlock()
}
return dao.sem.Acquire(ctx, 1)
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
func (dao *Dao) ConcurrentDB() dbx.Builder {
return dao.concurrentDB
}
// Continue releases the previously acquired Block() lock.
func (dao *Dao) Continue() {
if dao.sem == nil {
return
}
dao.sem.Release(1)
// 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 query with preset Select and From fields
// based on the provided model argument.
// Clone returns a new Dao with the same configuration options as the current one.
func (dao *Dao) Clone() *Dao {
clone := *dao
return &clone
}
// ModelQuery creates a new preconfigured select query with preset
// SELECT, FROM and other common fields based on the provided model.
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
tableName := m.TableName()
return dao.db.Select("{{" + tableName + "}}.*").From(tableName)
return dao.DB().
Select("{{" + tableName + "}}.*").
From(tableName).
WithBuildHook(func(query *dbx.Query) {
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries))
})
}
// FindById finds a single db record with the specified id and
@@ -105,15 +108,17 @@ type afterCallGroup struct {
// 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 {
switch txOrDB := dao.db.(type) {
switch txOrDB := dao.NonconcurrentDB().(type) {
case *dbx.Tx:
// nested transactions are not supported by default
// so execute the function within the current transaction
// ---
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
txDao := New(txOrDB)
txDao.MaxLockRetries = dao.MaxLockRetries
txDao.ModelQueryTimeout = dao.ModelQueryTimeout
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
@@ -165,14 +170,15 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
if txError == nil {
// execute after event calls on successful transaction
// (note: using the non-transaction dao to allow following queries in the after hooks)
for _, call := range afterCalls {
switch call.Action {
case "create":
dao.AfterCreateFunc(call.EventDao, call.Model)
dao.AfterCreateFunc(dao, call.Model)
case "update":
dao.AfterUpdateFunc(call.EventDao, call.Model)
dao.AfterUpdateFunc(dao, call.Model)
case "delete":
dao.AfterDeleteFunc(call.EventDao, call.Model)
dao.AfterDeleteFunc(dao, call.Model)
}
}
}
@@ -180,7 +186,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
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.
@@ -189,14 +195,14 @@ func (dao *Dao) Delete(m models.Model) error {
return errors.New("ID is not set")
}
return dao.failRetry(func(retryDao *Dao) error {
return dao.lockRetry(func(retryDao *Dao) error {
if retryDao.BeforeDeleteFunc != nil {
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
return err
}
}
if err := retryDao.db.Model(m).Delete(); err != nil {
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
return err
}
@@ -205,20 +211,23 @@ func (dao *Dao) Delete(m models.Model) error {
}
return nil
}, DefaultMaxFailRetries)
})
}
// Save upserts (update or create if primary key is not set) the provided model.
// Save persists the provided model in the database.
//
// If m.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a model for update you can use m.MarkAsNotNew().
func (dao *Dao) Save(m models.Model) error {
if m.IsNew() {
return dao.failRetry(func(retryDao *Dao) error {
return dao.lockRetry(func(retryDao *Dao) error {
return retryDao.create(m)
}, DefaultMaxFailRetries)
})
}
return dao.failRetry(func(retryDao *Dao) error {
return dao.lockRetry(func(retryDao *Dao) error {
return retryDao.update(m)
}, DefaultMaxFailRetries)
})
}
func (dao *Dao) update(m models.Model) error {
@@ -241,7 +250,7 @@ func (dao *Dao) update(m models.Model) error {
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
_, err := dao.db.Update(
_, err := dao.NonconcurrentDB().Update(
m.TableName(),
dataMap,
dbx.HashExp{"id": m.GetId()},
@@ -251,7 +260,7 @@ func (dao *Dao) update(m models.Model) error {
return err
}
} else {
if err := dao.db.Model(m).Update(); err != nil {
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
return err
}
}
@@ -292,12 +301,12 @@ func (dao *Dao) create(m models.Model) error {
dataMap["id"] = m.GetId()
}
_, err := dao.db.Insert(m.TableName(), dataMap).Execute()
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
if err != nil {
return err
}
} else {
if err := dao.db.Model(m).Insert(); err != nil {
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
return err
}
}
@@ -312,34 +321,19 @@ func (dao *Dao) create(m models.Model) error {
return nil
}
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
retryDao := dao
attempts := 1
Retry:
if attempts == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = &Dao{
db: dao.db,
AfterCreateFunc: dao.AfterCreateFunc,
AfterUpdateFunc: dao.AfterUpdateFunc,
AfterDeleteFunc: dao.AfterDeleteFunc,
return baseLockRetry(func(attempt int) error {
if attempt == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
retryDao.AfterCreateFunc = dao.AfterCreateFunc
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
}
}
// execute
err := op(retryDao)
if err != nil &&
attempts < maxRetries &&
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
attempts++
goto Retry
}
return err
return op(retryDao)
}, dao.MaxLockRetries)
}
+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
}
}
}
+118
View File
@@ -3,6 +3,7 @@ package daos_test
import (
"errors"
"testing"
"time"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
@@ -20,6 +21,105 @@ 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 TestDaoClone(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
hookCalls := map[string]int{}
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
dao.MaxLockRetries = 1
dao.ModelQueryTimeout = 2
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["BeforeDeleteFunc"]++
return nil
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["BeforeUpdateFunc"]++
return nil
}
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["BeforeCreateFunc"]++
return nil
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["AfterDeleteFunc"]++
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["AfterUpdateFunc"]++
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["AfterCreateFunc"]++
}
clone := dao.Clone()
clone.MaxLockRetries = 3
clone.ModelQueryTimeout = 4
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["NewAfterCreateFunc"]++
}
if dao.MaxLockRetries == clone.MaxLockRetries {
t.Fatal("Expected different MaxLockRetries")
}
if dao.ModelQueryTimeout == clone.ModelQueryTimeout {
t.Fatal("Expected different ModelQueryTimeout")
}
// trigger hooks
dao.BeforeDeleteFunc(nil, nil)
dao.BeforeUpdateFunc(nil, nil)
dao.BeforeCreateFunc(nil, nil)
dao.AfterDeleteFunc(nil, nil)
dao.AfterUpdateFunc(nil, nil)
dao.AfterCreateFunc(nil, nil)
clone.BeforeDeleteFunc(nil, nil)
clone.BeforeUpdateFunc(nil, nil)
clone.BeforeCreateFunc(nil, nil)
clone.AfterDeleteFunc(nil, nil)
clone.AfterUpdateFunc(nil, nil)
clone.AfterCreateFunc(nil, nil)
expectations := []struct {
hook string
total int
}{
{"BeforeDeleteFunc", 2},
{"BeforeUpdateFunc", 2},
{"BeforeCreateFunc", 2},
{"AfterDeleteFunc", 2},
{"AfterUpdateFunc", 2},
{"AfterCreateFunc", 1},
{"NewAfterCreateFunc", 1},
}
for _, e := range expectations {
if hookCalls[e.hook] != e.total {
t.Errorf("Expected %s to be caleed %d", e.hook, e.total)
}
}
}
func TestDaoModelQuery(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -52,6 +152,24 @@ func TestDaoModelQuery(t *testing.T) {
}
}
func TestDaoModelQueryCancellation(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
dao := daos.New(testApp.DB())
m := &models.Admin{}
if err := dao.ModelQuery(m).One(m); err != nil {
t.Fatalf("Failed to execute control query: %v", err)
}
dao.ModelQueryTimeout = 0 * time.Millisecond
if err := dao.ModelQuery(m).One(m); err == nil {
t.Fatal("Expected to be cancelled, got nil")
}
}
func TestDaoFindById(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+186 -32
View File
@@ -1,6 +1,8 @@
package daos
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -65,8 +67,7 @@ func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1)
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
@@ -85,15 +86,17 @@ func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeI
collections := []*models.Collection{}
query := dao.CollectionQuery()
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
if err := query.All(&collections); err != nil {
return nil, err
}
result := map[*models.Collection][]*schema.SchemaField{}
for _, c := range collections {
for _, f := range c.Schema.Fields() {
if f.Type != schema.FieldTypeRelation {
@@ -128,21 +131,39 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
return err
}
if total := len(result); total > 0 {
return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total)
names := make([]string, 0, len(result))
for ref := range result {
names = append(names, ref.Name)
}
return fmt.Errorf("The collection %q has external relation field references (%s).", collection.Name, strings.Join(names, ", "))
}
return dao.RunInTransaction(func(txDao *Dao) error {
// delete the related records table
if err := txDao.DeleteTable(collection.Name); err != nil {
return err
// delete the related view or records table
if collection.IsView() {
if err := txDao.DeleteView(collection.Name); err != nil {
return err
}
} else {
if err := txDao.DeleteTable(collection.Name); err != nil {
return err
}
}
// trigger views resave to check for dependencies
if err := txDao.resaveViewsWithChangedSchema(collection.Id); err != nil {
return fmt.Errorf("The collection has a view dependency - %w", err)
}
return txDao.Delete(collection)
})
}
// SaveCollection upserts the provided Collection model and updates
// SaveCollection persists the provided Collection model and updates
// its related records table schema.
//
// If collecction.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a collection for update you can use collecction.MarkAsNotNew().
func (dao *Dao) SaveCollection(collection *models.Collection) error {
var oldCollection *models.Collection
@@ -156,20 +177,41 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
}
}
return dao.RunInTransaction(func(txDao *Dao) error {
txErr := dao.RunInTransaction(func(txDao *Dao) error {
// set default collection type
if collection.Type == "" {
collection.Type = models.CollectionTypeBase
}
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
switch collection.Type {
case models.CollectionTypeView:
if err := txDao.saveViewCollection(collection, oldCollection); err != nil {
return err
}
default:
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
}
// sync the changes with the related records table
if err := txDao.SyncRecordTableSchema(collection, oldCollection); err != nil {
return err
}
}
// sync the changes with the related records table
return txDao.SyncRecordTableSchema(collection, oldCollection)
return nil
})
if txErr != nil {
return txErr
}
// trigger an update for all views with changed schema as a result of the current collection save
// (ignoring view errors to allow users to update the query from the UI)
dao.resaveViewsWithChangedSchema(collection.Id)
return nil
}
// ImportCollections imports the provided collections list within a single transaction.
@@ -182,7 +224,7 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
func (dao *Dao) ImportCollections(
importedCollections []*models.Collection,
deleteMissing bool,
beforeRecordsSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
afterSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
) error {
if len(importedCollections) == 0 {
return errors.New("No collections to import")
@@ -190,7 +232,7 @@ func (dao *Dao) ImportCollections(
return dao.RunInTransaction(func(txDao *Dao) error {
existingCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&existingCollections); err != nil {
if err := txDao.CollectionQuery().OrderBy("updated ASC").All(&existingCollections); err != nil {
return err
}
mappedExisting := make(map[string]*models.Collection, len(existingCollections))
@@ -246,6 +288,17 @@ func (dao *Dao) ImportCollections(
return fmt.Errorf("System collection %q cannot be deleted.", existing.Name)
}
// delete the related records table or view
if existing.IsView() {
if err := txDao.DeleteView(existing.Name); err != nil {
return err
}
} else {
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
}
// delete the collection
if err := txDao.Delete(existing); err != nil {
return err
@@ -260,29 +313,130 @@ func (dao *Dao) ImportCollections(
}
}
if beforeRecordsSync != nil {
if err := beforeRecordsSync(txDao, mappedImported, mappedExisting); err != nil {
// sync record tables
for _, imported := range importedCollections {
if imported.IsView() {
continue
}
existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err
}
}
// delete the record tables of the deleted collections
if deleteMissing {
for _, existing := range existingCollections {
if mappedImported[existing.GetId()] != nil {
continue // exist
}
// sync views
for _, imported := range importedCollections {
if !imported.IsView() {
continue
}
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
existing := mappedExisting[imported.GetId()]
if err := txDao.saveViewCollection(imported, existing); err != nil {
return err
}
}
// sync the upserted collections with the related records table
for _, imported := range importedCollections {
existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
if afterSync != nil {
if err := afterSync(txDao, mappedImported, mappedExisting); err != nil {
return err
}
}
return nil
})
}
// saveViewCollection persists the provided View collection changes:
// - deletes the old related SQL view (if any)
// - creates a new SQL view with the latest newCollection.Options.Query
// - generates a new schema based on newCollection.Options.Query
// - updates newCollection.Schema based on the generated view table info and query
// - saves the newCollection
//
// This method returns an error if newCollection is not a "view".
func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collection) error {
if !newCollection.IsView() {
return errors.New("not a view collection")
}
return dao.RunInTransaction(func(txDao *Dao) error {
query := newCollection.ViewOptions().Query
// generate collection schema from the query
viewSchema, err := txDao.CreateViewSchema(query)
if err != nil {
return err
}
// delete old renamed view
if oldCollection != nil {
if err := txDao.DeleteView(oldCollection.Name); err != nil {
return err
}
}
// (re)create the view
if err := txDao.SaveView(newCollection.Name, query); err != nil {
return err
}
newCollection.Schema = viewSchema
return txDao.Save(newCollection)
})
}
// resaveViewsWithChangedSchema updates all view collections with changed schemas.
func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
if err != nil {
return err
}
return dao.RunInTransaction(func(txDao *Dao) error {
for _, collection := range collections {
if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
continue
}
// clone the existing schema so that it is safe for temp modifications
oldSchema, err := collection.Schema.Clone()
if err != nil {
return err
}
// generate a new schema from the query
newSchema, err := txDao.CreateViewSchema(collection.ViewOptions().Query)
if err != nil {
return err
}
// unset the schema field ids to exclude from the comparison
for _, f := range oldSchema.Fields() {
f.Id = ""
}
for _, f := range newSchema.Fields() {
f.Id = ""
}
encodedNewSchema, err := json.Marshal(newSchema)
if err != nil {
return err
}
encodedOldSchema, err := json.Marshal(oldSchema)
if err != nil {
return err
}
if bytes.EqualFold(encodedNewSchema, encodedOldSchema) {
continue // no changes
}
if err := txDao.saveViewCollection(collection, nil); err != nil {
return err
}
}
+138 -36
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestCollectionQuery(t *testing.T) {
@@ -37,7 +38,7 @@ func TestFindCollectionsByType(t *testing.T) {
{"", false, 0},
{"unknown", false, 0},
{models.CollectionTypeAuth, false, 3},
{models.CollectionTypeBase, false, 4},
{models.CollectionTypeBase, false, 5},
}
for i, scenario := range scenarios {
@@ -45,16 +46,16 @@ func TestFindCollectionsByType(t *testing.T) {
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if len(collections) != scenario.expectTotal {
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
t.Errorf("[%d] Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
}
for _, c := range collections {
if c.Type != scenario.collectionType {
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
t.Errorf("[%d] Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
}
}
}
@@ -80,11 +81,11 @@ func TestFindCollectionByNameOrId(t *testing.T) {
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
t.Errorf("[%d] Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
}
}
}
@@ -108,7 +109,7 @@ func TestIsCollectionNameUnique(t *testing.T) {
for i, scenario := range scenarios {
result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
}
}
}
@@ -122,7 +123,13 @@ func TestFindCollectionReferences(t *testing.T) {
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 {
t.Fatal(err)
}
@@ -149,7 +156,7 @@ func TestFindCollectionReferences(t *testing.T) {
}
for i, f := range fields {
if !list.ExistInSlice(f.Name, expectedFields) {
t.Fatalf("(%d) Didn't expect field %v", i, f)
t.Fatalf("[%d] Didn't expect field %v", i, f)
}
}
}
@@ -159,21 +166,34 @@ func TestDeleteCollection(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
c0 := &models.Collection{}
c1, err := app.Dao().FindCollectionByNameOrId("clients")
colEmpty := &models.Collection{}
colAuth, err := app.Dao().FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
c2, err := app.Dao().FindCollectionByNameOrId("demo2")
colReferenced, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
colSystem, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
c3.System = true
if err := app.Dao().Save(c3); err != nil {
colSystem.System = true
if err := app.Dao().Save(colSystem); err != nil {
t.Fatal(err)
}
colView1, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
}
colView2, err := app.Dao().FindCollectionByNameOrId("view2")
if err != nil {
t.Fatal(err)
}
@@ -181,18 +201,29 @@ func TestDeleteCollection(t *testing.T) {
model *models.Collection
expectError bool
}{
{c0, true},
{c1, false},
{c2, true}, // is part of a reference
{c3, true}, // system
{colEmpty, true},
{colAuth, false},
{colReferenced, true},
{colSystem, true},
{colView1, true}, // view2 depend on it
{colView2, false},
}
for i, scenario := range scenarios {
err := app.Dao().DeleteCollection(scenario.model)
hasErr := err != nil
for i, s := range scenarios {
err := app.Dao().DeleteCollection(s.model)
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%d] Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if app.Dao().HasTable(s.model.Name) {
t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
}
}
}
@@ -228,7 +259,7 @@ func TestSaveCollectionCreate(t *testing.T) {
}
// check if the records table has the schema fields
columns, err := app.Dao().GetTableColumns(collection.Name)
columns, err := app.Dao().TableColumns(collection.Name)
if err != nil {
t.Fatal(err)
}
@@ -238,7 +269,7 @@ func TestSaveCollectionCreate(t *testing.T) {
}
for i, c := range columns {
if !list.ExistInSlice(c, expectedColumns) {
t.Fatalf("(%d) Didn't expect record column %s", i, c)
t.Fatalf("[%d] Didn't expect record column %s", i, c)
}
}
}
@@ -267,7 +298,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
// check if the records table has the schema fields
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
columns, err := app.Dao().GetTableColumns(collection.Name)
columns, err := app.Dao().TableColumns(collection.Name)
if err != nil {
t.Fatal(err)
}
@@ -276,12 +307,74 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
for i, c := range columns {
if !list.ExistInSlice(c, expectedColumns) {
t.Fatalf("(%d) Didn't expect record column %s", i, c)
t.Fatalf("[%d] Didn't expect record column %s", i, c)
}
}
}
// indirect update of a field used in view should cause view(s) update
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
// update MaxSelect fields
{
relMany := collection.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
relManyOpt.MaxSelect = types.Pointer(1)
fileOne := collection.Schema.GetFieldByName("file_one")
fileOneOpt := fileOne.Options.(*schema.FileOptions)
fileOneOpt.MaxSelect = 10
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
}
// check view1 schema
{
view1, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
}
relMany := view1.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
}
fileOne := view1.Schema.GetFieldByName("file_one")
fileOneOpt := fileOne.Options.(*schema.FileOptions)
if fileOneOpt.MaxSelect != 10 {
t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOneOpt.MaxSelect)
}
}
// check view2 schema
{
view2, err := app.Dao().FindCollectionByNameOrId("view2")
if err != nil {
t.Fatal(err)
}
relMany := view2.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
}
}
}
func TestImportCollections(t *testing.T) {
totalCollections := 10
scenarios := []struct {
name string
jsonData string
@@ -296,7 +389,7 @@ func TestImportCollections(t *testing.T) {
name: "empty collections",
jsonData: `[]`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
},
{
name: "minimal collection import",
@@ -306,7 +399,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 9,
expectCollectionsCount: totalCollections + 2,
},
{
name: "minimal collection import + failed beforeRecordsSync",
@@ -318,7 +411,7 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
},
{
name: "minimal collection import + successful beforeRecordsSync",
@@ -330,7 +423,7 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: false,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections + 1,
},
{
name: "new + update + delete system collection",
@@ -366,7 +459,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: true,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
},
{
name: "new + update + delete non-system collection",
@@ -409,7 +502,7 @@ func TestImportCollections(t *testing.T) {
},
{
"id":"wsmn24bux7wo113",
"name":"demo",
"name":"demo1_rename",
"schema":[
{
"id":"_2hlxbmp",
@@ -436,11 +529,19 @@ func TestImportCollections(t *testing.T) {
"type":"bool"
}
]
},
{
"id": "test_new_view",
"name": "new_view",
"type": "view",
"options": {
"query": "select id from demo2"
}
}
]`,
deleteMissing: true,
expectError: false,
expectCollectionsCount: 3,
expectCollectionsCount: 4,
},
{
name: "test with deleteMissing: false",
@@ -495,7 +596,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections + 1,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{
"nologin": 1,
@@ -503,6 +604,7 @@ func TestImportCollections(t *testing.T) {
"demo2": 2,
"demo3": 2,
"demo4": 11,
"demo5": 6,
"new_import": 1,
}
for name, expectedCount := range expectedCollectionFields {
+2 -2
View File
@@ -12,8 +12,8 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.ExternalAuth{})
}
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
/// linked to the provided auth record.
// FindAllExternalAuthsByRecord returns all ExternalAuth models
// linked to the provided auth record.
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
auths := []*models.ExternalAuth{}
+220 -242
View File
@@ -1,11 +1,9 @@
package daos
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
@@ -22,7 +20,74 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
tableName := collection.Name
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.
@@ -36,10 +101,8 @@ func (dao *Dao) FindRecordById(
return nil, err
}
tableName := collection.Name
query := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{tableName + ".id": recordId})
AndWhere(dbx.HashExp{collection.Name + ".id": recordId})
for _, filter := range optFilters {
if filter == nil {
@@ -50,12 +113,13 @@ func (dao *Dao) FindRecordById(
}
}
row := dbx.NullStringMap{}
if err := query.Limit(1).One(row); err != nil {
record := &models.Record{}
if err := query.Limit(1).One(record); err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// FindRecordsByIds finds all Record models by the provided ids.
@@ -85,12 +149,13 @@ func (dao *Dao) FindRecordsByIds(
}
}
rows := []dbx.NullStringMap{}
if err := query.All(&rows); err != nil {
records := make([]*models.Record, 0, len(recordIds))
if err := query.All(&records); err != nil {
return nil, err
}
return models.NewRecordsFromNullStringMaps(collection, rows), nil
return records, nil
}
// FindRecordsByExpr finds all records by the specified db expression.
@@ -100,6 +165,7 @@ func (dao *Dao) FindRecordsByIds(
// Returns an empty slice if no records are found.
//
// Example:
//
// expr1 := dbx.HashExp{"email": "test@example.com"}
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
// dao.FindRecordsByExpr("example", expr1, expr2)
@@ -118,35 +184,38 @@ func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expres
}
}
rows := []dbx.NullStringMap{}
var records []*models.Record
if err := query.All(&rows); err != nil {
if err := query.All(&records); err != nil {
return nil, err
}
return models.NewRecordsFromNullStringMaps(collection, rows), nil
return records, nil
}
// FindFirstRecordByData returns the first found record matching
// the provided key-value pair.
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
func (dao *Dao) FindFirstRecordByData(
collectionNameOrId string,
key string,
value any,
) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
row := dbx.NullStringMap{}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(row)
One(record)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
@@ -178,9 +247,9 @@ func (dao *Dao) IsRecordValueUnique(
var normalizedVal any
switch val := value.(type) {
case []string:
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
normalizedVal = append(types.JsonArray[string]{}, val...)
case []any:
normalizedVal = append(types.JsonArray{}, val...)
normalizedVal = append(types.JsonArray[any]{}, val...)
default:
normalizedVal = val
}
@@ -193,8 +262,7 @@ func (dao *Dao) IsRecordValueUnique(
AndWhere(expr).
Limit(1)
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
@@ -216,7 +284,7 @@ func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*model
id, _ := unverifiedClaims["id"].(string)
collectionId, _ := unverifiedClaims["collectionId"].(string)
if id == "" || collectionId == "" {
return nil, errors.New("Missing or invalid token claims.")
return nil, errors.New("missing or invalid token claims")
}
record, err := dao.FindRecordById(collectionId, id)
@@ -243,22 +311,24 @@ func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*model
// Returns an error if it is not an auth collection or the record is not found.
func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil || !collection.IsAuth() {
return nil, errors.New("Missing or not an auth collection.")
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)
}
row := dbx.NullStringMap{}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
Limit(1).
One(row)
One(record)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
@@ -266,24 +336,26 @@ func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (
// Returns an error if it is not an auth collection or the record is not found.
func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil || !collection.IsAuth() {
return nil, errors.New("Missing or not an auth collection.")
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)
}
row := dbx.NullStringMap{}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
"username": strings.ToLower(username),
})).
Limit(1).
One(row)
One(record)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
@@ -314,7 +386,10 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
return username
}
// SaveRecord upserts the provided Record model.
// SaveRecord persists the provided Record model in the database.
//
// If record.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a record for update you can use record.MarkAsNotNew().
func (dao *Dao) SaveRecord(record *models.Record) error {
if record.Collection().IsAuth() {
if record.Username() == "" {
@@ -344,10 +419,10 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
// DeleteRecord deletes the provided Record model.
//
// 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
// 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 {
// fetch rel references (if any)
//
@@ -358,234 +433,137 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
return err
}
// run all consequent DeleteRecord requests synchroniously
// to minimize SQLITE_BUSY errors
if len(refs) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := dao.Block(ctx); err != nil {
return err
}
defer dao.Continue()
}
return dao.RunInTransaction(func(txDao *Dao) error {
// always delete the record first to ensure that there will be no "A<->B"
// relations to prevent deadlock when calling DeleteRecord recursively
// manually trigger delete on any linked external auth to ensure
// that the `OnModel*` hooks are triggered
if record.Collection().IsAuth() {
// note: the select is outside of the transaction to minimize
// SQLITE_BUSY errors when mixing read&write in a single transaction
externalAuths, err := dao.FindAllExternalAuthsByRecord(record)
if err != nil {
return err
}
for _, auth := range externalAuths {
if err := txDao.DeleteExternalAuth(auth); err != nil {
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
}
// check if related records has to be deleted (if `CascadeDelete` is set)
// OR
// just unset the record id from any relation field values (if they are not required)
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(5)
for refCollection, fields := range refs {
for _, field := range fields {
options, _ := field.Options.(*schema.RelationOptions)
return txDao.cascadeRecordDelete(record, refs)
})
}
rows := []dbx.NullStringMap{}
// 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)
// fetch all referenced records
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
err := txDao.RecordQuery(refCollection).
Distinct(true).
LeftJoin(fmt.Sprintf(
// note: the case is used to normalize value access for single and multiple relations.
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), nil).
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": record.Id})).
AndWhere(dbx.HashExp{uniqueJsonEachAlias + ".value": record.Id}).
All(&rows)
if err != nil {
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)
query := dao.RecordQuery(refCollection).Distinct(true)
if opt, ok := field.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
} else {
query.InnerJoin(fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
}
if refCollection.Id == mainRecord.Collection().Id {
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
}
// 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)
for _, refRecord := range refRecords {
ids := refRecord.GetStringSlice(field.Name)
// unset the record id
for i := len(ids) - 1; i >= 0; i-- {
if ids[i] == record.Id {
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 actions 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.Set(field.Name, field.PrepareValue(ids))
if err := txDao.SaveRecord(refRecord); err != nil {
return err
}
err := dao.deleteRefRecords(mainRecord, refRecords, field)
if err != nil {
return err
}
if total < batchSize {
break // no more items
}
rows = rows[:0] // keep allocated memory
}
}
// delete linked external auths
if record.Collection().IsAuth() {
_, err = txDao.DB().Delete((&models.ExternalAuth{}).TableName(), dbx.HashExp{
"collectionId": record.Collection().Id,
"recordId": record.Id,
}).Execute()
if err != nil {
return err
}
}
return nil
})
}
// 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",
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
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
if err != nil {
return err
return nil
}
// deleteRefRecords checks 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).
//
// NB! This method is expected to be called inside a transaction.
func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
options, _ := field.Options.(*schema.RelationOptions)
if options == nil {
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
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 {
// 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 := dao.DeleteRecord(refRecord); err != nil {
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 {
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
}
}
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, refRecord.Collection().Name)
}
// set the actual columns name
for tempName, actualName := range toRename {
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
if err != nil {
return err
}
// save the reference changes
refRecord.Set(field.Name, field.PrepareValue(ids))
if err := dao.SaveRecord(refRecord); err != nil {
return err
}
}
return nil
})
return nil
}
+18 -9
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
@@ -86,7 +87,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
}
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
if indirectRelFieldOptions.IsMultiple() {
// for now don't allow multi-relation indirect fields expand
// due to eventual poor query performance with large data sets.
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
@@ -97,6 +98,8 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
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...),
@@ -124,7 +127,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
MaxSelect: nil,
CollectionId: indirectRel.Id,
}
if indirectRelField.Unique {
if isRelFieldUnique(indirectRel, indirectRelField.Name) {
relFieldOptions.MaxSelect = types.Pointer(1)
}
// indirect relation
@@ -176,7 +179,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
}
// reindex with the rel id
indexedRels := map[string]*models.Record{}
indexedRels := make(map[string]*models.Record, len(rels))
for _, rel := range rels {
indexedRels[rel.GetId()] = rel
}
@@ -216,12 +219,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
continue
}
oldRelExpand := oldExpandedRel.Expand()
newRelExpand := rel.Expand()
for k, v := range oldRelExpand {
newRelExpand[k] = v
}
rel.SetExpand(newRelExpand)
rel.MergeExpand(oldExpandedRel.Expand())
}
}
@@ -272,3 +270,14 @@ func normalizeExpands(paths []string) []string {
return list.ToUniqueStringSlice(result)
}
func isRelFieldUnique(collection *models.Collection, fieldName string) bool {
for _, idx := range collection.Indexes {
parsed := dbutils.ParseIndex(idx)
if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, fieldName) {
return true
}
}
return false
}
+70
View File
@@ -167,6 +167,22 @@ func TestExpandRecords(t *testing.T) {
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 _, s := range scenarios {
@@ -346,3 +362,57 @@ func TestExpandRecord(t *testing.T) {
}
}
}
func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
record, err := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
if err != nil {
t.Fatal(err)
}
// non-unique indirect expand
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
})
if len(errs) > 0 {
t.Fatal(errs)
}
result, ok := record.Expand()["demo4(rel_one_cascade)"].([]*models.Record)
if !ok {
t.Fatalf("Expected the expanded result to be a slice, got %v", result)
}
}
// mock a unique constraint for the rel_one_cascade field
{
demo4, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
demo4.Indexes = append(demo4.Indexes, "create unique index idx_unique_expand on demo4 (rel_one_cascade)")
if err := app.Dao().SaveCollection(demo4); err != nil {
t.Fatalf("Failed to mock unique constraint: %v", err)
}
}
// non-unique indirect expand
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
})
if len(errs) > 0 {
t.Fatal(errs)
}
result, ok := record.Expand()["demo4(rel_one_cascade)"].(*models.Record)
if !ok {
t.Fatalf("Expected the expanded result to be a single model, got %v", result)
}
}
}
+372
View File
@@ -0,0 +1,372 @@
package daos
import (
"fmt"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"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 {
return dao.RunInTransaction(func(txDao *Dao) error {
// create
// -----------------------------------------------------------
if oldCollection == nil {
cols := map[string]string{
schema.FieldNameId: "TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL",
schema.FieldNameCreated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
schema.FieldNameUpdated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
}
if newCollection.IsAuth() {
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
}
// 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 := txDao.DB().CreateTable(tableName, cols).Execute(); err != nil {
return err
}
// add named unique index on the email and tokenKey columns
if newCollection.IsAuth() {
_, err := txDao.DB().NewQuery(fmt.Sprintf(
`
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
`,
newCollection.Id, tableName,
newCollection.Id, tableName,
newCollection.Id, tableName,
)).Execute()
if err != nil {
return err
}
}
return txDao.createCollectionIndexes(newCollection)
}
// update
// -----------------------------------------------------------
oldTableName := oldCollection.Name
newTableName := newCollection.Name
oldSchema := oldCollection.Schema
newSchema := newCollection.Schema
deletedFieldNames := []string{}
renamedFieldNames := map[string]string{}
// drop old indexes (if any)
if err := txDao.dropCollectionIndex(oldCollection); err != nil {
return err
}
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
_, err := txDao.DB().RenameTable("{{"+oldTableName+"}}", "{{"+newTableName+"}}").Execute()
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 fmt.Errorf("failed to drop column %s - %w", oldField.Name, 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 fmt.Errorf("failed to add column %s - %w", field.Name, 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 fmt.Errorf("failed to rename column %s - %w", oldField.Name, 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
}
}
if err := txDao.normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection); err != nil {
return err
}
if err := txDao.syncRelationDisplayFieldsChanges(newCollection, renamedFieldNames, deletedFieldNames); err != nil {
return err
}
return txDao.createCollectionIndexes(newCollection)
})
}
func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection *models.Collection) error {
if newCollection.IsView() || oldCollection == nil {
return nil // view or not an update
}
return dao.RunInTransaction(func(txDao *Dao) error {
for _, newField := range newCollection.Schema.Fields() {
oldField := oldCollection.Schema.GetFieldById(newField.Id)
if oldField == nil {
continue
}
var isNewMultiple bool
if opt, ok := newField.Options.(schema.MultiValuer); ok {
isNewMultiple = opt.IsMultiple()
}
var isOldMultiple bool
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
isOldMultiple = opt.IsMultiple()
}
if isOldMultiple == isNewMultiple {
continue // no change
}
var updateQuery *dbx.Query
if !isOldMultiple && isNewMultiple {
// single -> multiple (convert to array)
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '') = ''
THEN '[]'
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN [[%s]]
ELSE json_array([[%s]])
END
)
END
)`,
newCollection.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
))
} else {
// multiple -> single (keep only the last element)
//
// note: for file fields the actual files are not deleted
// allowing additional custom handling via migration.
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '[]') = '[]'
THEN ''
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
ELSE [[%s]]
END
)
END
)`,
newCollection.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
))
}
if _, err := updateQuery.Execute(); err != nil {
return err
}
}
return nil
})
}
func (dao *Dao) syncRelationDisplayFieldsChanges(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
return nil // nothing to sync
}
refs, err := dao.FindCollectionReferences(collection)
if err != nil {
return err
}
for refCollection, refFields := range refs {
for _, refField := range refFields {
options, _ := refField.Options.(*schema.RelationOptions)
if options == nil {
continue
}
// remove deleted (if any)
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
for old, new := range renamedFieldNames {
for i, name := range newDisplayFields {
if name == old {
newDisplayFields[i] = new
}
}
}
// has changes
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
options.DisplayFields = newDisplayFields
// direct collection save to prevent self-referencing
// recursion and unnecessary records table sync checks
if err := dao.Save(refCollection); err != nil {
return err
}
}
}
}
return nil
}
func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
if collection.IsView() {
return nil // views don't have indexes
}
return dao.RunInTransaction(func(txDao *Dao) error {
for _, raw := range collection.Indexes {
parsed := dbutils.ParseIndex(raw)
if !parsed.IsValid() {
continue
}
if _, err := txDao.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
return err
}
}
return nil
})
}
func (dao *Dao) createCollectionIndexes(collection *models.Collection) error {
if collection.IsView() {
return nil // views don't have indexes
}
return dao.RunInTransaction(func(txDao *Dao) error {
// drop new indexes in case a duplicated index name is used
if err := txDao.dropCollectionIndex(collection); err != nil {
return err
}
// upsert new indexes
//
// note: we are returning validation errors because the indexes cannot be
// validated in a form, aka. before persisting the related collection
// record table changes
errs := validation.Errors{}
for i, idx := range collection.Indexes {
parsed := dbutils.ParseIndex(idx)
// ensure that the index is always for the current collection
parsed.TableName = collection.Name
if !parsed.IsValid() {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_invalid_index_expression",
"Invalid CREATE INDEX expression.",
)
continue
}
if _, err := txDao.DB().NewQuery(parsed.Build()).Execute(); err != nil {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_invalid_index_expression",
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
)
continue
}
}
if len(errs) > 0 {
return validation.Errors{"indexes": errs}
}
return nil
})
}
+259
View File
@@ -0,0 +1,259 @@
package daos_test
import (
"bytes"
"encoding/json"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestSyncRecordTableSchema(t *testing.T) {
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,
},
)
updatedCollection.Indexes = types.JsonArray[string]{"create index idx_title_renamed on anything (title_renamed)"}
scenarios := []struct {
name string
newCollection *models.Collection
oldCollection *models.Collection
expectedColumns []string
expectedIndexesCount int
}{
{
"new base collection",
&models.Collection{
Name: "new_table",
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "test",
Type: schema.FieldTypeText,
},
),
},
nil,
[]string{"id", "created", "updated", "test"},
0,
},
{
"new auth collection",
&models.Collection{
Name: "new_table_auth",
Type: models.CollectionTypeAuth,
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "test",
Type: schema.FieldTypeText,
},
),
Indexes: types.JsonArray[string]{"create index idx_auth_test on anything (email, username)"},
},
nil,
[]string{
"id", "created", "updated", "test",
"username", "email", "verified", "emailVisibility",
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
},
4,
},
{
"no changes",
oldCollection,
oldCollection,
[]string{"id", "created", "updated", "title", "active"},
3,
},
{
"renamed table, deleted column, renamed columnd and new column",
updatedCollection,
oldCollection,
[]string{"id", "created", "updated", "title_renamed", "new_field"},
1,
},
}
for _, s := range scenarios {
err := app.Dao().SyncRecordTableSchema(s.newCollection, s.oldCollection)
if err != nil {
t.Errorf("[%s] %v", s.name, err)
continue
}
if !app.Dao().HasTable(s.newCollection.Name) {
t.Errorf("[%s] Expected table %s to exist", s.name, s.newCollection.Name)
}
cols, _ := app.Dao().TableColumns(s.newCollection.Name)
if len(cols) != len(s.expectedColumns) {
t.Errorf("[%s] Expected columns %v, got %v", s.name, s.expectedColumns, cols)
}
for _, c := range cols {
if !list.ExistInSlice(c, s.expectedColumns) {
t.Errorf("[%s] Couldn't find column %s in %v", s.name, c, s.expectedColumns)
}
}
indexes, _ := app.Dao().TableIndexes(s.newCollection.Name)
if totalIndexes := len(indexes); totalIndexes != s.expectedIndexesCount {
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.name, s.expectedIndexesCount, totalIndexes, indexes)
}
}
}
func TestSingleVsMultipleValuesNormalization(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
// mock field changes
{
selectOneField := collection.Schema.GetFieldByName("select_one")
opt := selectOneField.Options.(*schema.SelectOptions)
opt.MaxSelect = 2
}
{
selectManyField := collection.Schema.GetFieldByName("select_many")
opt := selectManyField.Options.(*schema.SelectOptions)
opt.MaxSelect = 1
}
{
fileOneField := collection.Schema.GetFieldByName("file_one")
opt := fileOneField.Options.(*schema.FileOptions)
opt.MaxSelect = 2
}
{
fileManyField := collection.Schema.GetFieldByName("file_many")
opt := fileManyField.Options.(*schema.FileOptions)
opt.MaxSelect = 1
}
{
relOneField := collection.Schema.GetFieldByName("rel_one")
opt := relOneField.Options.(*schema.RelationOptions)
opt.MaxSelect = types.Pointer(2)
}
{
relManyField := collection.Schema.GetFieldByName("rel_many")
opt := relManyField.Options.(*schema.RelationOptions)
opt.MaxSelect = types.Pointer(1)
}
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
type expectation struct {
SelectOne string `db:"select_one"`
SelectMany string `db:"select_many"`
FileOne string `db:"file_one"`
FileMany string `db:"file_many"`
RelOne string `db:"rel_one"`
RelMany string `db:"rel_many"`
}
scenarios := []struct {
recordId string
expected expectation
}{
{
"imy661ixudk5izi",
expectation{
SelectOne: `[]`,
SelectMany: ``,
FileOne: `[]`,
FileMany: ``,
RelOne: `[]`,
RelMany: ``,
},
},
{
"al1h9ijdeojtsjy",
expectation{
SelectOne: `["optionB"]`,
SelectMany: `optionB`,
FileOne: `["300_Jsjq7RdBgA.png"]`,
FileMany: ``,
RelOne: `["84nmscqy84lsi1t"]`,
RelMany: `oap640cot4yru2s`,
},
},
{
"84nmscqy84lsi1t",
expectation{
SelectOne: `["optionB"]`,
SelectMany: `optionC`,
FileOne: `["test_d61b33QdDU.txt"]`,
FileMany: `test_tC1Yc87DfC.txt`,
RelOne: `[]`,
RelMany: `oap640cot4yru2s`,
},
},
}
for _, s := range scenarios {
result := new(expectation)
err := app.Dao().DB().Select(
"select_one",
"select_many",
"file_one",
"file_many",
"rel_one",
"rel_many",
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
if err != nil {
t.Errorf("[%s] Failed to load record: %v", s.recordId, err)
continue
}
encodedResult, err := json.Marshal(result)
if err != nil {
t.Errorf("[%s] Failed to encode result: %v", s.recordId, err)
continue
}
encodedExpectation, err := json.Marshal(s.expected)
if err != nil {
t.Errorf("[%s] Failed to encode expectation: %v", s.recordId, err)
continue
}
if !bytes.EqualFold(encodedExpectation, encodedResult) {
t.Errorf("[%s] Expected \n%s, \ngot \n%s", s.recordId, encodedExpectation, encodedResult)
}
}
}
+235 -101
View File
@@ -11,10 +11,12 @@ import (
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRecordQuery(t *testing.T) {
@@ -34,6 +36,91 @@ func TestRecordQuery(t *testing.T) {
}
}
func TestRecordQueryOneWithRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
id := "84nmscqy84lsi1t"
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": id})
record := &models.Record{}
if err := q.One(record); err != nil {
t.Fatal(err)
}
if record.GetString("id") != id {
t.Fatalf("Expected record with id %q, got %q", id, record.GetString("id"))
}
}
func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
id1 := "84nmscqy84lsi1t"
id2 := "al1h9ijdeojtsjy"
{
records := []models.Record{}
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": []any{id1, id2}}).
OrderBy("created asc")
if err := q.All(&records); err != nil {
t.Fatal(err)
}
if len(records) != 2 {
t.Fatalf("Expected %d records, got %d", 2, len(records))
}
if records[0].Id != id1 {
t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
}
if records[1].Id != id2 {
t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
}
}
{
records := []*models.Record{}
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": []any{id1, id2}}).
OrderBy("created asc")
if err := q.All(&records); err != nil {
t.Fatal(err)
}
if len(records) != 2 {
t.Fatalf("Expected %d records, got %d", 2, len(records))
}
if records[0].Id != id1 {
t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
}
if records[1].Id != id2 {
t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
}
}
}
func TestFindRecordById(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -632,10 +719,16 @@ func TestDeleteRecord(t *testing.T) {
// delete existing record + cascade
// ---
calledQueries := []string{}
app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.Dao().NonconcurrentDB().(*dbx.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) {
app.Dao().ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.Dao().NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
app.Dao().ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
@@ -655,121 +748,162 @@ func TestDeleteRecord(t *testing.T) {
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
expectedRelManyPart := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
}
expectedRelOneJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
expectedRelOnePart := "SELECT DISTINCT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`="
if !strings.Contains(joinedQueries, expectedRelOnePart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
}
}
func TestSyncRecordTableSchema(t *testing.T) {
func TestDeleteRecordBatchProcessing(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
if err := createMockBatchProcessingData(app.Dao()); err != nil {
t.Fatal(err)
}
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
// find and delete the first c1 record to trigger cascade
mainRecord, _ := app.Dao().FindRecordById("c1", "a")
if err := app.Dao().DeleteRecord(mainRecord); 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"},
},
// check if the main record was deleted
_, err := app.Dao().FindRecordById(mainRecord.Collection().Id, mainRecord.Id)
if err == nil {
t.Fatal("The main record wasn't deleted")
}
for i, scenario := range scenarios {
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
if err != nil {
t.Errorf("(%d) %v", i, err)
continue
}
// check if the c1 b rel field were updated
c1RecordB, err := app.Dao().FindRecordById("c1", "b")
if err != nil || c1RecordB.GetString("rel") != "" {
t.Fatalf("Expected c1RecordB.rel to be nil, got %v", c1RecordB.GetString("rel"))
}
if !app.Dao().HasTable(scenario.newCollection.Name) {
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
// check if the c2 rel fields were updated
c2Records, err := app.Dao().FindRecordsByExpr("c2", nil)
if err != nil || len(c2Records) == 0 {
t.Fatalf("Failed to fetch c2 records: %v", err)
}
for _, r := range c2Records {
ids := r.GetStringSlice("rel")
if len(ids) != 1 || ids[0] != "b" {
t.Fatalf("Expected only 'b' rel id, got %v", ids)
}
}
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)
}
}
// check if all c3 relations were deleted
c3Records, err := app.Dao().FindRecordsByExpr("c3", nil)
if err != nil {
t.Fatalf("Failed to fetch c3 records: %v", err)
}
if total := len(c3Records); total != 0 {
t.Fatalf("Expected c3 records to be deleted, found %d", total)
}
}
func createMockBatchProcessingData(dao *daos.Dao) error {
// create mock collection without relation
c1 := &models.Collection{}
c1.Id = "c1"
c1.Name = c1.Id
c1.Schema = schema.NewSchema(
&schema.SchemaField{
Name: "text",
Type: schema.FieldTypeText,
},
// self reference
&schema.SchemaField{
Name: "rel",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: types.Pointer(1),
CollectionId: "c1",
CascadeDelete: false, // should unset all rel fields
},
},
)
if err := dao.SaveCollection(c1); err != nil {
return err
}
// create mock collection with a multi-rel field
c2 := &models.Collection{}
c2.Id = "c2"
c2.Name = c2.Id
c2.Schema = schema.NewSchema(
&schema.SchemaField{
Name: "rel",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: types.Pointer(10),
CollectionId: "c1",
CascadeDelete: false, // should unset all rel fields
},
},
)
if err := dao.SaveCollection(c2); err != nil {
return err
}
// create mock collection with a single-rel field
c3 := &models.Collection{}
c3.Id = "c3"
c3.Name = c3.Id
c3.Schema = schema.NewSchema(
&schema.SchemaField{
Name: "rel",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: types.Pointer(1),
CollectionId: "c1",
CascadeDelete: true, // should delete all c3 records
},
},
)
if err := dao.SaveCollection(c3); err != nil {
return err
}
// insert mock records
c1RecordA := models.NewRecord(c1)
c1RecordA.Id = "a"
c1RecordA.Set("rel", c1RecordA.Id) // self reference
if err := dao.Save(c1RecordA); err != nil {
return err
}
c1RecordB := models.NewRecord(c1)
c1RecordB.Id = "b"
c1RecordB.Set("rel", c1RecordA.Id) // rel to another record from the same collection
if err := dao.Save(c1RecordB); err != nil {
return err
}
for i := 0; i < 4500; i++ {
c2Record := models.NewRecord(c2)
c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
if err := dao.Save(c2Record); err != nil {
return err
}
c3Record := models.NewRecord(c3)
c3Record.Set("rel", c1RecordA.Id)
if err := dao.Save(c3Record); err != nil {
return err
}
}
// set the same id as the relation for at least 1 record
// to check whether the correct condition will be added
c3Record := models.NewRecord(c3)
c3Record.Set("rel", c1RecordA.Id)
c3Record.Id = c1RecordA.Id
if err := dao.Save(c3Record); err != nil {
return err
}
return nil
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
_, err := dao.DB().Delete(tableName, expr).Execute()
_, err := dao.NonconcurrentDB().Delete(tableName, expr).Execute()
return err
}
+66 -5
View File
@@ -1,16 +1,19 @@
package daos
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
)
// HasTable checks if a table with the provided name exists (case insensitive).
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
func (dao *Dao) HasTable(tableName string) bool {
var exists bool
err := dao.DB().Select("count(*)").
From("sqlite_schema").
AndWhere(dbx.HashExp{"type": "table"}).
AndWhere(dbx.HashExp{"type": []any{"table", "view"}}).
AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})).
Limit(1).
Row(&exists)
@@ -18,8 +21,8 @@ func (dao *Dao) HasTable(tableName string) bool {
return err == nil && exists
}
// GetTableColumns returns all column names of a single table by its name.
func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
// TableColumns returns all column names of a single table by its name.
func (dao *Dao) TableColumns(tableName string) ([]string, error) {
columns := []string{}
err := dao.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
@@ -29,9 +32,67 @@ func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
return columns, err
}
// TableInfo returns the `table_info` pragma result for the specified table.
func (dao *Dao) TableInfo(tableName string) ([]*models.TableInfoRow, error) {
info := []*models.TableInfoRow{}
err := dao.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
All(&info)
if err != nil {
return nil, err
}
// mattn/go-sqlite3 doesn't throw an error on invalid or missing table
// so we additionally have to check whether the loaded info result is nonempty
if len(info) == 0 {
return nil, fmt.Errorf("empty table info probably due to invalid or missing table %s", tableName)
}
return info, nil
}
// TableIndexes returns a name grouped map with all non empty index of the specified table.
//
// Note: This method doesn't return an error on nonexisting table.
func (dao *Dao) TableIndexes(tableName string) (map[string]string, error) {
indexes := []struct {
Name string
Sql string
}{}
err := dao.DB().Select("name", "sql").
From("sqlite_master").
AndWhere(dbx.NewExp("sql is not null")).
AndWhere(dbx.HashExp{
"type": "index",
"tbl_name": tableName,
}).
All(&indexes)
if err != nil {
return nil, err
}
result := make(map[string]string, len(indexes))
for _, idx := range indexes {
result[idx.Name] = idx.Sql
}
return result, nil
}
// DeleteTable drops the specified table.
//
// This method is a no-op if a table with the provided name doesn't exist.
//
// Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input!
func (dao *Dao) DeleteTable(tableName string) error {
_, err := dao.DB().DropTable(tableName).Execute()
_, err := dao.DB().NewQuery(fmt.Sprintf(
"DROP TABLE IF EXISTS {{%s}}",
tableName,
)).Execute()
return err
}
+87 -13
View File
@@ -3,6 +3,7 @@ package daos_test
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
@@ -23,17 +24,18 @@ func TestHasTable(t *testing.T) {
{"_admins", true},
{"demo3", true},
{"DEMO3", true}, // table names are case insensitives by default
{"view1", true}, // view
}
for i, scenario := range scenarios {
result := app.Dao().HasTable(scenario.tableName)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
}
}
}
func TestGetTableColumns(t *testing.T) {
func TestTableColumns(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -45,21 +47,50 @@ func TestGetTableColumns(t *testing.T) {
{"_params", []string{"id", "key", "value", "created", "updated"}},
}
for i, scenario := range scenarios {
columns, _ := app.Dao().GetTableColumns(scenario.tableName)
for i, s := range scenarios {
columns, _ := app.Dao().TableColumns(s.tableName)
if len(columns) != len(scenario.expected) {
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expected, columns)
if len(columns) != len(s.expected) {
t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
continue
}
for _, c := range columns {
if !list.ExistInSlice(c, scenario.expected) {
t.Errorf("(%d) Didn't expect column %s", i, c)
if !list.ExistInSlice(c, s.expected) {
t.Errorf("[%d] Didn't expect column %s", i, c)
}
}
}
}
func TestTableInfo(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
tableName string
expected string
}{
{"", "null"},
{"missing", "null"},
{
"_admins",
`[{"PK":1,"Index":0,"Name":"id","Type":"TEXT","NotNull":false,"DefaultValue":null},{"PK":0,"Index":1,"Name":"avatar","Type":"INTEGER","NotNull":true,"DefaultValue":0},{"PK":0,"Index":2,"Name":"email","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":3,"Name":"tokenKey","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":4,"Name":"passwordHash","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":5,"Name":"lastResetSentAt","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":6,"Name":"created","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":7,"Name":"updated","Type":"TEXT","NotNull":true,"DefaultValue":""}]`,
},
}
for i, s := range scenarios {
rows, _ := app.Dao().TableInfo(s.tableName)
raw, _ := json.Marshal(rows)
rawStr := string(raw)
if rawStr != s.expected {
t.Errorf("[%d] Expected \n%v, \ngot \n%v", i, s.expected, rawStr)
}
}
}
func TestDeleteTable(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -69,16 +100,17 @@ func TestDeleteTable(t *testing.T) {
expectError bool
}{
{"", true},
{"test", true},
{"test", false}, // missing tables are ignored
{"_admins", false},
{"demo3", false},
}
for i, scenario := range scenarios {
err := app.Dao().DeleteTable(scenario.tableName)
for i, s := range scenarios {
err := app.Dao().DeleteTable(s.tableName)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
if hasErr != s.expectError {
t.Errorf("[%d] Expected hasErr %v, got %v", i, s.expectError, hasErr)
}
}
}
@@ -107,3 +139,45 @@ func TestVacuum(t *testing.T) {
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
}
}
func TestTableIndexes(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
table string
expectError bool
expectIndexes []string
}{
{
"missing",
false,
nil,
},
{
"demo2",
false,
[]string{"idx_demo2_created", "idx_unique_demo2_title", "idx_demo2_active"},
},
}
for _, s := range scenarios {
result, err := app.Dao().TableIndexes(s.table)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v", s.table, s.expectError, hasErr)
}
if len(s.expectIndexes) != len(result) {
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.table, len(s.expectIndexes), len(result), result)
continue
}
for _, name := range s.expectIndexes {
if result[name] == "" {
t.Errorf("[%s] Missing index %q in \n%v", s.table, name, result)
}
}
}
}
+590
View File
@@ -0,0 +1,590 @@
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.TableInfo(name); err != nil {
// manually cleanup previously created view in case the func
// is called in a nested transaction and the error is discarded
txDao.DeleteView(name)
return err
}
return nil
})
}
// CreateViewSchema creates a new view schema from the provided select query.
//
// There are some caveats:
// - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
result := schema.NewSchema()
suggestedFields, err := dao.parseQueryToFields(selectQuery)
if err != nil {
return result, err
}
// note wrap in a transaction in case the selectQuery contains
// multiple statements allowing us to rollback on any error
txErr := dao.RunInTransaction(func(txDao *Dao) error {
tempView := "_temp_" + security.PseudorandomString(5)
// create a temp view with the provided query
if err := txDao.SaveView(tempView, selectQuery); err != nil {
return err
}
defer txDao.DeleteView(tempView)
// extract the generated view table info
info, err := txDao.TableInfo(tempView)
if err != nil {
return err
}
var hasId bool
for _, row := range info {
if row.Name == schema.FieldNameId {
hasId = true
}
if list.ExistInSlice(row.Name, schema.BaseModelFieldNames()) {
continue // skip base model fields since they are not part of the schema
}
var field *schema.SchemaField
if f, ok := suggestedFields[row.Name]; ok {
field = f.field
} else {
field = defaultViewField(row.Name)
}
result.AddField(field)
}
if !hasId {
return errors.New("missing required id column (you 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{}
query := dao.RecordQuery(qf.collection).Limit(1)
if opt, ok := qf.original.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{cleanFieldName: filename})
} else {
query.InnerJoin(fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{_je_file}}`,
cleanFieldName, cleanFieldName, cleanFieldName,
), dbx.HashExp{"_je_file.value": filename})
}
if err := query.One(record); err != nil {
return nil, err
}
return record, nil
}
// -------------------------------------------------------------------
// Raw query to schema helpers
// -------------------------------------------------------------------
type queryField struct {
// field is the final resolved field.
field *schema.SchemaField
// collection refers to the original field's collection model.
// It could be nil if the found query field is not from a collection schema.
collection *models.Collection
// original is the original found collection field.
// It could be nil if the found query field is not from a collection schema.
original *schema.SchemaField
}
func defaultViewField(name string) *schema.SchemaField {
return &schema.SchemaField{
Name: name,
Type: schema.FieldTypeJson,
}
}
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
if len(parts) == 2 {
fieldName = parts[1]
collection = collections[parts[0]]
} else {
fieldName = parts[0]
collection = collections[mainTable.alias]
}
// fallback to the default field if the found column is not from a collection schema
if collection == nil {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
}
continue
}
if fieldName == "*" {
return nil, errors.New("dynamic column names are not supported")
}
// find the first field by name (case insensitive)
var field *schema.SchemaField
for _, f := range collection.Schema.Fields() {
if strings.EqualFold(f.Name, fieldName) {
field = f
break
}
}
if field != nil {
clone := *field
clone.Id = "" // unset to prevent duplications if the same field is aliased multiple times
clone.Name = col.alias
result[col.alias] = &queryField{
field: &clone,
collection: collection,
original: field,
}
continue
}
if fieldName == schema.FieldNameId {
// convert to relation since it is a direct id reference
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: types.Pointer(1),
CollectionId: collection.Id,
},
},
collection: collection,
}
} else if fieldName == schema.FieldNameCreated || fieldName == schema.FieldNameUpdated {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeDate,
},
collection: collection,
}
} else if fieldName == schema.FieldNameUsername && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeText,
},
collection: collection,
}
} else if fieldName == schema.FieldNameEmail && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeEmail,
},
collection: collection,
}
} else if (fieldName == schema.FieldNameVerified || fieldName == schema.FieldNameEmailVisibility) && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeBool,
},
collection: collection,
}
} else {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
collection: collection,
}
}
}
return result, nil
}
func (dao *Dao) findCollectionsByIdentifiers(tables []identifier) (map[string]*models.Collection, error) {
names := make([]any, 0, len(tables))
for _, table := range tables {
if strings.Contains(table.alias, "(") {
continue // skip expressions
}
names = append(names, table.original)
}
if len(names) == 0 {
return nil, nil
}
result := make(map[string]*models.Collection, len(names))
collections := make([]*models.Collection, 0, len(names))
err := dao.CollectionQuery().
AndWhere(dbx.In("name", names...)).
All(&collections)
if err != nil {
return nil, err
}
for _, table := range tables {
for _, collection := range collections {
if collection.Name == table.original {
result[table.alias] = collection
}
}
}
return result, nil
}
// -------------------------------------------------------------------
// Raw query identifiers parser
// -------------------------------------------------------------------
var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(inner join|outer join|left join|right join|join)\s+?`)
var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group by|having|order|limit|with)\s+?`)
var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
type identifier struct {
original string
alias string
}
type identifiersParser struct {
columns []identifier
tables []identifier
}
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(selectQuery, ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "")
tk := tokenizer.NewFromString(str)
tk.Separators(',', ' ', '\n', '\t')
tk.KeepSeparator(true)
var skip bool
var partType string
var activeBuilder *strings.Builder
var selectParts strings.Builder
var fromParts strings.Builder
var joinParts strings.Builder
for {
token, err := tk.Scan()
if err != nil {
if err != io.EOF {
return err
}
break
}
trimmed := strings.ToLower(strings.TrimSpace(token))
switch trimmed {
case "select":
skip = false
partType = "select"
activeBuilder = &selectParts
case "distinct":
continue // ignore as it is not important for the identifiers parsing
case "from":
skip = false
partType = "from"
activeBuilder = &fromParts
case "_join_":
skip = false
// the previous part was also a join
if partType == "join" {
joinParts.WriteString(",")
}
partType = "join"
activeBuilder = &joinParts
case "_discard_":
// skip following tokens
skip = true
default:
isJoin := partType == "join"
if isJoin && trimmed == "on" {
skip = true
}
if !skip && activeBuilder != nil {
activeBuilder.WriteString(" ")
activeBuilder.WriteString(token)
}
}
}
selects, err := extractIdentifiers(selectParts.String())
if err != nil {
return err
}
froms, err := extractIdentifiers(fromParts.String())
if err != nil {
return err
}
joins, err := extractIdentifiers(joinParts.String())
if err != nil {
return err
}
p.columns = selects
p.tables = froms
p.tables = append(p.tables, joins...)
return nil
}
func extractIdentifiers(rawExpression string) ([]identifier, error) {
rawTk := tokenizer.NewFromString(rawExpression)
rawTk.Separators(',')
rawIdentifiers, err := rawTk.ScanAll()
if err != nil {
return nil, err
}
result := make([]identifier, 0, len(rawIdentifiers))
for _, rawIdentifier := range rawIdentifiers {
tk := tokenizer.NewFromString(rawIdentifier)
tk.Separators(' ', '\n', '\t')
parts, err := tk.ScanAll()
if err != nil {
return nil, err
}
resolved, err := identifierFromParts(parts)
if err != nil {
return nil, err
}
result = append(result, resolved)
}
return result, nil
}
func identifierFromParts(parts []string) (identifier, error) {
var result identifier
switch len(parts) {
case 3:
if !strings.EqualFold(parts[1], "as") {
return result, fmt.Errorf(`invalid identifier part - expected "as", got %v`, parts[1])
}
result.original = parts[0]
result.alias = parts[2]
case 2:
result.original = parts[0]
result.alias = parts[1]
case 1:
subParts := strings.Split(parts[0], ".")
result.original = parts[0]
result.alias = subParts[len(subParts)-1]
default:
return result, fmt.Errorf(`invalid identifier parts %v`, parts)
}
result.original = trimRawIdentifier(result.original)
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, ".")
}
+612
View File
@@ -0,0 +1,612 @@
package daos_test
import (
"encoding/json"
"fmt"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func ensureNoTempViews(app core.App, t *testing.T) {
var total int
err := app.Dao().DB().Select("count(*)").
From("sqlite_schema").
AndWhere(dbx.HashExp{"type": "view"}).
AndWhere(dbx.NewExp(`[[name]] LIKE '%\_temp\_%' ESCAPE '\'`)).
Limit(1).
Row(&total)
if err != nil {
t.Fatalf("Failed to check for temp views: %v", err)
}
if total > 0 {
t.Fatalf("Expected all temp views to be deleted, got %d", total)
}
}
func TestDeleteView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
viewName string
expectError bool
}{
{"", true},
{"demo1", true}, // not a view table
{"missing", false}, // missing or already deleted
{"view1", false}, // existing
{"VieW1", false}, // view names are case insensitives
}
for i, s := range scenarios {
err := app.Dao().DeleteView(s.viewName)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
}
}
ensureNoTempViews(app, t)
}
func TestSaveView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
scenarioName string
viewName string
query string
expectError bool
expectColumns []string
}{
{
"empty name and query",
"",
"",
true,
nil,
},
{
"empty name",
"",
"select * from _admins",
true,
nil,
},
{
"empty query",
"123Test",
"",
true,
nil,
},
{
"invalid query",
"123Test",
"123 456",
true,
nil,
},
{
"missing table",
"123Test",
"select id from missing",
true,
nil,
},
{
"non select query",
"123Test",
"drop table _admins",
true,
nil,
},
{
"multiple select queries",
"123Test",
"select *, count(id) as c from _admins; select * from demo1;",
true,
nil,
},
{
"try to break the parent parenthesis",
"123Test",
"select *, count(id) as c from `_admins`)",
true,
nil,
},
{
"simple select query (+ trimmed semicolon)",
"123Test",
";select *, count(id) as c from _admins;",
false,
[]string{
"id", "created", "updated",
"passwordHash", "tokenKey", "email",
"lastResetSentAt", "avatar", "c",
},
},
{
"update old view with new query",
"123Test",
"select 1 as test from _admins",
false,
[]string{"test"},
},
}
for _, s := range scenarios {
err := app.Dao().SaveView(s.viewName, s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
infoRows, err := app.Dao().TableInfo(s.viewName)
if err != nil {
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
continue
}
if len(s.expectColumns) != len(infoRows) {
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
continue
}
for _, row := range infoRows {
if !list.ExistInSlice(row.Name, s.expectColumns) {
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
}
}
}
ensureNoTempViews(app, t)
}
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
_, err := txDao.CreateViewSchema("select id from missing")
if err == nil {
t.Fatal("Expected error, got nil")
}
return nil
})
ensureNoTempViews(app, t)
}
func TestCreateViewSchema(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
query string
expectError bool
expectFields map[string]string // name-type pairs
}{
{
"empty query",
"",
true,
nil,
},
{
"invalid query",
"test 123456",
true,
nil,
},
{
"missing table",
"select id from missing",
true,
nil,
},
{
"query with wildcard column",
"select a.id, a.* from demo1 a",
true,
nil,
},
{
"query without id",
"select text, url, created, updated from demo1",
true,
nil,
},
{
"query with comments",
`
select
-- test single line
id,
text,
/* multi
line comment */
url, created, updated from demo1
`,
false,
map[string]string{
"text": schema.FieldTypeText,
"url": schema.FieldTypeUrl,
},
},
{
"query with all fields and quoted identifiers",
`
select
"id",
"created",
"updated",
[text],
` + "`bool`" + `,
"url",
"select_one",
"select_many",
"file_one",
"demo1"."file_many",
` + "`demo1`." + "`number`" + ` number_alias,
"email",
"datetime",
"json",
"rel_one",
"rel_many"
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.FieldTypeRelation,
"text_alias": schema.FieldTypeText,
"url_alias": schema.FieldTypeUrl,
"bool_alias": schema.FieldTypeBool,
"number_alias": schema.FieldTypeNumber,
"created_alias": schema.FieldTypeDate,
"updated_alias": schema.FieldTypeDate,
"custom": schema.FieldTypeJson,
},
},
{
"query with distinct and reordered id column",
`select distinct
id as id2,
id,
123 as custom
from demo1`,
false,
map[string]string{
"id2": schema.FieldTypeRelation,
"custom": schema.FieldTypeJson,
},
},
{
"query with aliasing the same field multiple times",
`select
a.id as id,
a.text as alias1,
a.text as alias2,
b.text as alias3,
b.text as alias4
from demo1 a
left join demo1 as b`,
false,
map[string]string{
"alias1": schema.FieldTypeText,
"alias2": schema.FieldTypeText,
"alias3": schema.FieldTypeText,
"alias4": schema.FieldTypeText,
},
},
}
for _, s := range scenarios {
result, err := app.Dao().CreateViewSchema(s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if len(s.expectFields) != len(result.Fields()) {
serialized, _ := json.Marshal(result)
t.Errorf("[%s] Expected %d fields, got %d: \n%s", s.name, len(s.expectFields), len(result.Fields()), serialized)
continue
}
for name, typ := range s.expectFields {
field := result.GetFieldByName(name)
if field == nil {
t.Errorf("[%s] Expected to find field %s, got nil", s.name, name)
continue
}
if field.Type != typ {
t.Errorf("[%s] Expected field %s to be %q, got %s", s.name, name, typ, field.Type)
continue
}
}
}
ensureNoTempViews(app, t)
}
func TestFindRecordByViewFile(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
prevCollection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
totalLevels := 6
// create collection view mocks
fileOneAlias := "file_one one0"
fileManyAlias := "file_many many0"
mockCollections := make([]*models.Collection, 0, totalLevels)
for i := 0; i <= totalLevels; i++ {
view := new(models.Collection)
view.Type = models.CollectionTypeView
view.Name = fmt.Sprintf("_test_view%d", i)
view.SetOptions(&models.CollectionViewOptions{
Query: fmt.Sprintf(
"select id, %s, %s from %s",
fileOneAlias,
fileManyAlias,
prevCollection.Name,
),
})
// save view
if err := app.Dao().SaveCollection(view); err != nil {
t.Fatalf("Failed to save view%d: %v", i, err)
}
mockCollections = append(mockCollections, view)
prevCollection = view
fileOneAlias = fmt.Sprintf("one%d one%d", i, i+1)
fileManyAlias = fmt.Sprintf("many%d many%d", i, i+1)
}
fileOneName := "test_d61b33QdDU.txt"
fileManyName := "test_QZFjKjXchk.txt"
expectedRecordId := "84nmscqy84lsi1t"
scenarios := []struct {
name string
collectionNameOrId string
fileFieldName string
filename string
expectError bool
expectRecordId string
}{
{
"missing collection",
"missing",
"a",
fileOneName,
true,
"",
},
{
"non-view collection",
"demo1",
"file_one",
fileOneName,
true,
"",
},
{
"view collection after the max recursion limit",
mockCollections[totalLevels-1].Name,
fmt.Sprintf("one%d", totalLevels-1),
fileOneName,
true,
"",
},
{
"first view collection (single file)",
mockCollections[0].Name,
"one0",
fileOneName,
false,
expectedRecordId,
},
{
"first view collection (many files)",
mockCollections[0].Name,
"many0",
fileManyName,
false,
expectedRecordId,
},
{
"last view collection before the recursion limit (single file)",
mockCollections[totalLevels-2].Name,
fmt.Sprintf("one%d", totalLevels-2),
fileOneName,
false,
expectedRecordId,
},
{
"last view collection before the recursion limit (many files)",
mockCollections[totalLevels-2].Name,
fmt.Sprintf("many%d", totalLevels-2),
fileManyName,
false,
expectedRecordId,
},
}
for _, s := range scenarios {
record, err := app.Dao().FindRecordByViewFile(
s.collectionNameOrId,
s.fileFieldName,
s.filename,
)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if record.Id != s.expectRecordId {
t.Errorf("[%s] Expected recordId %q, got %q", s.name, s.expectRecordId, record.Id)
}
}
}
+14
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
@@ -52,6 +53,14 @@ func main() {
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
)
var queryTimeout int
app.RootCmd.PersistentFlags().IntVar(
&queryTimeout,
"queryTimeout",
30,
"the default SELECT queries timeout in seconds",
)
app.RootCmd.ParseFlags(os.Args[1:])
// ---------------------------------------------------------------
@@ -70,6 +79,11 @@ func main() {
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 {
// serves static files from the provided public dir (if exists)
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
+23 -7
View File
@@ -1,6 +1,7 @@
package forms
import (
"database/sql"
"errors"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -46,19 +47,34 @@ func (form *AdminLogin) Validate() error {
// Submit validates and submits the admin form.
// 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 {
return nil, err
}
admin, err := form.dao.FindAdminByEmail(form.Identity)
if err != nil {
return nil, err
admin, fetchErr := form.dao.FindAdminByEmail(form.Identity)
// 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) {
return admin, nil
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
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
}
+47
View File
@@ -1,9 +1,11 @@
package forms_test
import (
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -47,3 +49,48 @@ func TestAdminLoginValidateAndSubmit(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)
}
}
+11 -3
View File
@@ -63,7 +63,10 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
// Submit validates and submits the admin password reset confirmation form.
// 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 {
return nil, err
}
@@ -80,8 +83,13 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
if err := form.dao.SaveAdmin(admin); err != nil {
return nil, err
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
admin = m
return form.dao.SaveAdmin(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return admin, nil
+71 -1
View File
@@ -1,9 +1,11 @@
package forms_test
import (
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -54,7 +56,24 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
form.Password = s.password
form.PasswordConfirm = s.passwordConfirm
admin, 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)
}
}
admin, err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if s.expectError {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
hasErr := err != nil
if hasErr != s.expectError {
@@ -78,3 +97,54 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(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")
}
}
+12 -6
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -55,7 +56,10 @@ func (form *AdminPasswordResetRequest) Validate() error {
// Submit validates and submits the form.
// 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 {
return err
}
@@ -71,12 +75,14 @@ func (form *AdminPasswordResetRequest) Submit() error {
return errors.New("You have already requested a password reset.")
}
if err := mails.SendAdminPasswordReset(form.app, admin); err != nil {
return err
}
// update last sent timestamp
admin.LastResetSentAt = types.NowDateTime()
return form.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...)
}
+69 -1
View File
@@ -1,9 +1,11 @@
package forms_test
import (
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -31,7 +33,24 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
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
if hasErr != s.expectError {
@@ -53,3 +72,52 @@ func TestAdminPasswordResetRequestValidateAndSubmit(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")
}
}
+4 -3
View File
@@ -57,6 +57,7 @@ func (form *AdminUpsert) Validate() error {
form.admin.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
validation.By(validators.UniqueId(form.dao, form.admin.TableName())),
).Else(validation.In(form.admin.Id)),
),
validation.Field(
@@ -98,7 +99,7 @@ func (form *AdminUpsert) checkUniqueEmail(value any) error {
//
// You can optionally provide a list of InterceptorFunc to further
// 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 {
return err
}
@@ -116,7 +117,7 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
form.admin.SetPassword(form.Password)
}
return runInterceptors(func() error {
return form.dao.SaveAdmin(form.admin)
return runInterceptors(form.admin, func(admin *models.Admin) error {
return form.dao.SaveAdmin(admin)
}, interceptors...)
}
+8 -8
View File
@@ -137,10 +137,10 @@ func TestAdminUpsertValidateAndSubmit(t *testing.T) {
interceptorCalls := 0
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
err := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptorCalls++
return next()
return next(m)
}
})
@@ -196,16 +196,16 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
interceptorAdminEmail := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptor1Called = true
return next()
return next(m)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptorAdminEmail = admin.Email // to check if the record was filled
interceptor2Called = true
return testErr
+87
View File
@@ -0,0 +1,87 @@
package forms
import (
"regexp"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/core"
)
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
//
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
type AppleClientSecretCreate struct {
app core.App
// ClientId is the identifier of your app (aka. Service ID).
ClientId string `form:"clientId" json:"clientId"`
// TeamId is a 10-character string associated with your developer account
// (usually could be found next to your name in the Apple Developer site).
TeamId string `form:"teamId" json:"teamId"`
// KeyId is a 10-character key identifier generated for the "Sign in with Apple"
// private key associated with your developer account.
KeyId string `form:"keyId" json:"keyId"`
// PrivateKey is the private key associated to your app.
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
PrivateKey string `form:"privateKey" json:"privateKey"`
// Duration specifies how long the generated JWT token should be considered valid.
// The specified value must be in seconds and max 15777000 (~6months).
Duration int `form:"duration" json:"duration"`
}
// NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer
// config created from the provided [core.App] instances.
func NewAppleClientSecretCreate(app core.App) *AppleClientSecretCreate {
form := &AppleClientSecretCreate{
app: app,
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AppleClientSecretCreate) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.ClientId, validation.Required),
validation.Field(&form.TeamId, validation.Required, validation.Length(10, 10)),
validation.Field(&form.KeyId, validation.Required, validation.Length(10, 10)),
validation.Field(&form.PrivateKey, validation.Required, validation.Match(privateKeyRegex)),
validation.Field(&form.Duration, validation.Required, validation.Min(1), validation.Max(15777000)),
)
}
// Submit validates the form and returns a new Apple Client Secret JWT.
func (form *AppleClientSecretCreate) Submit() (string, error) {
if err := form.Validate(); err != nil {
return "", err
}
signKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(strings.TrimSpace(form.PrivateKey)))
if err != nil {
return "", err
}
now := time.Now()
claims := &jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
Subject: form.ClientId,
Issuer: form.TeamId,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(form.Duration) * time.Second)),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = form.KeyId
return token.SignedString(signKey)
}
+117
View File
@@ -0,0 +1,117 @@
package forms_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
privatePem := pem.EncodeToMemory(
&pem.Block{
Type: "PRIVATE KEY",
Bytes: encodedKey,
},
)
scenarios := []struct {
name string
formData map[string]any
expectError bool
}{
{
"empty data",
map[string]any{},
true,
},
{
"invalid data",
map[string]any{
"clientId": "",
"teamId": "123456789",
"keyId": "123456789",
"privateKey": "-----BEGIN PRIVATE KEY----- invalid -----END PRIVATE KEY-----",
"duration": -1,
},
true,
},
{
"valid data",
map[string]any{
"clientId": "123",
"teamId": "1234567890",
"keyId": "1234567891",
"privateKey": string(privatePem),
"duration": 1,
},
false,
},
}
for _, s := range scenarios {
form := forms.NewAppleClientSecretCreate(app)
rawData, marshalErr := json.Marshal(s.formData)
if marshalErr != nil {
t.Errorf("[%s] Failed to marshalize the scenario data: %v", s.name, marshalErr)
continue
}
// load data
loadErr := json.Unmarshal(rawData, form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
secret, err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
if secret == "" {
t.Errorf("[%s] Expected non-empty secret", s.name)
}
claims := jwt.MapClaims{}
token, _, err := jwt.NewParser().ParseUnverified(secret, claims)
if err != nil {
t.Errorf("[%s] Failed to parse token: %v", s.name, err)
}
if alg := token.Header["alg"]; alg != "ES256" {
t.Errorf("[%s] Expected %q alg header, got %q", s.name, "ES256", alg)
}
if kid := token.Header["kid"]; kid != form.KeyId {
t.Errorf("[%s] Expected %q kid header, got %q", s.name, form.KeyId, kid)
}
}
}
+8 -21
View File
@@ -4,8 +4,6 @@ package forms
import (
"regexp"
"github.com/pocketbase/pocketbase/models"
)
// base ID value regex pattern
@@ -13,32 +11,21 @@ var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
// InterceptorNextFunc is a interceptor handler function.
// 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.
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
type InterceptorFunc[T any] func(next InterceptorNextFunc[T]) InterceptorNextFunc[T]
// 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-- {
next = interceptors[i](next)
}
return next()
}
// InterceptorWithRecordNextFunc is a Record interceptor handler function.
// Usually used in combination with InterceptorWithRecordFunc.
type InterceptorWithRecordNextFunc = func(record *models.Record) error
// InterceptorWithRecordFunc defines a single Record interceptor function
// that will execute the provided next func handler.
type InterceptorWithRecordFunc func(next InterceptorWithRecordNextFunc) InterceptorWithRecordNextFunc
// runInterceptorsWithRecord executes the provided list of Record interceptors.
func runInterceptorsWithRecord(record *models.Record, next InterceptorWithRecordNextFunc, interceptors ...InterceptorWithRecordFunc) error {
for i := len(interceptors) - 1; i >= 0; i-- {
next = interceptors[i](next)
}
return next(record)
return next(data)
}
+206 -53
View File
@@ -4,14 +4,16 @@ import (
"encoding/json"
"fmt"
"regexp"
"strings"
"strconv"
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"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/types"
@@ -25,17 +27,18 @@ type CollectionUpsert struct {
dao *daos.Dao
collection *models.Collection
Id string `form:"id" json:"id"`
Type string `form:"type" json:"type"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
ListRule *string `form:"listRule" json:"listRule"`
ViewRule *string `form:"viewRule" json:"viewRule"`
CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
Options types.JsonMap `form:"options" json:"options"`
Id string `form:"id" json:"id"`
Type string `form:"type" json:"type"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
Indexes types.JsonArray[string] `form:"indexes" json:"indexes"`
ListRule *string `form:"listRule" json:"listRule"`
ViewRule *string `form:"viewRule" json:"viewRule"`
CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
Options types.JsonMap `form:"options" json:"options"`
}
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
@@ -56,6 +59,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
form.Type = form.collection.Type
form.Name = form.collection.Name
form.System = form.collection.System
form.Indexes = form.collection.Indexes
form.ListRule = form.collection.ListRule
form.ViewRule = form.collection.ViewRule
form.CreateRule = form.collection.CreateRule
@@ -68,7 +72,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
}
clone, _ := form.collection.Schema.Clone()
if clone != nil {
if clone != nil && form.Type != models.CollectionTypeView {
form.Schema = *clone
} else {
form.Schema = schema.Schema{}
@@ -85,6 +89,16 @@ func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionUpsert) Validate() error {
isAuth := form.Type == models.CollectionTypeAuth
isView := form.Type == models.CollectionTypeView
// generate schema from the query (overwriting any explicit user defined schema)
if isView {
options := models.CollectionViewOptions{}
if err := decodeOptions(form.Options, &options); err != nil {
return err
}
form.Schema, _ = form.dao.CreateViewSchema(options.Query)
}
return validation.ValidateStruct(form,
validation.Field(
@@ -93,6 +107,7 @@ func (form *CollectionUpsert) Validate() error {
form.collection.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
validation.By(validators.UniqueId(form.dao, form.collection.TableName())),
).Else(validation.In(form.collection.Id)),
),
validation.Field(
@@ -102,7 +117,11 @@ func (form *CollectionUpsert) Validate() error {
validation.Field(
&form.Type,
validation.Required,
validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
validation.In(
models.CollectionTypeBase,
models.CollectionTypeAuth,
models.CollectionTypeView,
),
validation.By(form.ensureNoTypeChange),
),
validation.Field(
@@ -113,23 +132,33 @@ func (form *CollectionUpsert) Validate() error {
validation.By(form.ensureNoSystemNameChange),
validation.By(form.checkUniqueName),
),
// validates using the type's own validation rules + some collection's specific
// validates using the type's own validation rules + some collection's specifics
validation.Field(
&form.Schema,
validation.By(form.checkMinSchemaFields),
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureExistingRelationCollectionId),
validation.When(
isAuth,
validation.By(form.ensureNoAuthFieldName),
),
validation.By(form.checkRelationFields),
validation.When(isAuth, validation.By(form.ensureNoAuthFieldName)),
),
validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
validation.Field(
&form.CreateRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(
&form.UpdateRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(
&form.DeleteRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(&form.Indexes, validation.By(form.checkIndexes)),
validation.Field(&form.Options, validation.By(form.checkOptions)),
)
}
@@ -147,11 +176,6 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
}
// ensure that there is no existing table name with the same name
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
}
return nil
}
@@ -202,9 +226,17 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
return nil
}
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
func (form *CollectionUpsert) checkRelationFields(value any) error {
v, _ := value.(schema.Schema)
systemDisplayFields := schema.BaseModelFieldNames()
systemDisplayFields = append(systemDisplayFields,
schema.FieldNameUsername,
schema.FieldNameEmail,
schema.FieldNameEmailVisibility,
schema.FieldNameVerified,
)
for i, field := range v.Fields() {
if field.Type != schema.FieldTypeRelation {
continue
@@ -212,14 +244,56 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
options, _ := field.Options.(*schema.RelationOptions)
if options == nil {
continue
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.NewError(
"validation_schema_invalid_relation_field_options",
"The relation field has invalid field options.",
)},
}
}
if _, err := form.dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_invalid_relation",
"The relation collection doesn't exist.",
)}
// prevent collectionId change
oldField := form.collection.Schema.GetFieldById(field.Id)
if oldField != nil {
oldOptions, _ := oldField.Options.(*schema.RelationOptions)
if oldOptions != nil && oldOptions.CollectionId != options.CollectionId {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_relation_change",
"The relation collection cannot be changed.",
),
}},
}
}
}
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),
),
}},
}
}
}
}
@@ -257,13 +331,15 @@ func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
}
func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
if form.Type == models.CollectionTypeAuth {
return nil // auth collections doesn't require having additional schema fields
}
v, _ := value.(schema.Schema)
v, ok := value.(schema.Schema)
if !ok || len(v.Fields()) == 0 {
return validation.ErrRequired
switch form.Type {
case models.CollectionTypeAuth, models.CollectionTypeView:
return nil // no schema fields constraint
default:
if len(v.Fields()) == 0 {
return validation.ErrRequired
}
}
return nil
@@ -309,18 +385,52 @@ func (form *CollectionUpsert) checkRule(value any) error {
return nil
}
func (form *CollectionUpsert) checkIndexes(value any) error {
v, _ := value.(types.JsonArray[string])
if form.Type == models.CollectionTypeView && len(v) > 0 {
return validation.NewError(
"validation_indexes_not_supported",
"The collection doesn't support indexes.",
)
}
for i, rawIndex := range v {
parsed := dbutils.ParseIndex(rawIndex)
if !parsed.IsValid() {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_invalid_index_expression",
"Invalid CREATE INDEX expression.",
),
}
}
// note: we don't check the index table because it is always
// overwritten by the daos.SyncRecordTableSchema to allow
// easier partial modifications (eg. changing only the collection name).
// if !strings.EqualFold(parsed.TableName, form.Name) {
// return validation.Errors{
// strconv.Itoa(i): validation.NewError(
// "validation_invalid_index_table",
// fmt.Sprintf("The index table must be the same as the collection name."),
// ),
// }
// }
}
return nil
}
func (form *CollectionUpsert) checkOptions(value any) error {
v, _ := value.(types.JsonMap)
if form.Type == models.CollectionTypeAuth {
raw, err := v.MarshalJSON()
if err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
switch form.Type {
case models.CollectionTypeAuth:
options := models.CollectionAuthOptions{}
if err := json.Unmarshal(raw, &options); err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
if err := decodeOptions(v, &options); err != nil {
return err
}
// check the generic validations
@@ -332,6 +442,39 @@ func (form *CollectionUpsert) checkOptions(value any) error {
if err := form.checkRule(options.ManageRule); err != nil {
return validation.Errors{"manageRule": err}
}
case models.CollectionTypeView:
options := models.CollectionViewOptions{}
if err := decodeOptions(v, &options); err != nil {
return err
}
// check the generic validations
if err := options.Validate(); err != nil {
return err
}
// check the query option
if _, err := form.dao.CreateViewSchema(options.Query); err != nil {
return validation.Errors{
"query": validation.NewError(
"validation_invalid_view_query",
fmt.Sprintf("Invalid query - %s", err.Error()),
),
}
}
}
return nil
}
func decodeOptions(options types.JsonMap, result any) error {
raw, err := options.MarshalJSON()
if err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
if err := json.Unmarshal(raw, result); err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
return nil
@@ -343,7 +486,7 @@ func (form *CollectionUpsert) checkOptions(value any) error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Collection]) error {
if err := form.Validate(); err != nil {
return err
}
@@ -367,7 +510,17 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
form.collection.Name = form.Name
}
form.collection.Schema = form.Schema
// view schema is autogenerated on save and cannot have indexes
if !form.collection.IsView() {
form.collection.Schema = form.Schema
// normalize indexes format
form.collection.Indexes = make(types.JsonArray[string], len(form.Indexes))
for i, rawIdx := range form.Indexes {
form.collection.Indexes[i] = dbutils.ParseIndex(rawIdx).Build()
}
}
form.collection.ListRule = form.ListRule
form.collection.ViewRule = form.ViewRule
form.collection.CreateRule = form.CreateRule
@@ -375,7 +528,7 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
form.collection.DeleteRule = form.DeleteRule
form.collection.SetOptions(form.Options)
return runInterceptors(func() error {
return form.dao.SaveCollection(form.collection)
return runInterceptors(form.collection, func(collection *models.Collection) error {
return form.dao.SaveCollection(collection)
}, interceptors...)
}
+218 -24
View File
@@ -10,6 +10,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
@@ -22,7 +23,7 @@ func TestNewCollectionUpsert(t *testing.T) {
collection.Name = "test_name"
collection.Type = "test_type"
collection.System = true
listRule := "testview"
listRule := "test_list"
collection.ListRule = &listRule
viewRule := "test_view"
collection.ViewRule = &viewRule
@@ -98,6 +99,7 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}{
{"empty create (base)", "", "{}", []string{"name", "schema"}},
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
{"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
{"empty update", "demo2", "{}", []string{}},
{
"create failure",
@@ -113,9 +115,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"viewRule": "missing = '123'",
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'"
"deleteRule": "missing = '123'",
"indexes": ["create index '' on '' ()"]
}`,
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
},
{
"create failure - existing name",
@@ -169,7 +172,26 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
[]string{"schema"},
},
{
"create failure - check type options validators",
"create failure - missing relation display field",
"",
`{
"name": "test_new",
"type": "base",
"schema": [
{
"name":"test",
"type":"relation",
"options":{
"collectionId":"wsmn24bux7wo113",
"displayFields":["text", "missing"]
}
}
]
}`,
[]string{"schema"},
},
{
"create failure - check auth options validators",
"",
`{
"name": "test_new",
@@ -181,6 +203,16 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"options"},
},
{
"create failure - check view options validators",
"",
`{
"name": "test_new",
"type": "view",
"options": { "query": "invalid query" }
}`,
[]string{"options"},
},
{
"create success",
"",
@@ -190,13 +222,22 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"system": true,
"schema": [
{"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' && verified = true",
"viewRule": "test1='123' && emailVisibility = true",
"createRule": "test1='123' && email != ''",
"updateRule": "test1='123' && username != ''",
"deleteRule": "test1='123' && id != ''"
"deleteRule": "test1='123' && id != ''",
"indexes": ["create index idx_test_new on anything (test1)"]
}`,
[]string{},
},
@@ -207,9 +248,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"schema": [
{"id":"a123456","name":"test1","type":"url"},
{"id":"b123456","name":"test2","type":"bool"}
]
],
"indexes": ["create index idx_test_new on test_new (test1)", "invalid"]
}`,
[]string{"schema"},
[]string{"schema", "indexes"},
},
{
"update success - rename fields to existing field names (aka. reusing field names)",
@@ -253,6 +295,26 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"type"},
},
{
"update failure - changing relation collection",
"users",
`{
"schema": [
{
"id": "lkeigvv3",
"name": "rel",
"type": "relation",
"options": {
"collectionId": "wzlqyes4orhoygb",
"cascadeDelete": false,
"maxSelect": 1,
"displayFields": null
}
}
]
}`,
[]string{"schema"},
},
{
"update failure - all fields",
"demo2",
@@ -268,9 +330,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'",
"options": {"test": 123}
"options": {"test": 123},
"indexes": ["create index '' from demo2 on (id)"]
}`,
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
},
{
"update success - update all fields",
@@ -286,7 +349,11 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"createRule": "test='123' && email != ''",
"updateRule": "test='123' && username != ''",
"deleteRule": "test='123' && id != ''",
"options": {"minPasswordLength": 10}
"options": {"minPasswordLength": 10},
"indexes": [
"create index idx_clients_test1 on anything (id, email, test)",
"create unique index idx_clients_test2 on clients (id, username, email)"
]
}`,
[]string{},
},
@@ -313,7 +380,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null
"deleteRule": null,
"indexes": []
}`,
[]string{},
},
@@ -329,6 +397,101 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]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;"
},
"indexes": ["create index idx_test_view on upsert_view (id)"]
}`,
[]string{
"listRule",
"viewRule",
"options",
"indexes", // views don't have indexes
},
},
{
"view create success",
"",
`{
"name": "upsert_view",
"type": "view",
"listRule": "id='123' && verified = true",
"viewRule": "id='123' && emailVisibility = true",
"schema": [
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
],
"options": {
"query": "select id, emailVisibility, verified from users"
}
}`,
[]string{
// "schema", should be overwritten by an autogenerated from the query
},
},
{
"view update failure (schema autogeneration and rule fields check)",
"upsert_view",
`{
"name": "upsert_view_2",
"listRule": "id='456' && verified = true",
"viewRule": "id='456'",
"createRule": "id='123'",
"updateRule": "id='123'",
"deleteRule": "id='123'",
"schema": [
{"id":"abc123","name":"verified","type":"bool"}
],
"options": {
"query": "select 1 as id"
}
}`,
[]string{
"listRule", // missing field (ignoring the old or explicit schema)
"createRule", // not allowed
"updateRule", // not allowed
"deleteRule", // not allowed
},
},
{
"view update failure (check query identifiers format)",
"upsert_view",
`{
"listRule": null,
"viewRule": null,
"options": {
"query": "select 1 as id, 2 as [invalid!@#]"
}
}`,
[]string{
"schema", // should fail due to invalid field name
},
},
{
"view update success",
"upsert_view",
`{
"listRule": null,
"viewRule": null,
"options": {
"query": "select 1 as id, 2 as valid"
}
}`,
[]string{},
},
}
for _, s := range scenarios {
@@ -351,10 +514,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptorCalls++
return next()
return next(c)
}
}
@@ -427,10 +590,41 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
}
formSchema, _ := form.Schema.MarshalJSON()
collectionSchema, _ := collection.Schema.MarshalJSON()
if string(formSchema) != string(collectionSchema) {
t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
rawFormSchema, _ := form.Schema.MarshalJSON()
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
t.Errorf("[%s] Expected Schema \n%v, \ngot \n%v", s.testName, string(rawCollectionSchema), string(rawFormSchema))
continue
}
for _, f := range form.Schema.Fields() {
if collection.Schema.GetFieldByName(f.Name) == nil {
t.Errorf("[%s] Missing field %s \nin \n%v", s.testName, f.Name, string(rawFormSchema))
continue
}
}
// check indexes (if any)
allIndexes, _ := app.Dao().TableIndexes(form.Name)
for _, formIdx := range form.Indexes {
parsed := dbutils.ParseIndex(formIdx)
parsed.TableName = form.Name
normalizedIdx := parsed.Build()
var exists bool
for _, idx := range allIndexes {
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
exists = true
continue
}
}
if !exists {
t.Errorf(
"[%s] Missing index %s \nin \n%v", s.testName, normalizedIdx, allIndexes)
continue
}
}
}
}
@@ -451,16 +645,16 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
interceptorCollectionName := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptor1Called = true
return next()
return next(c)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptorCollectionName = collection.Name // to check if the record was filled
interceptor2Called = true
return testErr
+6 -6
View File
@@ -56,17 +56,17 @@ func (form *CollectionsImport) Validate() error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.Collection]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(func() error {
return runInterceptors(form.Collections, func(collections []*models.Collection) error {
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
importErr := txDao.ImportCollections(
form.Collections,
collections,
form.DeleteMissing,
form.beforeRecordsSync,
form.afterSync,
)
if importErr == nil {
return nil
@@ -89,10 +89,10 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
}, interceptors...)
}
func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
func (form *CollectionsImport) afterSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
// refresh the actual persisted collections list
refreshedCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&refreshedCollections); err != nil {
if err := txDao.CollectionQuery().OrderBy("updated ASC").All(&refreshedCollections); err != nil {
return err
}
+86 -16
View File
@@ -38,6 +38,8 @@ func TestCollectionsImportValidate(t *testing.T) {
}
func TestCollectionsImportSubmit(t *testing.T) {
totalCollections := 10
scenarios := []struct {
name string
jsonData string
@@ -52,7 +54,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
"collections": []
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: nil,
},
{
@@ -82,7 +84,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
@@ -101,7 +103,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
@@ -137,7 +139,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: false,
expectCollectionsCount: 10,
expectCollectionsCount: totalCollections + 3,
expectEvents: map[string]int{
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
@@ -160,7 +162,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
@@ -202,9 +204,9 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 5,
"OnModelBeforeDelete": 4,
},
},
{
@@ -213,7 +215,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
"collections": [
{
"id":"sz5l5z67tg7gku0",
"name":"demo2",
"name":"demo2_rename",
"schema":[
{
"id":"_2hlxbmp",
@@ -253,7 +255,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: false,
expectCollectionsCount: 9,
expectCollectionsCount: totalCollections + 2,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
@@ -341,8 +343,76 @@ func TestCollectionsImportSubmit(t *testing.T) {
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnModelBeforeDelete": 5,
"OnModelAfterDelete": 5,
"OnModelBeforeDelete": totalCollections - 2,
"OnModelAfterDelete": totalCollections - 2,
},
},
{
name: "lazy system table name error",
jsonData: `{
"collections": [
{
"name": "_admins",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
},
{
name: "lazy view evaluation",
jsonData: `{
"collections": [
{
"name": "view_before",
"type": "view",
"options": {
"query": "select id, active from base_test"
}
},
{
"name": "base_test",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "view_after_new",
"type": "view",
"options": {
"query": "select id, active from base_test"
}
},
{
"name": "view_after_old",
"type": "view",
"options": {
"query": "select id from demo1"
}
}
]
}`,
expectError: false,
expectCollectionsCount: totalCollections + 4,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 3,
"OnModelAfterUpdate": 3,
"OnModelBeforeCreate": 4,
"OnModelAfterCreate": 4,
},
},
}
@@ -404,16 +474,16 @@ func TestCollectionsImportSubmitInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func(imports []*models.Collection) error {
interceptor1Called = true
return next()
return next(imports)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func(imports []*models.Collection) error {
interceptor2Called = true
return testErr
}
+4 -3
View File
@@ -114,9 +114,9 @@ func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record,
// Submit validates and submits the auth record email change confirmation form.
// On success returns the updated auth record associated to `form.Token`.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// You can optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -130,7 +130,8 @@ func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithReco
authRecord.SetVerified(true)
authRecord.RefreshTokenKey() // invalidate old tokens
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
return form.dao.SaveRecord(m)
}, interceptors...)
+3 -3
View File
@@ -85,7 +85,7 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -165,7 +165,7 @@ func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -173,7 +173,7 @@ func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorEmail = record.Email()
interceptor2Called = true
+3 -3
View File
@@ -62,14 +62,14 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
// Submit validates and sends the change email request.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// You can optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptorsWithRecord(form.record, func(m *models.Record) error {
return runInterceptors(form.record, func(m *models.Record) error {
return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
}, interceptors...)
}
+3 -3
View File
@@ -60,7 +60,7 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -119,7 +119,7 @@ func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -127,7 +127,7 @@ func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor2Called = true
return testErr
+82 -37
View File
@@ -1,8 +1,10 @@
package forms
import (
"context"
"errors"
"fmt"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
@@ -14,12 +16,26 @@ import (
"golang.org/x/oauth2"
)
// RecordOAuth2LoginData defines the OA
type RecordOAuth2LoginData struct {
ExternalAuth *models.ExternalAuth
Record *models.Record
OAuth2User *auth.AuthUser
ProviderClient auth.Provider
}
// 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
@@ -62,6 +78,11 @@ 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,
@@ -87,11 +108,14 @@ func (form *RecordOAuth2Login) checkProviderName(value any) error {
//
// If an auth record doesn't exist, it will make an attempt to create it
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
// You can intercept/modify the create form by setting the optional beforeCreateFuncs argument.
// 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(
beforeCreateFuncs ...func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error,
interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
) (*models.Record, *auth.AuthUser, error) {
if err := form.Validate(); err != nil {
return nil, nil, err
@@ -106,6 +130,11 @@ func (form *RecordOAuth2Login) Submit(
return nil, nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
provider.SetContext(ctx)
// load provider configuration
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
if err := providerConfig.SetupProvider(provider); err != nil {
@@ -147,16 +176,41 @@ func (form *RecordOAuth2Login) Submit(
authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
}
saveErr := form.dao.RunInTransaction(func(txDao *daos.Dao) error {
if authRecord == nil {
authRecord = models.NewRecord(form.collection)
authRecord.RefreshId()
authRecord.MarkAsNew()
createForm := NewRecordUpsert(form.app, authRecord)
interceptorData := &RecordOAuth2LoginData{
ExternalAuth: rel,
Record: authRecord,
OAuth2User: authUser,
ProviderClient: provider,
}
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 authUser.Username != "" && usernameRegex.MatchString(authUser.Username) {
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(form.collection.Id, authUser.Username)
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
@@ -164,10 +218,10 @@ func (form *RecordOAuth2Login) Submit(
// load the OAuth2 profile data as fallback
if createForm.Email == "" {
createForm.Email = authUser.Email
createForm.Email = data.OAuth2User.Email
}
createForm.Verified = false
if createForm.Email == authUser.Email {
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
}
@@ -176,11 +230,8 @@ func (form *RecordOAuth2Login) Submit(
createForm.PasswordConfirm = createForm.Password
}
for _, f := range beforeCreateFuncs {
if f == nil {
continue
}
if err := f(createForm, authRecord, authUser); err != nil {
if form.beforeOAuth2RecordCreateFunc != nil {
if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); err != nil {
return err
}
}
@@ -190,45 +241,39 @@ func (form *RecordOAuth2Login) Submit(
return err
}
} else {
// update the existing auth record empty email if the authUser has one
// 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 authRecord.Email() == "" && authUser.Email != "" {
authRecord.SetEmail(authUser.Email)
if err := txDao.SaveRecord(authRecord); err != nil {
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 authUser)
if !authRecord.Verified() && (authRecord.Email() == "" || authRecord.Email() == authUser.Email) {
authRecord.SetVerified(true)
if err := txDao.SaveRecord(authRecord); err != nil {
// (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 rel == nil {
rel = &models.ExternalAuth{
CollectionId: authRecord.Collection().Id,
RecordId: authRecord.Id,
if data.ExternalAuth == nil {
data.ExternalAuth = &models.ExternalAuth{
CollectionId: data.Record.Collection().Id,
RecordId: data.Record.Id,
Provider: form.Provider,
ProviderId: authUser.Id,
ProviderId: data.OAuth2User.Id,
}
if err := txDao.SaveExternalAuth(rel); err != nil {
if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
return err
}
}
return nil
})
if saveErr != nil {
return nil, authUser, saveErr
}
return authRecord, authUser, nil
}
+32 -14
View File
@@ -1,6 +1,7 @@
package forms
import (
"database/sql"
"errors"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -48,30 +49,47 @@ func (form *RecordPasswordLogin) Validate() error {
// Submit validates and submits the form.
// On success returns the authorized record model.
func (form *RecordPasswordLogin) Submit() (*models.Record, error) {
//
// 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()
if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
return nil, errors.New("Password authentication is not allowed for the collection.")
}
var record *models.Record
var authRecord *models.Record
var fetchErr error
if authOptions.AllowEmailAuth &&
(!authOptions.AllowUsernameAuth || is.EmailFormat.Validate(form.Identity) == nil) {
record, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
} else {
record, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
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)
}
if fetchErr != nil || !record.ValidatePassword(form.Password) {
return nil, errors.New("Invalid login credentials.")
// ignore not found errors to allow custom fetch implementations
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
return nil, fetchErr
}
return record, nil
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
}
+53 -1
View File
@@ -1,13 +1,15 @@
package forms_test
import (
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -128,3 +130,53 @@ func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
}
}
}
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)
}
}
+5 -4
View File
@@ -72,9 +72,9 @@ func (form *RecordPasswordResetConfirm) checkToken(value any) error {
// Submit validates and submits the form.
// On success returns the updated auth record associated to `form.Token`.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
// 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
}
@@ -91,7 +91,8 @@ func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRe
return nil, err
}
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
return form.dao.SaveRecord(m)
}, interceptors...)
+3 -3
View File
@@ -79,7 +79,7 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -157,7 +157,7 @@ func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -165,7 +165,7 @@ func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorTokenKey = record.TokenKey()
interceptor2Called = true
+4 -4
View File
@@ -60,9 +60,9 @@ func (form *RecordPasswordResetRequest) Validate() error {
// Submit validates and submits the form.
// On success, sends a password reset email to the `form.Email` auth record.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.Validate(); err != nil {
return err
}
@@ -81,7 +81,7 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRe
// update last sent timestamp
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
return runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
return runInterceptors(authRecord, func(m *models.Record) error {
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
return err
}
+3 -3
View File
@@ -67,7 +67,7 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -135,7 +135,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -143,7 +143,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorLastResetSentAt = record.LastResetSentAt()
interceptor2Called = true
+303 -174
View File
@@ -7,7 +7,6 @@ import (
"log"
"net/http"
"regexp"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -18,6 +17,7 @@ import (
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/security"
@@ -34,7 +34,7 @@ type RecordUpsert struct {
manageAccess bool
record *models.Record
filesToUpload map[string][]*rest.UploadedFile
filesToUpload map[string][]*filesystem.File
filesToDelete []string // names list
// base model fields
@@ -51,7 +51,7 @@ type RecordUpsert struct {
OldPassword string `json:"oldPassword"`
// ---
Data map[string]any `json:"data"`
data map[string]any
}
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
@@ -66,7 +66,7 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
dao: app.Dao(),
record: record,
filesToDelete: []string{},
filesToUpload: map[string][]*rest.UploadedFile{},
filesToUpload: map[string][]*filesystem.File{},
}
form.loadFormDefaults()
@@ -74,6 +74,11 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
return form
}
// Data returns the loaded form's data.
func (form *RecordUpsert) Data() map[string]any {
return form.data
}
// SetFullManageAccess sets the manageAccess bool flag of the current
// form to enable/disable directly changing some system record fields
// (often used with auth collection records).
@@ -96,9 +101,9 @@ func (form *RecordUpsert) loadFormDefaults() {
form.Verified = form.record.Verified()
}
form.Data = map[string]any{}
form.data = map[string]any{}
for _, field := range form.record.Collection().Schema.Fields() {
form.Data[field.Name] = form.record.Get(field.Name)
form.data[field.Name] = form.record.Get(field.Name)
}
}
@@ -112,49 +117,56 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
return t
}
func (form *RecordUpsert) extractRequestData(r *http.Request, keyPrefix string) (map[string]any, error) {
func (form *RecordUpsert) extractRequestData(
r *http.Request,
keyPrefix string,
) (map[string]any, map[string][]*filesystem.File, error) {
switch form.getContentType(r) {
case "application/json":
return form.extractJsonData(r, keyPrefix)
case "multipart/form-data":
return form.extractMultipartFormData(r, keyPrefix)
default:
return nil, errors.New("Unsupported request Content-Type.")
return nil, nil, errors.New("unsupported request content-type")
}
}
func (form *RecordUpsert) extractJsonData(r *http.Request, keyPrefix string) (map[string]any, error) {
result := map[string]any{}
func (form *RecordUpsert) extractJsonData(
r *http.Request,
keyPrefix string,
) (map[string]any, map[string][]*filesystem.File, error) {
data := map[string]any{}
err := rest.CopyJsonBody(r, &result)
err := rest.CopyJsonBody(r, &data)
if keyPrefix != "" {
parts := strings.Split(keyPrefix, ".")
for _, part := range parts {
if result[part] == nil {
if data[part] == nil {
break
}
if v, ok := result[part].(map[string]any); ok {
result = v
if v, ok := data[part].(map[string]any); ok {
data = v
}
}
}
return result, err
return data, nil, err
}
func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix string) (map[string]any, error) {
result := map[string]any{}
func (form *RecordUpsert) extractMultipartFormData(
r *http.Request,
keyPrefix string,
) (map[string]any, map[string][]*filesystem.File, error) {
// parse form data (if not already)
if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil {
return result, err
return nil, nil, err
}
data := map[string]any{}
filesToUpload := map[string][]*filesystem.File{}
arrayValueSupportTypes := schema.ArraybleFieldTypes()
form.filesToUpload = map[string][]*rest.UploadedFile{}
for fullKey, values := range r.PostForm {
key := fullKey
if keyPrefix != "" {
@@ -162,15 +174,15 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix st
}
if len(values) == 0 {
result[key] = nil
data[key] = nil
continue
}
field := form.record.Collection().Schema.GetFieldByName(key)
if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) {
result[key] = values
data[key] = values
} else {
result[key] = values[0]
data[key] = values[0]
}
}
@@ -196,76 +208,165 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix st
continue
}
options, ok := field.Options.(*schema.FileOptions)
if !ok {
continue
}
if form.filesToUpload[key] == nil {
form.filesToUpload[key] = []*rest.UploadedFile{}
}
if options.MaxSelect == 1 {
form.filesToUpload[key] = append(form.filesToUpload[key], files[0])
} else if options.MaxSelect > 1 {
form.filesToUpload[key] = append(form.filesToUpload[key], files...)
}
filesToUpload[key] = append(filesToUpload[key], files...)
}
return result, nil
}
func (form *RecordUpsert) normalizeData() error {
for _, field := range form.record.Collection().Schema.Fields() {
if v, ok := form.Data[field.Name]; ok {
form.Data[field.Name] = field.PrepareValue(v)
}
}
return nil
return data, filesToUpload, nil
}
// LoadRequest extracts the json or multipart/form-data request data
// and lods it into the form.
//
// File upload is supported only via multipart/form-data.
//
// To DELETE previously uploaded file(s) you can suffix the field name
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
// For single file upload fields, you can skip the index and directly
// reset the field using its field name (eg. `myfile = null`).
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
requestData, err := form.extractRequestData(r, keyPrefix)
requestData, uploadedFiles, err := form.extractRequestData(r, keyPrefix)
if err != nil {
return err
}
return form.LoadData(requestData)
if err := form.LoadData(requestData); err != nil {
return err
}
for key, files := range uploadedFiles {
form.AddFiles(key, files...)
}
return nil
}
// LoadData loads and normalizes the provided data into the form.
// FilesToUpload returns the parsed request files ready for upload.
func (form *RecordUpsert) FilesToUpload() map[string][]*filesystem.File {
return form.filesToUpload
}
// FilesToUpload returns the parsed request filenames ready to be deleted.
func (form *RecordUpsert) FilesToDelete() []string {
return form.filesToDelete
}
// AddFiles adds the provided file(s) to the specified file field.
//
// To DELETE previously uploaded file(s) you can suffix the field name
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
// For single file upload fields, you can skip the index and directly
// reset the field using its field name (eg. `myfile = null`).
// If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
// then the newly added file will REPLACE the existing one.
// In this case if you pass more than 1 files only the first one will be assigned.
//
// If the file field is a MULTI-value file field (aka. "Max Select > 1"),
// then the newly added file(s) will be APPENDED to the existing one(s).
//
// Example
//
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
// form.AddFiles("documents", f1, f2)
func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error {
field := form.record.Collection().Schema.GetFieldByName(key)
if field == nil || field.Type != schema.FieldTypeFile {
return errors.New("invalid field key")
}
options, ok := field.Options.(*schema.FileOptions)
if !ok {
return errors.New("failed to initilize field options")
}
if len(files) == 0 {
return nil // nothing to upload
}
if form.filesToUpload == nil {
form.filesToUpload = map[string][]*filesystem.File{}
}
oldNames := list.ToUniqueStringSlice(form.data[key])
if options.MaxSelect == 1 {
// mark previous file(s) for deletion before replacing
if len(oldNames) > 0 {
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
}
// replace
form.filesToUpload[key] = []*filesystem.File{files[0]}
form.data[key] = field.PrepareValue(files[0].Name)
} else {
// append
form.filesToUpload[key] = append(form.filesToUpload[key], files...)
for _, f := range files {
oldNames = append(oldNames, f.Name)
}
form.data[key] = field.PrepareValue(oldNames)
}
return nil
}
// RemoveFiles removes a single or multiple file from the specified file field.
//
// NB! If filesToDelete is not set it will remove all existing files
// assigned to the file field (including those assigned with AddFiles)!
//
// Example
//
// // mark only only 2 files for removal
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
//
// // mark all "documents" files for removal
// form.AddFiles("documents")
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
field := form.record.Collection().Schema.GetFieldByName(key)
if field == nil || field.Type != schema.FieldTypeFile {
return errors.New("invalid field key")
}
existing := list.ToUniqueStringSlice(form.data[key])
// mark all files for deletion
if len(toDelete) == 0 {
toDelete = make([]string, len(existing))
copy(toDelete, existing)
}
// check for existing files
for i := len(existing) - 1; i >= 0; i-- {
if list.ExistInSlice(existing[i], toDelete) {
form.filesToDelete = append(form.filesToDelete, existing[i])
existing = append(existing[:i], existing[i+1:]...)
}
}
// check for newly uploaded files
for i := len(form.filesToUpload[key]) - 1; i >= 0; i-- {
f := form.filesToUpload[key][i]
if list.ExistInSlice(f.Name, toDelete) {
form.filesToUpload[key] = append(form.filesToUpload[key][:i], form.filesToUpload[key][i+1:]...)
}
}
form.data[key] = field.PrepareValue(existing)
return nil
}
// LoadData loads and normalizes the provided regular record data fields into the form.
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
// load base system fields
if v, ok := requestData["id"]; ok {
if v, ok := requestData[schema.FieldNameId]; ok {
form.Id = cast.ToString(v)
}
// load auth system fields
if form.record.Collection().IsAuth() {
if v, ok := requestData["username"]; ok {
if v, ok := requestData[schema.FieldNameUsername]; ok {
form.Username = cast.ToString(v)
}
if v, ok := requestData["email"]; ok {
if v, ok := requestData[schema.FieldNameEmail]; ok {
form.Email = cast.ToString(v)
}
if v, ok := requestData["emailVisibility"]; ok {
if v, ok := requestData[schema.FieldNameEmailVisibility]; ok {
form.EmailVisibility = cast.ToBool(v)
}
if v, ok := requestData["verified"]; ok {
if v, ok := requestData[schema.FieldNameVerified]; ok {
form.Verified = cast.ToBool(v)
}
if v, ok := requestData["password"]; ok {
@@ -279,8 +380,16 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
}
}
// extend the record schema data with the request data
extendedData := form.record.SchemaData()
// replace modifiers (if any)
requestData = form.record.ReplaceModifers(requestData)
// create a shallow copy of form.data
var extendedData = make(map[string]any, len(form.data))
for k, v := range form.data {
extendedData[k] = v
}
// extend form.data with the request data
rawData, err := json.Marshal(requestData)
if err != nil {
return err
@@ -291,89 +400,58 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
for _, field := range form.record.Collection().Schema.Fields() {
key := field.Name
value := extendedData[key]
value = field.PrepareValue(value)
value := field.PrepareValue(extendedData[key])
if field.Type != schema.FieldTypeFile {
form.Data[key] = value
form.data[key] = value
continue
}
options, _ := field.Options.(*schema.FileOptions)
oldNames := list.ToUniqueStringSlice(form.Data[key])
// -----------------------------------------------------------
// Delete previously uploaded file(s)
// -----------------------------------------------------------
oldNames := form.record.GetStringSlice(key)
submittedNames := list.ToUniqueStringSlice(value)
// ensure that all submitted names are existing to prevent accidental files deletions
if len(submittedNames) > len(oldNames) || len(list.SubtractSlice(submittedNames, oldNames)) != 0 {
return validation.Errors{
key: validation.NewError(
"validation_unknown_filenames",
"The field contains unknown filenames.",
),
}
}
// if empty value was set, mark all previously uploaded files for deletion
if len(list.ToUniqueStringSlice(value)) == 0 && len(oldNames) > 0 {
form.filesToDelete = append(form.filesToDelete, oldNames...)
form.Data[key] = []string{}
// otherwise check for "deleted" (aka. unsubmitted) file names
if len(submittedNames) == 0 && len(oldNames) > 0 {
form.RemoveFiles(key)
} else if len(oldNames) > 0 {
indexesToDelete := make([]int, 0, len(extendedData))
toDelete := []string{}
// search for individual file name to delete (eg. "file.test.png = null")
for i, name := range oldNames {
if v, ok := extendedData[key+"."+name]; ok && cast.ToString(v) == "" {
indexesToDelete = append(indexesToDelete, i)
}
}
// search for individual file index to delete (eg. "file.0 = null")
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
for indexedKey := range extendedData {
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
if indexErr != nil || index >= len(oldNames) {
continue
}
indexesToDelete = append(indexesToDelete, index)
}
}
// slice to fill only with the non-deleted indexes
nonDeleted := make([]string, 0, len(oldNames))
for i, name := range oldNames {
// not marked for deletion
if !list.ExistInSlice(i, indexesToDelete) {
nonDeleted = append(nonDeleted, name)
for _, name := range oldNames {
// submitted as a modifier or a new array
if !list.ExistInSlice(name, submittedNames) {
toDelete = append(toDelete, name)
continue
}
// store the id to actually delete the file later
form.filesToDelete = append(form.filesToDelete, name)
}
form.Data[key] = nonDeleted
if len(toDelete) > 0 {
form.RemoveFiles(key, toDelete...)
}
}
// -----------------------------------------------------------
// Check for new uploaded file
// -----------------------------------------------------------
if len(form.filesToUpload[key]) == 0 {
continue
}
// refresh oldNames list
oldNames = list.ToUniqueStringSlice(form.Data[key])
if options.MaxSelect == 1 {
// delete previous file(s) before replacing
if len(oldNames) > 0 {
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
}
form.Data[key] = form.filesToUpload[key][0].Name()
} else if options.MaxSelect > 1 {
// append the id of each uploaded file instance
for _, file := range form.filesToUpload[key] {
oldNames = append(oldNames, file.Name())
}
form.Data[key] = oldNames
// allow file key reasignments for file names sorting
// (only if all submitted values already exists)
if len(submittedNames) > 0 && len(list.SubtractSlice(submittedNames, oldNames)) == 0 {
form.data[key] = submittedNames
}
}
return form.normalizeData()
return nil
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -386,6 +464,7 @@ func (form *RecordUpsert) Validate() error {
form.record.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
validation.By(validators.UniqueId(form.dao, form.record.TableName())),
).Else(validation.In(form.record.Id)),
),
}
@@ -397,7 +476,7 @@ func (form *RecordUpsert) Validate() error {
&form.Username,
// require only on update, because on create we fallback to auto generated username
validation.When(!form.record.IsNew(), validation.Required),
validation.Length(3, 100),
validation.Length(3, 150),
validation.Match(usernameRegex),
validation.By(form.checkUniqueUsername),
),
@@ -429,13 +508,16 @@ func (form *RecordUpsert) Validate() error {
),
validation.Field(
&form.Password,
validation.When(form.record.IsNew(), validation.Required),
validation.When(
(form.record.IsNew() || form.PasswordConfirm != "" || form.OldPassword != ""),
validation.Required,
),
validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72),
),
validation.Field(
&form.PasswordConfirm,
validation.When(
(form.record.IsNew() || form.Password != ""),
(form.record.IsNew() || form.Password != "" || form.OldPassword != ""),
validation.Required,
),
validation.By(validators.Compare(form.Password)),
@@ -446,7 +528,7 @@ func (form *RecordUpsert) Validate() error {
// - form.manageAccess is not set
// - changing the existing password
validation.When(
!form.record.IsNew() && !form.manageAccess && form.Password != "",
!form.record.IsNew() && !form.manageAccess && (form.Password != "" || form.PasswordConfirm != ""),
validation.Required,
validation.By(form.checkOldPassword),
),
@@ -463,7 +545,7 @@ func (form *RecordUpsert) Validate() error {
form.dao,
form.record,
form.filesToUpload,
).Validate(form.Data)
).Validate(form.data)
}
func (form *RecordUpsert) checkUniqueUsername(value any) error {
@@ -583,7 +665,7 @@ func (form *RecordUpsert) ValidateAndFill() error {
}
}
if form.Password != "" {
if form.Password != "" && form.Password == form.PasswordConfirm {
if err := form.record.SetPassword(form.Password); err != nil {
return err
}
@@ -591,7 +673,7 @@ func (form *RecordUpsert) ValidateAndFill() error {
}
// bulk load the remaining form data
form.record.Load(form.Data)
form.record.Load(form.data)
return nil
}
@@ -607,22 +689,25 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
return err
}
// use the default app.Dao to prevent changing the transaction form.Dao
// and causing "transaction has already been committed or rolled back" error
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
var dryDao *daos.Dao
if form.dao.ConcurrentDB() == form.dao.NonconcurrentDB() {
// it is already in a transaction and therefore use the app concurrent db pool
// to prevent "transaction has already been committed or rolled back" error
dryDao = daos.New(form.app.Dao().ConcurrentDB())
} else {
// otherwise use the form noncurrent dao db pool
dryDao = daos.New(form.dao.NonconcurrentDB())
}
return dryDao.RunInTransaction(func(txDao *daos.Dao) error {
tx, ok := txDao.DB().(*dbx.Tx)
if !ok {
return errors.New("failed to get transaction db")
}
defer tx.Rollback()
txDao.BeforeCreateFunc = nil
txDao.AfterCreateFunc = nil
txDao.BeforeUpdateFunc = nil
txDao.AfterUpdateFunc = nil
if err := txDao.SaveRecord(form.record); err != nil {
return err
return form.prepareError(err)
}
// restore record isNew state
@@ -642,30 +727,59 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.ValidateAndFill(); err != nil {
return err
}
return runInterceptors(func() error {
return runInterceptors(form.record, func(record *models.Record) error {
form.record = record
if !form.record.HasId() {
form.record.RefreshId()
form.record.MarkAsNew()
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return fmt.Errorf("failed to process the uploaded files: %w", err)
}
dao := form.dao.Clone()
// persist the record model
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
// try to cleanup the successfully uploaded files
if _, err := form.deleteFilesByNamesList(form.getFilesToUploadNames()); err != nil && form.app.IsDebug() {
log.Println(err)
// upload new files (if any)
//
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hooks
// to allow uploading AFTER the before app model hooks (eg. in case of an id change)
// but BEFORE the actual record db persistence
// ---
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
if form.dao.BeforeCreateFunc != nil {
if err := form.dao.BeforeCreateFunc(eventDao, m); err != nil {
return err
}
}
return fmt.Errorf("failed to save the record: %w", saveErr)
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
return form.processFilesToUpload()
}
return nil
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
if form.dao.BeforeUpdateFunc != nil {
if err := form.dao.BeforeUpdateFunc(eventDao, m); err != nil {
return err
}
}
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
return form.processFilesToUpload()
}
return nil
}
// ---
// persist the record model
if err := dao.SaveRecord(form.record); err != nil {
return form.prepareError(err)
}
// delete old files (if any)
@@ -680,25 +794,13 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
}, interceptors...)
}
func (form *RecordUpsert) getFilesToUploadNames() []string {
names := []string{}
for fieldKey := range form.filesToUpload {
for _, file := range form.filesToUpload[fieldKey] {
names = append(names, file.Name())
}
}
return names
}
func (form *RecordUpsert) processFilesToUpload() error {
if len(form.filesToUpload) == 0 {
return nil // no parsed file fields
}
if !form.record.HasId() {
return errors.New("the record is not persisted yet")
return errors.New("the record doesn't have an id")
}
fs, err := form.app.NewFilesystem()
@@ -712,8 +814,8 @@ func (form *RecordUpsert) processFilesToUpload() error {
for fieldKey := range form.filesToUpload {
for i, file := range form.filesToUpload[fieldKey] {
path := form.record.BaseFilesPath() + "/" + file.Name()
if err := fs.UploadMultipart(file.Header(), path); err == nil {
path := form.record.BaseFilesPath() + "/" + file.Name
if err := fs.UploadFile(file, path); err == nil {
// keep track of the already uploaded file
uploaded = append(uploaded, path)
} else {
@@ -746,7 +848,7 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
}
if !form.record.HasId() {
return filenames, errors.New("the record doesn't have a unique ID")
return filenames, errors.New("the record doesn't have an id")
}
fs, err := form.app.NewFilesystem()
@@ -779,3 +881,30 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
return filenames, nil
}
// prepareError parses the provided error and tries to return
// user-friendly validation error(s).
func (form *RecordUpsert) prepareError(err error) error {
msg := strings.ToLower(err.Error())
validationErrs := validation.Errors{}
// check for unique constraint failure
if strings.Contains(msg, "unique constraint failed") {
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")
c := form.record.Collection()
for _, f := range c.Schema.Fields() {
// blank space to unify multi-columns lookup
if strings.Contains(msg+" ", fmt.Sprintf("%s.%s ", strings.ToLower(c.Name), f.Name)) {
validationErrs[f.Name] = validation.NewError("validation_not_unique", "Value must be unique")
}
}
}
if len(validationErrs) > 0 {
return validationErrs
}
return err
}
+277 -41
View File
@@ -4,8 +4,10 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -16,6 +18,7 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -44,9 +47,9 @@ func TestNewRecordUpsert(t *testing.T) {
form := forms.NewRecordUpsert(app, record)
val := form.Data["title"]
val := form.Data()["title"]
if val != "test_value" {
t.Errorf("Expected record data to be loaded, got %v", form.Data)
t.Errorf("Expected record data to be loaded, got %v", form.Data())
}
}
@@ -87,9 +90,10 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
"unknown": "test456",
// file fields unset/delete
"file_one": nil,
"file_many.0": "", // delete by index
"file_many.1": "test.png", // should be ignored
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
"file_many.0": "", // delete by index
"file_many-": []string{"test_MaWC6mWyrP.txt", "test_tC1Yc87DfC.txt"}, // multiple delete with modifier
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
"file_many.2": "test.png", // should be ignored
},
},
}
@@ -107,15 +111,15 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
}
if v, ok := form.Data["text"]; !ok || v != "test123" {
if v, ok := form.Data()["text"]; !ok || v != "test123" {
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
}
if v, ok := form.Data["unknown"]; ok {
if v, ok := form.Data()["unknown"]; ok {
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
}
fileOne, ok := form.Data["file_one"]
fileOne, ok := form.Data()["file_one"]
if !ok {
t.Fatal("Expect file_one field to be set")
}
@@ -123,7 +127,7 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
}
fileMany, ok := form.Data["file_many"]
fileMany, ok := form.Data()["file_many"]
if !ok || fileMany == nil {
t.Fatal("Expect file_many field to be set")
}
@@ -147,11 +151,12 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
"a.b.text": "test123",
"a.b.unknown": "test456",
// file fields unset/delete
"a.b.file_one": "",
"a.b.file_many.0": "",
"a.b.file_many.300_WlbFWSGmW9.png": "test.png", // delete by name
"a.b.file_many.1": "test.png", // should be ignored
}, "file_many")
"a.b.file_one-": "test_d61b33QdDU.txt", // delete with modifier
"a.b.file_many.0": "", // delete by index
"a.b.file_many-": "test_tC1Yc87DfC.txt", // delete with modifier
"a.b.file_many.300_WlbFWSGmW9.png": "", // delete by filename
"a.b.file_many.2": "test.png", // should be ignored
}, "a.b.file_many")
if err != nil {
t.Fatal(err)
}
@@ -168,15 +173,15 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
}
if v, ok := form.Data["text"]; !ok || v != "test123" {
if v, ok := form.Data()["text"]; !ok || v != "test123" {
t.Fatalf("Expect text field to be %q, got %q", "test123", v)
}
if v, ok := form.Data["unknown"]; ok {
if v, ok := form.Data()["unknown"]; ok {
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
}
fileOne, ok := form.Data["file_one"]
fileOne, ok := form.Data()["file_one"]
if !ok {
t.Fatal("Expect file_one field to be set")
}
@@ -184,12 +189,12 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
}
fileMany, ok := form.Data["file_many"]
fileMany, ok := form.Data()["file_many"]
if !ok || fileMany == nil {
t.Fatal("Expect file_many field to be set")
}
manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
expectedRemains := 2 // -2 from 3 removed + 1 new upload
expectedRemains := 3 // 5 old; 3 deleted and 1 new uploaded
if manyfilesRemains != expectedRemains {
t.Fatalf("Expect file_many to be %d, got %d (%v)", expectedRemains, manyfilesRemains, fileMany)
}
@@ -214,11 +219,11 @@ func TestRecordUpsertLoadData(t *testing.T) {
t.Fatal(loadErr)
}
if v, ok := form.Data["title"]; !ok || v != "test_new" {
if v, ok := form.Data()["title"]; !ok || v != "test_new" {
t.Fatalf("Expect title field to be %v, got %v", "test_new", v)
}
if v, ok := form.Data["active"]; !ok || v != true {
if v, ok := form.Data()["active"]; !ok || v != true {
t.Fatalf("Expect active field to be %v, got %v", true, v)
}
}
@@ -316,12 +321,10 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
}
// ensure that the record changes weren't persisted
// ---
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
if err != nil {
t.Fatal(err)
}
if recordAfter.GetString("title") == "dry_test" {
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "dry_test")
}
@@ -335,6 +338,66 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
}
}
func TestRecordUpsertDrySubmitWithNestedTx(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "dry_test",
})
if err != nil {
t.Fatal(err)
}
txErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
form := forms.NewRecordUpsert(app, recordBefore)
form.SetDao(txDao)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
form.LoadRequest(req, "")
callbackCalls := 0
result := form.DrySubmit(func(innerTxDao *daos.Dao) error {
callbackCalls++
return nil
})
if result != nil {
t.Fatalf("Expected nil, got error %v", result)
}
// ensure callback was called
if callbackCalls != 1 {
t.Fatalf("Expected callbackCalls to be 1, got %d", callbackCalls)
}
// ensure that the original txDao can still be used after the DrySubmit rollback
if _, err := txDao.FindRecordById(collection.Id, recordBefore.Id); err != nil {
t.Fatalf("Expected the dry submit rollback to not affect the outer tx context, got %v", err)
}
// ensure that the record changes weren't persisted
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
if err != nil {
t.Fatal(err)
}
if recordAfter.GetString("title") == "dry_test" {
t.Fatalf("Expected record.title to be %v, got %v", recordBefore.GetString("title"), "dry_test")
}
return nil
})
if txErr != nil {
t.Fatalf("Nested transactions failure: %v", txErr)
}
}
func TestRecordUpsertSubmitFailure(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -366,10 +429,10 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
form.LoadRequest(req, "")
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next()
return next(r)
}
}
@@ -405,7 +468,7 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
if v := recordAfter.Get("email"); v == "invalid" {
t.Fatalf("Expected record.email not to change, got %v", v)
}
if v := recordAfter.GetStringSlice("file_many"); len(v) != 3 {
if v := recordAfter.GetStringSlice("file_many"); len(v) != 5 {
t.Fatalf("Expected record.file_many not to change, got %v", v)
}
@@ -443,10 +506,10 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
form.LoadRequest(req, "")
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next()
return next(r)
}
}
@@ -477,8 +540,8 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
}
fileMany := (recordAfter.GetStringSlice("file_many"))
if len(fileMany) != 4 { // 1 replace + 1 new
t.Fatalf("Expected 4 record.file_many, got %d (%v)", len(fileMany), fileMany)
if len(fileMany) != 6 { // 1 replace + 1 new
t.Fatalf("Expected 6 record.file_many, got %d (%v)", len(fileMany), fileMany)
}
for _, f := range fileMany {
if !hasRecordFile(app, recordAfter, f) {
@@ -498,22 +561,22 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
}
form := forms.NewRecordUpsert(app, record)
form.Data["title"] = "test_new"
form.Data()["title"] = "test_new"
testErr := errors.New("test_error")
interceptorRecordTitle := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptor1Called = true
return next()
return next(r)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorRecordTitle = record.GetString("title") // to check if the record was filled
interceptor2Called = true
return testErr
@@ -533,7 +596,7 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorRecordTitle != form.Data["title"].(string) {
if interceptorRecordTitle != form.Data()["title"].(string) {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
@@ -718,10 +781,10 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
true,
},
{
"invalid username length (more than 100)",
"invalid username length (more than 150)",
"",
map[string]any{
"username": strings.Repeat("a", 101),
"username": strings.Repeat("a", 151),
"password": "12345678",
"passwordConfirm": "12345678",
},
@@ -792,6 +855,24 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
},
// password
{
"trigger the password validations if only oldPassword is set",
"4q1xlclmfloku33",
map[string]any{
"oldPassword": "1234567890",
},
false,
true,
},
{
"trigger the password validations if only passwordConfirm is set",
"4q1xlclmfloku33",
map[string]any{
"passwordConfirm": "1234567890",
},
false,
true,
},
{
"try to update password without managed access",
"4q1xlclmfloku33",
@@ -863,3 +944,158 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
}
}
}
func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
recordBefore, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
// create test temp files
tempDir := filepath.Join(app.DataDir(), "temp")
if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
tmpFile, _ := os.CreateTemp(os.TempDir(), "tmpfile1-*.txt")
tmpFile.Close()
form := forms.NewRecordUpsert(app, recordBefore)
f1, err := filesystem.NewFileFromPath(tmpFile.Name())
if err != nil {
t.Fatal(err)
}
f2, err := filesystem.NewFileFromPath(tmpFile.Name())
if err != nil {
t.Fatal(err)
}
f3, err := filesystem.NewFileFromPath(tmpFile.Name())
if err != nil {
t.Fatal(err)
}
removed0 := "test_d61b33QdDU.txt" // replaced
removed1 := "300_WlbFWSGmW9.png"
removed2 := "logo_vcfJJG5TAh.svg"
form.AddFiles("file_one", f1) // should replace the existin file
form.AddFiles("file_many", f2, f3) // should append
form.RemoveFiles("file_many", removed1, removed2) // should remove
filesToUpload := form.FilesToUpload()
if v, ok := filesToUpload["file_one"]; !ok || len(v) != 1 {
t.Fatalf("Expected filesToUpload[file_one] to have exactly 1 file, got %v", v)
}
if v, ok := filesToUpload["file_many"]; !ok || len(v) != 2 {
t.Fatalf("Expected filesToUpload[file_many] to have exactly 2 file, got %v", v)
}
filesToDelete := form.FilesToDelete()
if len(filesToDelete) != 3 {
t.Fatalf("Expected exactly 2 file to delete, got %v", filesToDelete)
}
for _, f := range []string{removed0, removed1, removed2} {
if !list.ExistInSlice(f, filesToDelete) {
t.Fatalf("Missing file %q from filesToDelete %v", f, filesToDelete)
}
}
if err := form.Submit(); err != nil {
t.Fatalf("Failed to submit the RecordUpsert form, got %v", err)
}
recordAfter, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
// ensure files deletion
if hasRecordFile(app, recordAfter, removed0) {
t.Fatalf("Expected the old file_one file to be deleted")
}
if hasRecordFile(app, recordAfter, removed1) {
t.Fatalf("Expected %s to be deleted", removed1)
}
if hasRecordFile(app, recordAfter, removed2) {
t.Fatalf("Expected %s to be deleted", removed2)
}
fileOne := recordAfter.GetStringSlice("file_one")
if len(fileOne) == 0 {
t.Fatalf("Expected new file_one file to be uploaded")
}
fileMany := recordAfter.GetStringSlice("file_many")
if len(fileMany) != 5 {
t.Fatalf("Expected file_many to be 5, got %v", fileMany)
}
}
func TestRecordUpsertUploadFailure(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
testDaos := []*daos.Dao{
app.Dao(), // with hooks
daos.New(app.Dao().DB()), // without hooks
}
for i, dao := range testDaos {
// create with invalid file
{
prefix := fmt.Sprintf("%d-create", i)
new := models.NewRecord(collection)
new.Id = "123456789012341"
form := forms.NewRecordUpsert(app, new)
form.SetDao(dao)
form.LoadData(map[string]any{"title": "new_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatalf("[%s] Expected error, got nil", prefix)
}
if r, err := app.Dao().FindRecordById(collection.Id, new.Id); err == nil {
t.Fatalf("[%s] Expected the inserted record to be deleted, found \n%v", prefix, r.PublicExport())
}
}
// update with invalid file
{
prefix := fmt.Sprintf("%d-update", i)
record, err := app.Dao().FindRecordById(collection.Id, "1tmknxy2868d869")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordUpsert(app, record)
form.SetDao(dao)
form.LoadData(map[string]any{"title": "update_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatalf("[%s] Expected error, got nil", prefix)
}
if r, _ := app.Dao().FindRecordById(collection.Id, record.Id); r == nil || r.GetString("title") == "update_test" {
t.Fatalf("[%s] Expected the record changes to be reverted, got \n%v", prefix, r.PublicExport())
}
}
}
}
+6 -4
View File
@@ -77,9 +77,9 @@ func (form *RecordVerificationConfirm) checkToken(value any) error {
// Submit validates and submits the form.
// On success returns the verified auth record associated to `form.Token`.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -98,7 +98,9 @@ func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRec
record.SetVerified(true)
}
interceptorsErr := runInterceptorsWithRecord(record, func(m *models.Record) error {
interceptorsErr := runInterceptors(record, func(m *models.Record) error {
record = m
if wasVerified {
return nil // already verified
}
+3 -3
View File
@@ -57,7 +57,7 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -117,7 +117,7 @@ func TestRecordVerificationConfirmInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -125,7 +125,7 @@ func TestRecordVerificationConfirmInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorVerified = record.Verified()
interceptor2Called = true
+4 -4
View File
@@ -60,9 +60,9 @@ func (form *RecordVerificationRequest) Validate() error {
// Submit validates and sends a verification request email
// to the `form.Email` auth record.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.Validate(); err != nil {
return err
}
@@ -87,7 +87,7 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRec
record.SetLastVerificationSentAt(types.NowDateTime())
}
return runInterceptorsWithRecord(record, func(m *models.Record) error {
return runInterceptors(record, func(m *models.Record) error {
if m.Verified() {
return nil // already verified
}
+3 -3
View File
@@ -85,7 +85,7 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -153,7 +153,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -161,7 +161,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorLastVerificationSentAt = record.LastVerificationSentAt()
interceptor2Called = true
+4 -2
View File
@@ -50,12 +50,14 @@ func (form *SettingsUpsert) Validate() error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Settings]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(func() error {
return runInterceptors(form.Settings, func(s *settings.Settings) error {
form.Settings = s
encryptionKey := os.Getenv(form.app.EncryptionEnv())
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
return err
+9 -8
View File
@@ -8,6 +8,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -78,10 +79,10 @@ func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
interceptorCalls++
return next()
return next(s)
}
}
@@ -135,16 +136,16 @@ func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
interceptor1Called = true
return next()
return next(s)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
interceptor2Called = true
return testErr
}
+28 -14
View File
@@ -6,23 +6,27 @@ import (
"github.com/gabriel-vasile/mimetype"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
// UploadedFileSize checks whether the validated `rest.UploadedFile`
// size is no more than the provided maxBytes.
//
// Example:
//
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
func UploadedFileSize(maxBytes int) validation.RuleFunc {
return func(value any) error {
v, _ := value.(*rest.UploadedFile)
v, _ := value.(*filesystem.File)
if v == nil {
return nil // nothing to validate
}
if int(v.Header().Size) > maxBytes {
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
if int(v.Size) > maxBytes {
return validation.NewError(
"validation_file_size_limit",
fmt.Sprintf("Failed to upload %q - the maximum allowed file size is %v bytes.", v.OriginalName, maxBytes),
)
}
return nil
@@ -33,28 +37,34 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
// mimetype is within the provided allowed mime types.
//
// Example:
// validMimeTypes := []string{"test/plain","image/jpeg"}
//
// validMimeTypes := []string{"test/plain","image/jpeg"}
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
return func(value any) error {
v, _ := value.(*rest.UploadedFile)
v, _ := value.(*filesystem.File)
if v == nil {
return nil // nothing to validate
}
baseErr := validation.NewError(
"validation_invalid_mime_type",
fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
)
if len(validTypes) == 0 {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
return baseErr
}
f, err := v.Header().Open()
f, err := v.Reader.Open()
if err != nil {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
return baseErr
}
defer f.Close()
filetype, err := mimetype.DetectReader(f)
if err != nil {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
return baseErr
}
for _, t := range validTypes {
@@ -63,9 +73,13 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
}
}
return validation.NewError("validation_invalid_mime_type", fmt.Sprintf(
"The following mime types are only allowed: %s.",
strings.Join(validTypes, ","),
))
return validation.NewError(
"validation_invalid_mime_type",
fmt.Sprintf(
"%q mime type must be one of: %s.",
v.Name,
strings.Join(validTypes, ", "),
),
)
}
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/rest"
)
@@ -30,7 +31,7 @@ func TestUploadedFileSize(t *testing.T) {
scenarios := []struct {
maxBytes int
file *rest.UploadedFile
file *filesystem.File
expectError bool
}{
{0, nil, false},
@@ -70,7 +71,7 @@ func TestUploadedFileMimeType(t *testing.T) {
scenarios := []struct {
types []string
file *rest.UploadedFile
file *filesystem.File
expectError bool
}{
{nil, nil, false},
+39
View File
@@ -0,0 +1,39 @@
package validators
import (
"database/sql"
"errors"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
)
// Compare checks whether the provided model id exists.
//
// Example:
//
// validation.Field(&form.Id, validation.By(validators.UniqueId(form.dao, tableName)))
func UniqueId(dao *daos.Dao, tableName string) validation.RuleFunc {
return func(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
var foundId string
err := dao.DB().
Select("id").
From(tableName).
Where(dbx.HashExp{"id": v}).
Limit(1).
Row(&foundId)
if (err != nil && !errors.Is(err, sql.ErrNoRows)) || foundId != "" {
return validation.NewError("validation_invalid_id", "The model id is invalid or already exists.")
}
return nil
}
}
+34
View File
@@ -0,0 +1,34 @@
package validators_test
import (
"testing"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/tests"
)
func TestUniqueId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
id string
tableName string
expectError bool
}{
{"", "", false},
{"test", "", true},
{"wsmn24bux7wo113", "_collections", true},
{"test_unique_id", "unknown_table", true},
{"test_unique_id", "_collections", false},
}
for i, s := range scenarios {
err := validators.UniqueId(app.Dao(), s.tableName)(s.id)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
}
}
+27 -21
View File
@@ -12,8 +12,8 @@ import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -23,12 +23,13 @@ var requiredErr = validation.NewError("validation_required", "Missing required v
// using the provided record constraints and schema.
//
// Example:
//
// validator := NewRecordDataValidator(app.Dao(), record, nil)
// err := validator.Validate(map[string]any{"test":123})
func NewRecordDataValidator(
dao *daos.Dao,
record *models.Record,
uploadedFiles map[string][]*rest.UploadedFile,
uploadedFiles map[string][]*filesystem.File,
) *RecordDataValidator {
return &RecordDataValidator{
dao: dao,
@@ -42,7 +43,7 @@ func NewRecordDataValidator(
type RecordDataValidator struct {
dao *daos.Dao
record *models.Record
uploadedFiles map[string][]*rest.UploadedFile
uploadedFiles map[string][]*filesystem.File
}
// Validate validates the provided `data` by checking it against
@@ -85,17 +86,6 @@ func (validator *RecordDataValidator) Validate(data map[string]any) error {
errs[key] = err
continue
}
// check unique constraint
if field.Unique && !validator.dao.IsRecordValueUnique(
validator.record.Collection().Id,
key,
value,
validator.record.GetId(),
) {
errs[key] = validation.NewError("validation_not_unique", "Value must be unique")
continue
}
}
if len(errs) == 0 {
@@ -117,6 +107,8 @@ func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField,
return validator.checkEmailValue(field, value)
case schema.FieldTypeUrl:
return validator.checkUrlValue(field, value)
case schema.FieldTypeEditor:
return validator.checkEditorValue(field, value)
case schema.FieldTypeDate:
return validator.checkDateValue(field, value)
case schema.FieldTypeSelect:
@@ -236,6 +228,10 @@ func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, v
return nil
}
func (validator *RecordDataValidator) checkEditorValue(field *schema.SchemaField, value any) error {
return nil
}
func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error {
val, _ := value.(types.DateTime)
if val.IsZero() {
@@ -288,16 +284,22 @@ func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField
return nil
}
func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error {
raw, _ := types.ParseJsonRaw(value)
if len(raw) == 0 {
return nil // nothing to check
}
var emptyJsonValues = []string{
"null", `""`, "[]", "{}",
}
func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error {
if is.JSON.Validate(value) != nil {
return validation.NewError("validation_invalid_json", "Must be a valid json value")
}
raw, _ := types.ParseJsonRaw(value)
rawStr := strings.TrimSpace(raw.String())
if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) {
return requiredErr
}
return nil
}
@@ -314,9 +316,9 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
}
// extract the uploaded files
files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles[field.Name]))
files := make([]*filesystem.File, 0, len(validator.uploadedFiles[field.Name]))
for _, file := range validator.uploadedFiles[field.Name] {
if list.ExistInSlice(file.Name(), names) {
if list.ExistInSlice(file.Name, names) {
files = append(files, file)
}
}
@@ -349,6 +351,10 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
options, _ := field.Options.(*schema.RelationOptions)
if options.MinSelect != nil && len(ids) < *options.MinSelect {
return validation.NewError("validation_not_enough_values", fmt.Sprintf("Select at least %d", *options.MinSelect))
}
if options.MaxSelect != nil && len(ids) > *options.MaxSelect {
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", *options.MaxSelect))
}
+33 -112
View File
@@ -13,6 +13,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -20,7 +21,7 @@ import (
type testDataFieldScenario struct {
name string
data map[string]any
files map[string][]*rest.UploadedFile
files map[string][]*filesystem.File
expectedErrors []string
}
@@ -98,16 +99,6 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(text) check unique constraint",
map[string]any{
"field1": "test",
"field2": "test",
"field3": "test",
},
nil,
[]string{"field3"},
},
{
"(text) check min constraint",
map[string]any{
@@ -224,16 +215,6 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(number) check unique constraint",
map[string]any{
"field1": 123,
"field2": 123,
"field3": 123,
},
nil,
[]string{"field3"},
},
{
"(number) check min constraint",
map[string]any{
@@ -344,16 +325,6 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(bool) check unique constraint",
map[string]any{
"field1": true,
"field2": true,
"field3": true,
},
nil,
[]string{"field3"},
},
{
"(bool) valid data (only required)",
map[string]any{
@@ -440,16 +411,6 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
nil,
[]string{"field1", "field2", "field3"},
},
{
"(email) check unique constraint",
map[string]any{
"field1": "test@example.com",
"field2": "test@test.com",
"field3": "test@example.com",
},
nil,
[]string{"field3"},
},
{
"(email) check ExceptDomains constraint",
map[string]any{
@@ -556,16 +517,6 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
nil,
[]string{"field1", "field3"},
},
{
"(url) check unique constraint",
map[string]any{
"field1": "http://example.com",
"field2": "http://test.com",
"field3": "http://example.com",
},
nil,
[]string{"field3"},
},
{
"(url) check ExceptDomains constraint",
map[string]any{
@@ -694,16 +645,6 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(date) check unique constraint",
map[string]any{
"field1": "2029-01-01 01:01:01.123",
"field2": "2029-01-01 01:01:01.123",
"field3": "2029-01-01 01:01:01.123",
},
nil,
[]string{"field3"},
},
{
"(date) check min date constraint",
map[string]any{
@@ -826,26 +767,6 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
nil,
[]string{},
},
{
"(select) check unique constraint",
map[string]any{
"field1": "a",
"field2": "b",
"field3": []string{"a", "b", "c"},
},
nil,
[]string{"field3"},
},
{
"(select) check unique constraint - same elements but different order",
map[string]any{
"field1": "a",
"field2": "b",
"field3": []string{"a", "c", "b"},
},
nil,
[]string{},
},
{
"(select) check Values constraint",
map[string]any{
@@ -964,7 +885,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
"field3": []string{},
},
nil,
[]string{},
[]string{"field2"},
},
{
"(json) check required constraint - zero map",
@@ -974,27 +895,27 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
"field3": map[string]string{},
},
nil,
[]string{"field2"},
},
{
"(json) check json text invalid obj, array and number normalizations",
map[string]any{
"field1": `[1 2 3]`,
"field2": `{a: 123}`,
"field3": `123.456 abc`,
},
nil,
[]string{},
},
{
"(json) check unique constraint",
"(json) check json text reserved literals normalizations",
map[string]any{
"field1": `{"test":123}`,
"field2": `{"test":123}`,
"field3": map[string]any{"test": 123},
"field1": `true`,
"field2": `false`,
"field3": `null`,
},
nil,
[]string{"field3"},
},
{
"(json) check json text validator",
map[string]any{
"field1": `[1, 2, 3`,
"field2": `invalid`,
"field3": `null`, // valid
},
nil,
[]string{"field1", "field2"},
[]string{},
},
{
"(json) valid data - only required fields",
@@ -1086,10 +1007,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"check MaxSelect constraint",
map[string]any{
"field1": "test1",
"field2": []string{"test1", testFiles[0].Name(), testFiles[3].Name()},
"field2": []string{"test1", testFiles[0].Name, testFiles[3].Name},
"field3": []string{"test1", "test2", "test3", "test4"},
},
map[string][]*rest.UploadedFile{
map[string][]*filesystem.File{
"field2": {testFiles[0], testFiles[3]},
},
[]string{"field2", "field3"},
@@ -1097,11 +1018,11 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
{
"check MaxSize constraint",
map[string]any{
"field1": testFiles[0].Name(),
"field2": []string{"test1", testFiles[0].Name()},
"field1": testFiles[0].Name,
"field2": []string{"test1", testFiles[0].Name},
"field3": []string{"test1", "test2", "test3"},
},
map[string][]*rest.UploadedFile{
map[string][]*filesystem.File{
"field1": {testFiles[0]},
"field2": {testFiles[0]},
},
@@ -1111,10 +1032,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"check MimeTypes constraint",
map[string]any{
"field1": "test1",
"field2": []string{"test1", testFiles[0].Name()},
"field3": []string{testFiles[1].Name(), testFiles[2].Name()},
"field2": []string{"test1", testFiles[0].Name},
"field3": []string{testFiles[1].Name, testFiles[2].Name},
},
map[string][]*rest.UploadedFile{
map[string][]*filesystem.File{
"field2": {testFiles[0], testFiles[1], testFiles[2]},
"field3": {testFiles[1], testFiles[2]},
},
@@ -1134,10 +1055,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"valid data - just new files",
map[string]any{
"field1": nil,
"field2": []string{testFiles[0].Name(), testFiles[1].Name()},
"field2": []string{testFiles[0].Name, testFiles[1].Name},
"field3": nil,
},
map[string][]*rest.UploadedFile{
map[string][]*filesystem.File{
"field2": {testFiles[0], testFiles[1]},
},
[]string{},
@@ -1146,10 +1067,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"valid data - mixed existing and new files",
map[string]any{
"field1": "test1",
"field2": []string{"test1", testFiles[0].Name()},
"field2": []string{"test1", testFiles[0].Name},
"field3": "test1", // will be casted
},
map[string][]*rest.UploadedFile{
map[string][]*filesystem.File{
"field2": {testFiles[0], testFiles[1], testFiles[2]},
},
[]string{},
@@ -1201,6 +1122,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Unique: true,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MinSelect: types.Pointer(2),
CollectionId: demo.Id,
},
},
@@ -1248,11 +1170,10 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
[]string{"field2"},
},
{
"check unique constraint",
"check min constraint",
map[string]any{
"field1": relId1,
"field2": relId2,
"field3": []string{relId1, relId2, relId3, relId3}, // repeating values are collapsed
"field3": []string{relId1},
},
nil,
[]string{"field3"},
+1
View File
@@ -7,6 +7,7 @@ import (
// Compare checks whether the validated value matches another string.
//
// Example:
//
// validation.Field(&form.PasswordConfirm, validation.By(validators.Compare(form.Password)))
func Compare(valueToCompare string) validation.RuleFunc {
return func(value any) error {
+47 -47
View File
@@ -4,85 +4,85 @@ go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/aws/aws-sdk-go v1.44.156
github.com/aws/aws-sdk-go v1.44.249
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.3.4
github.com/domodwyer/mailyak/v3 v3.6.0
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
github.com/fatih/color v1.13.0
github.com/gabriel-vasile/mimetype v1.4.1
github.com/ganigeorgiev/fexpr v0.1.1
github.com/fatih/color v1.15.0
github.com/gabriel-vasile/mimetype v1.4.2
github.com/ganigeorgiev/fexpr v0.3.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v4 v4.4.3
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
github.com/mattn/go-sqlite3 v1.14.16
github.com/pocketbase/dbx v1.8.0
github.com/pocketbase/dbx v1.10.0
github.com/spf13/cast v1.5.0
github.com/spf13/cobra v1.6.1
gocloud.dev v0.27.0
golang.org/x/crypto v0.4.0
golang.org/x/net v0.4.0
golang.org/x/oauth2 v0.3.0
golang.org/x/sync v0.1.0
modernc.org/sqlite v1.20.0
github.com/spf13/cobra v1.7.0
gocloud.dev v0.29.0
golang.org/x/crypto v0.8.0
golang.org/x/net v0.9.0
golang.org/x/oauth2 v0.7.0
modernc.org/sqlite v1.22.0
)
require (
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/aws/aws-sdk-go-v2 v1.17.2 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.18.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.4 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.43 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.17 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.11.26 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.17.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.2 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.33.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.10 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.2.0 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/term v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/image v0.7.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/term v0.7.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.4.0 // indirect
golang.org/x/tools v0.8.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.104.0 // indirect
google.golang.org/api v0.119.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37 // indirect
google.golang.org/grpc v1.51.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.54.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.21.5 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
+822 -264
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -7,10 +7,8 @@ linters:
disable-all: true
enable:
- asciicheck
- deadcode
- depguard
- exportloopref
- gocritic
- gofmt
- goimports
- gomodguard
@@ -26,5 +24,4 @@ linters:
- typecheck
- unconvert
- unused
- varcheck
- whitespace

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