From 90dba45d7ca53928dfd0a8c99c6aabee5239a58e Mon Sep 17 00:00:00 2001
From: Gani Georgiev
Date: Sun, 30 Oct 2022 10:28:14 +0200
Subject: [PATCH] initial v0.8 pre-release
---
Makefile | 4 +-
README.md | 2 +-
apis/admin.go | 53 +-
apis/admin_test.go | 304 +--
{tools/rest => apis}/api_error.go | 5 +-
{tools/rest => apis}/api_error_test.go | 16 +-
apis/base.go | 51 +-
apis/base_test.go | 4 +-
apis/collection.go | 29 +-
apis/collection_test.go | 585 ++++--
apis/file.go | 23 +-
apis/file_test.go | 41 +-
apis/logs.go | 15 +-
apis/logs_test.go | 28 +-
apis/middlewares.go | 195 +-
apis/middlewares_test.go | 460 ++++-
apis/realtime.go | 126 +-
apis/realtime_test.go | 58 +-
apis/record_auth.go | 477 +++++
apis/record_auth_test.go | 1115 +++++++++++
apis/{record.go => record_crud.go} | 226 +--
apis/record_crud_test.go | 1725 +++++++++++++++++
apis/record_helpers.go | 186 ++
apis/record_test.go | 1052 ----------
apis/settings.go | 27 +-
apis/settings_test.go | 84 +-
apis/user.go | 519 -----
apis/user_test.go | 1113 -----------
cmd/temp_upgrade.go | 444 +++++
core/app.go | 109 +-
core/base.go | 140 +-
core/base_test.go | 72 +-
core/events.go | 44 +-
core/settings.go | 123 +-
core/settings_templates.go | 6 +-
core/settings_test.go | 143 +-
daos/admin.go | 20 +-
daos/admin_test.go | 44 +-
daos/base_test.go | 14 +-
daos/collection.go | 71 +-
daos/collection_test.go | 221 ++-
daos/external_auth.go | 45 +-
daos/external_auth_test.go | 82 +-
daos/record.go | 376 +++-
daos/record_expand.go | 125 +-
daos/record_expand_test.go | 272 ++-
daos/record_test.go | 534 +++--
daos/request_test.go | 10 +-
daos/user.go | 282 ---
daos/user_test.go | 275 ---
examples/base/main.go | 2 +-
forms/admin_login.go | 51 +-
forms/admin_login_test.go | 47 +-
forms/admin_password_reset_confirm.go | 59 +-
forms/admin_password_reset_confirm_test.go | 70 +-
forms/admin_password_reset_request.go | 55 +-
forms/admin_password_reset_request_test.go | 41 +-
forms/admin_upsert.go | 52 +-
forms/admin_upsert_test.go | 154 +-
forms/base.go | 4 +-
forms/collection_upsert.go | 170 +-
forms/collection_upsert_test.go | 266 +--
forms/collections_import.go | 55 +-
forms/collections_import_test.go | 106 +-
forms/record_email_change_confirm.go | 135 ++
forms/record_email_change_confirm_test.go | 126 ++
forms/record_email_change_request.go | 70 +
...go => record_email_change_request_test.go} | 31 +-
forms/record_oauth2_login.go | 234 +++
...in_test.go => record_oauth2_login_test.go} | 49 +-
forms/record_password_login.go | 77 +
forms/record_password_login_test.go | 130 ++
forms/record_password_reset_confirm.go | 96 +
forms/record_password_reset_confirm_test.go | 117 ++
forms/record_password_reset_request.go | 86 +
... => record_password_reset_request_test.go} | 82 +-
forms/record_upsert.go | 519 +++--
forms/record_upsert_test.go | 582 +++---
forms/record_verification_confirm.go | 103 +
forms/record_verification_confirm_test.go | 79 +
forms/record_verification_request.go | 94 +
...go => record_verification_request_test.go} | 82 +-
forms/settings_upsert.go | 56 +-
forms/settings_upsert_test.go | 42 +-
forms/test_email_send.go | 26 +-
forms/test_email_send_test.go | 54 +-
forms/user_email_change_confirm.go | 143 --
forms/user_email_change_confirm_test.go | 131 --
forms/user_email_change_request.go | 88 -
forms/user_email_login.go | 80 -
forms/user_email_login_test.go | 116 --
forms/user_oauth2_login.go | 195 --
forms/user_password_reset_confirm.go | 108 --
forms/user_password_reset_confirm_test.go | 175 --
forms/user_password_reset_request.go | 100 -
forms/user_upsert.go | 165 --
forms/user_upsert_test.go | 432 -----
forms/user_verification_confirm.go | 103 -
forms/user_verification_confirm_test.go | 150 --
forms/user_verification_request.go | 104 -
forms/validators/file.go | 14 +-
forms/validators/record_data.go | 44 +-
forms/validators/record_data_test.go | 229 +--
go.mod | 88 +-
go.sum | 1657 +++++++++++++---
mails/{user.go => record.go} | 45 +-
mails/{user_test.go => record_test.go} | 24 +-
migrations/1640988000_init.go | 130 +-
.../1661586591_add_externalAuths_table.go | 76 -
models/admin.go | 60 +-
models/admin_test.go | 90 +
models/base.go | 60 +-
models/base_test.go | 98 +-
models/collection.go | 180 +-
models/collection_test.go | 371 ++++
models/external_auth.go | 7 +-
models/record.go | 558 ++++--
models/record_test.go | 1388 ++++++++++---
models/request.go | 6 +-
models/schema/schema_field.go | 102 +-
models/schema/schema_field_test.go | 248 ++-
models/user.go | 47 -
models/user_test.go | 43 -
pocketbase.go | 1 +
resolvers/record_field_resolver.go | 168 +-
resolvers/record_field_resolver_test.go | 192 +-
tests/app.go | 80 +-
tests/data/data.db | Bin 172032 -> 237568 bytes
tests/data/logs.db | Bin 45056 -> 1028096 bytes
.../01562272-e67e-4925-9f37-02b5f899853c.txt | 1 -
.../4881bdef-06b4-4dea-8d97-6125ad242677.png | Bin 1169 -> 0 bytes
...0_4881bdef-06b4-4dea-8d97-6125ad242677.png | Bin 884 -> 0 bytes
...0_4881bdef-06b4-4dea-8d97-6125ad242677.png | Bin 2170 -> 0 bytes
...0_4881bdef-06b4-4dea-8d97-6125ad242677.png | Bin 1309 -> 0 bytes
...0_4881bdef-06b4-4dea-8d97-6125ad242677.png | Bin 1293 -> 0 bytes
...bdef-06b4-4dea-8d97-6125ad242677.png.attrs | 1 -
...b_4881bdef-06b4-4dea-8d97-6125ad242677.png | Bin 1270 -> 0 bytes
...bdef-06b4-4dea-8d97-6125ad242677.png.attrs | 1 -
...f_4881bdef-06b4-4dea-8d97-6125ad242677.png | Bin 884 -> 0 bytes
...bdef-06b4-4dea-8d97-6125ad242677.png.attrs | 1 -
...t_4881bdef-06b4-4dea-8d97-6125ad242677.png | Bin 1269 -> 0 bytes
...bdef-06b4-4dea-8d97-6125ad242677.png.attrs | 1 -
.../8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt | 1 -
...1d65-6a2e-4f11-87b3-d8a3170bfd4f.txt.attrs | 1 -
.../4q1xlclmfloku33/300_1SEi6Q6U72.png | Bin 0 -> 1132 bytes
.../4q1xlclmfloku33/300_1SEi6Q6U72.png.attrs} | 2 +-
.../0x50_300_1SEi6Q6U72.png | Bin 0 -> 599 bytes
.../0x50_300_1SEi6Q6U72.png.attrs} | 2 +-
.../100x100_300_1SEi6Q6U72.png | Bin 0 -> 1289 bytes
.../100x100_300_1SEi6Q6U72.png.attrs} | 2 +-
.../70x0_300_1SEi6Q6U72.png | Bin 0 -> 890 bytes
.../70x0_300_1SEi6Q6U72.png.attrs} | 2 +-
.../70x50_300_1SEi6Q6U72.png | Bin 0 -> 616 bytes
.../70x50_300_1SEi6Q6U72.png.attrs | 1 +
.../70x50b_300_1SEi6Q6U72.png | Bin 0 -> 868 bytes
.../70x50b_300_1SEi6Q6U72.png.attrs | 1 +
.../70x50f_300_1SEi6Q6U72.png | Bin 0 -> 599 bytes
.../70x50f_300_1SEi6Q6U72.png.attrs | 1 +
.../70x50t_300_1SEi6Q6U72.png | Bin 0 -> 621 bytes
.../70x50t_300_1SEi6Q6U72.png.attrs | 1 +
.../oap640cot4yru2s/test_kfd2wYLxkz.txt | 1 +
.../test_kfd2wYLxkz.txt.attrs} | 2 +-
.../315d3131-c1f7-453a-8a91-f12c06207edc.png | Bin 1169 -> 0 bytes
...3131-c1f7-453a-8a91-f12c06207edc.png.attrs | 1 -
.../55aa6938-e53c-4b58-b446-146f3d80b2c4.txt | 1 -
...6938-e53c-4b58-b446-146f3d80b2c4.txt.attrs | 1 -
.../b635c395-6837-49e5-8535-b0a6ebfbdbf3.png | Bin 1169 -> 0 bytes
...c395-6837-49e5-8535-b0a6ebfbdbf3.png.attrs | 1 -
.../c2c58441-27f5-4574-96f8-6f79dae9ff4d.png | Bin 1169 -> 0 bytes
...8441-27f5-4574-96f8-6f79dae9ff4d.png.attrs | 1 -
.../e526d938-c5ab-41cb-a334-85b9c3e37f72.png | Bin 1169 -> 0 bytes
...d938-c5ab-41cb-a334-85b9c3e37f72.png.attrs | 1 -
...0_315d3131-c1f7-453a-8a91-f12c06207edc.png | Bin 2170 -> 0 bytes
...3131-c1f7-453a-8a91-f12c06207edc.png.attrs | 1 -
...0_b635c395-6837-49e5-8535-b0a6ebfbdbf3.png | Bin 2170 -> 0 bytes
...c395-6837-49e5-8535-b0a6ebfbdbf3.png.attrs | 1 -
...0_c2c58441-27f5-4574-96f8-6f79dae9ff4d.png | Bin 2170 -> 0 bytes
...8441-27f5-4574-96f8-6f79dae9ff4d.png.attrs | 1 -
...0_e526d938-c5ab-41cb-a334-85b9c3e37f72.png | Bin 2170 -> 0 bytes
...d938-c5ab-41cb-a334-85b9c3e37f72.png.attrs | 1 -
.../5e74d0b5-c183-4419-9d7c-a6a3d4a7faca.txt | 1 -
...d0b5-c183-4419-9d7c-a6a3d4a7faca.txt.attrs | 1 -
.../f80296fb-9fa5-4372-80f2-be196b973c7b.txt | 1 -
...96fb-9fa5-4372-80f2-be196b973c7b.txt.attrs | 1 -
.../ff3b7633-6440-43d8-a957-1bfb3d3421ec.txt | 1 -
...7633-6440-43d8-a957-1bfb3d3421ec.txt.attrs | 1 -
.../935a3325-f511-4d11-87f4-51034234a8d9.png | Bin 1169 -> 0 bytes
...3325-f511-4d11-87f4-51034234a8d9.png.attrs | 1 -
...0_935a3325-f511-4d11-87f4-51034234a8d9.png | Bin 2170 -> 0 bytes
...3325-f511-4d11-87f4-51034234a8d9.png.attrs | 1 -
.../84nmscqy84lsi1t/300_WlbFWSGmW9.png | Bin 0 -> 1132 bytes
.../84nmscqy84lsi1t/300_WlbFWSGmW9.png.attrs | 1 +
.../84nmscqy84lsi1t/logo_vcfJJG5TAh.svg | 9 +
.../84nmscqy84lsi1t/logo_vcfJJG5TAh.svg.attrs | 1 +
.../84nmscqy84lsi1t/test_QZFjKjXchk.txt | 1 +
.../84nmscqy84lsi1t/test_QZFjKjXchk.txt.attrs | 1 +
.../84nmscqy84lsi1t/test_d61b33QdDU.txt | 1 +
.../84nmscqy84lsi1t/test_d61b33QdDU.txt.attrs | 1 +
.../100x100_300_WlbFWSGmW9.png | Bin 0 -> 1289 bytes
.../100x100_300_WlbFWSGmW9.png.attrs | 1 +
.../al1h9ijdeojtsjy/300_Jsjq7RdBgA.png | Bin 0 -> 1132 bytes
.../al1h9ijdeojtsjy/300_Jsjq7RdBgA.png.attrs | 1 +
.../100x100_300_Jsjq7RdBgA.png | Bin 0 -> 1289 bytes
.../100x100_300_Jsjq7RdBgA.png.attrs | 1 +
.../7nwo8tuiatetxdm/test_JnXeKEwgwr.txt | 1 +
.../7nwo8tuiatetxdm/test_JnXeKEwgwr.txt.attrs | 1 +
.../lcl9d87w22ml6jy/300_UhLKX91HVb.png | Bin 0 -> 1132 bytes
.../lcl9d87w22ml6jy/300_UhLKX91HVb.png.attrs | 1 +
.../lcl9d87w22ml6jy/test_FLurQTgrY8.txt | 1 +
.../lcl9d87w22ml6jy/test_FLurQTgrY8.txt.attrs | 1 +
.../100x100_300_UhLKX91HVb.png | Bin 0 -> 1289 bytes
.../100x100_300_UhLKX91HVb.png.attrs | 1 +
.../mk5fmymtx4wsprk/300_JdfBOieXAW.png | Bin 0 -> 1132 bytes
.../mk5fmymtx4wsprk/300_JdfBOieXAW.png.attrs | 1 +
.../100x100_300_JdfBOieXAW.png | Bin 0 -> 1289 bytes
.../100x100_300_JdfBOieXAW.png.attrs | 1 +
tests/logs.go | 8 +-
tokens/admin.go | 4 +-
tokens/record.go | 78 +
tokens/record_test.go | 100 +
tokens/tokens.go | 5 +
tokens/user.go | 44 -
tokens/user_test.go | 100 -
tools/filesystem/filesystem.go | 44 +-
tools/filesystem/filesystem_test.go | 51 +
tools/hook/hook.go | 4 +-
tools/mailer/smtp.go | 5 +-
tools/rest/multi_binder.go | 6 +-
tools/rest/multi_binder_test.go | 6 +-
tools/rest/uploaded_file.go | 36 +-
tools/rest/uploaded_file_test.go | 88 +-
tools/search/filter.go | 70 +-
tools/search/filter_test.go | 26 +-
tools/search/provider.go | 30 +-
tools/search/provider_test.go | 56 +-
tools/security/jwt.go | 2 +
tools/security/random_test.go | 2 +-
tools/store/store.go | 8 +-
tools/subscriptions/broker.go | 3 +
tools/subscriptions/client.go | 28 +-
tools/types/datetime.go | 2 +-
tools/types/datetime_test.go | 20 +-
tools/types/json_array.go | 4 -
tools/types/json_array_test.go | 2 +-
tools/types/json_map.go | 4 -
tools/types/json_map_test.go | 2 +-
tools/types/types.go | 8 +
tools/types/types_test.go | 24 +
tools/types/untitled.js.erb | 27 +
ui/.env | 15 +-
ui/dist/assets/AuthMethodsDocs.6da908f0.js | 64 +
ui/dist/assets/AuthRefreshDocs.1d6e4e08.js | 87 +
ui/dist/assets/AuthWithOAuth2Docs.169fa55a.js | 151 ++
.../assets/AuthWithPasswordDocs.f656a4b9.js | 106 +
ui/dist/assets/CodeEditor.0b64cb4e.js | 13 -
ui/dist/assets/CodeEditor.45f24efe.js | 13 +
.../assets/ConfirmEmailChangeDocs.e9bf0cab.js | 94 +
.../ConfirmPasswordResetDocs.f1aa1be6.js | 102 +
.../ConfirmVerificationDocs.e35127c6.js | 78 +
ui/dist/assets/CreateApiDocs.60f76221.js | 102 +
ui/dist/assets/DeleteApiDocs.06551842.js | 58 +
.../FilterAutocompleteInput.37739e76.js | 1 -
.../FilterAutocompleteInput.7da1d2a3.js | 1 +
ui/dist/assets/ListApiDocs.11b26f68.js | 140 ++
ui/dist/assets/ListApiDocs.68f52edd.css | 1 +
.../assets/ListExternalAuthsDocs.960c39a0.js | 93 +
...PageAdminConfirmPasswordReset.19234b2d.js} | 2 +-
...PageAdminRequestPasswordReset.7514fbab.js} | 2 +-
.../PageRecordConfirmEmailChange.75e01a7f.js | 4 +
...PageRecordConfirmPasswordReset.d1097e1b.js | 4 +
.../PageRecordConfirmVerification.cdc4fa83.js | 3 +
.../PageUserConfirmEmailChange.df55986a.js | 4 -
.../PageUserConfirmPasswordReset.b3361442.js | 4 -
.../PageUserConfirmVerification.afbff8ec.js | 3 -
ui/dist/assets/RealtimeApiDocs.e9e53954.js | 103 +
.../assets/RequestEmailChangeDocs.1fe72b2e.js | 70 +
.../RequestPasswordResetDocs.c8de2eb6.js | 50 +
.../RequestVerificationDocs.2b01d123.js | 50 +
ui/dist/assets/SdkTabs.88269ae0.js | 1 +
ui/dist/assets/SdkTabs.9b0b7a06.css | 1 +
.../assets/UnlinkExternalAuthDocs.3445a27c.js | 80 +
ui/dist/assets/UpdateApiDocs.f4df7d8c.js | 118 ++
ui/dist/assets/ViewApiDocs.d6f654f1.js | 66 +
ui/dist/assets/index.0a5eb9c8.css | 1 +
ui/dist/assets/index.26987507.css | 1 -
ui/dist/assets/index.97f016a1.js | 175 ++
ui/dist/assets/index.9c8b95cd.js | 13 +
ui/dist/assets/index.a9121ab1.js | 12 -
ui/dist/assets/index.e13041a6.js | 661 -------
ui/dist/index.html | 4 +-
ui/package-lock.json | 641 +++---
ui/package.json | 4 +-
ui/src/App.svelte | 14 +-
ui/src/actions/tooltip.js | 2 +-
.../components/admins/AdminUpsertPanel.svelte | 26 +-
.../components/admins/PageAdminLogin.svelte | 8 +-
ui/src/components/admins/PageAdmins.svelte | 8 +-
ui/src/components/base/Accordion.svelte | 35 +-
ui/src/components/base/CodeBlock.svelte | 3 +-
ui/src/components/base/Confirmation.svelte | 10 +-
.../base/FilterAutocompleteInput.svelte | 59 +-
ui/src/components/base/FormattedDate.svelte | 23 +-
.../components/base/HorizontalScroller.svelte | 81 +
ui/src/components/base/Installer.svelte | 2 +-
ui/src/components/base/OverlayPanel.svelte | 8 +-
ui/src/components/base/PageWrapper.svelte | 2 +-
ui/src/components/base/PreviewPopup.svelte | 12 +-
ui/src/components/base/RefreshButton.svelte | 4 +-
ui/src/components/base/Searchbar.svelte | 4 +-
ui/src/components/base/Select.svelte | 14 +-
ui/src/components/base/Toasts.svelte | 4 +-
ui/src/components/base/Toggler.svelte | 53 +-
.../CollectionAuthOptionsTab.svelte | 221 +++
.../collections/CollectionDocsPanel.svelte | 156 ++
.../collections/CollectionFieldsTab.svelte | 91 +-
.../collections/CollectionRulesTab.svelte | 145 +-
.../collections/CollectionUpsertPanel.svelte | 98 +-
.../collections/CollectionsSidebar.svelte | 46 +-
.../collections/FieldAccordion.svelte | 27 +-
.../components/collections/RuleField.svelte | 121 ++
.../collections/docs/AuthMethodsDocs.svelte | 110 ++
.../collections/docs/AuthRefreshDocs.svelte | 169 ++
.../docs/AuthWithOAuth2Docs.svelte | 261 +++
.../docs/AuthWithPasswordDocs.svelte | 223 +++
.../docs/CollectionDocsPanel.svelte | 111 --
.../docs/ConfirmEmailChangeDocs.svelte | 183 ++
.../docs/ConfirmPasswordResetDocs.svelte | 197 ++
.../docs/ConfirmVerificationDocs.svelte | 165 ++
.../collections/docs/CreateApiDocs.svelte | 211 +-
.../collections/docs/DeleteApiDocs.svelte | 57 +-
.../collections/docs/FilterSyntax.svelte | 144 +-
.../collections/docs/ListApiDocs.svelte | 118 +-
.../docs/ListExternalAuthsDocs.svelte | 162 ++
.../collections/docs/RealtimeApiDocs.svelte | 163 +-
.../docs/RequestEmailChangeDocs.svelte | 144 ++
.../docs/RequestPasswordResetDocs.svelte | 119 ++
.../docs/RequestVerificationDocs.svelte | 119 ++
.../collections/docs/SdkTabs.svelte | 11 +-
.../docs/UnlinkExternalAuthDocs.svelte | 154 ++
.../collections/docs/UpdateApiDocs.svelte | 220 ++-
.../collections/docs/ViewApiDocs.svelte | 78 +-
.../collections/schema/FieldTypeSelect.svelte | 8 +-
.../collections/schema/RelationOptions.svelte | 61 +-
ui/src/components/logs/LogsChart.svelte | 3 +-
ui/src/components/logs/LogsList.svelte | 51 +-
.../ExternalAuthsList.svelte | 22 +-
.../PageRecordConfirmEmailChange.svelte} | 5 +-
.../PageRecordConfirmPasswordReset.svelte} | 7 +-
.../PageRecordConfirmVerification.svelte} | 5 +-
ui/src/components/records/PageRecords.svelte | 15 +-
.../components/records/RecordFieldCell.svelte | 7 +-
.../records/RecordFilePreview.svelte | 2 +-
ui/src/components/records/RecordSelect.svelte | 75 +-
.../records/RecordSelectOption.svelte | 6 +-
.../records/RecordUpsertPanel.svelte | 253 ++-
ui/src/components/records/RecordsList.svelte | 164 +-
.../records/fields/AuthFields.svelte | 155 ++
.../records/fields/DateField.svelte | 7 +
.../records/fields/FileField.svelte | 4 +-
.../records/fields/RelationField.svelte | 9 +-
.../records/fields/UserField.svelte | 33 -
.../settings/AuthProviderAccordion.svelte | 7 -
.../settings/EmailAuthAccordion.svelte | 123 --
.../settings/EmailTemplateAccordion.svelte | 8 +-
.../settings/PageAuthProviders.svelte | 22 +-
ui/src/components/settings/PageStorage.svelte | 65 +-
.../settings/PageTokenOptions.svelte | 8 +-
ui/src/components/users/PageUsers.svelte | 305 ---
ui/src/components/users/UserSelect.svelte | 123 --
.../components/users/UserSelectOption.svelte | 17 -
.../components/users/UserUpsertPanel.svelte | 308 ---
ui/src/routes.js | 67 +-
ui/src/scss/_accordion.scss | 49 +-
ui/src/scss/_base.scss | 18 +-
ui/src/scss/_docs_panel.scss | 99 +
ui/src/scss/_dropdown.scss | 4 +-
ui/src/scss/_form.scss | 137 +-
ui/src/scss/_layout.scss | 21 +-
ui/src/scss/_overlay_panel.scss | 23 +-
ui/src/scss/_table.scss | 30 +-
ui/src/scss/_tabs.scss | 4 +-
ui/src/scss/_tooltip.scss | 10 +-
ui/src/scss/_vars.scss | 4 +-
ui/src/scss/main.scss | 2 +
ui/src/stores/collections.js | 28 +-
ui/src/utils/ApiClient.js | 1 -
ui/src/utils/CommonHelper.js | 174 +-
ui/vite.config.js | 6 +
388 files changed, 21580 insertions(+), 13603 deletions(-)
rename {tools/rest => apis}/api_error.go (94%)
rename {tools/rest => apis}/api_error_test.go (92%)
create mode 100644 apis/record_auth.go
create mode 100644 apis/record_auth_test.go
rename apis/{record.go => record_crud.go} (63%)
create mode 100644 apis/record_crud_test.go
create mode 100644 apis/record_helpers.go
delete mode 100644 apis/record_test.go
delete mode 100644 apis/user.go
delete mode 100644 apis/user_test.go
create mode 100644 cmd/temp_upgrade.go
delete mode 100644 daos/user.go
delete mode 100644 daos/user_test.go
create mode 100644 forms/record_email_change_confirm.go
create mode 100644 forms/record_email_change_confirm_test.go
create mode 100644 forms/record_email_change_request.go
rename forms/{user_email_change_request_test.go => record_email_change_request_test.go} (71%)
create mode 100644 forms/record_oauth2_login.go
rename forms/{user_oauth2_login_test.go => record_oauth2_login_test.go} (61%)
create mode 100644 forms/record_password_login.go
create mode 100644 forms/record_password_login_test.go
create mode 100644 forms/record_password_reset_confirm.go
create mode 100644 forms/record_password_reset_confirm_test.go
create mode 100644 forms/record_password_reset_request.go
rename forms/{user_password_reset_request_test.go => record_password_reset_request_test.go} (50%)
create mode 100644 forms/record_verification_confirm.go
create mode 100644 forms/record_verification_confirm_test.go
create mode 100644 forms/record_verification_request.go
rename forms/{user_verification_request_test.go => record_verification_request_test.go} (54%)
delete mode 100644 forms/user_email_change_confirm.go
delete mode 100644 forms/user_email_change_confirm_test.go
delete mode 100644 forms/user_email_change_request.go
delete mode 100644 forms/user_email_login.go
delete mode 100644 forms/user_email_login_test.go
delete mode 100644 forms/user_oauth2_login.go
delete mode 100644 forms/user_password_reset_confirm.go
delete mode 100644 forms/user_password_reset_confirm_test.go
delete mode 100644 forms/user_password_reset_request.go
delete mode 100644 forms/user_upsert.go
delete mode 100644 forms/user_upsert_test.go
delete mode 100644 forms/user_verification_confirm.go
delete mode 100644 forms/user_verification_confirm_test.go
delete mode 100644 forms/user_verification_request.go
rename mails/{user.go => record.go} (62%)
rename mails/{user_test.go => record_test.go} (64%)
delete mode 100644 migrations/1661586591_add_externalAuths_table.go
delete mode 100644 models/user.go
delete mode 100644 models/user_test.go
delete mode 100644 tests/data/storage/2c1010aa-b8fe-41d9-a980-99534ca8a167/94568ca2-0bee-49d7-b749-06cb97956fd9/01562272-e67e-4925-9f37-02b5f899853c.txt
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/0x50_4881bdef-06b4-4dea-8d97-6125ad242677.png
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/100x100_4881bdef-06b4-4dea-8d97-6125ad242677.png
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x0_4881bdef-06b4-4dea-8d97-6125ad242677.png
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50_4881bdef-06b4-4dea-8d97-6125ad242677.png
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50_4881bdef-06b4-4dea-8d97-6125ad242677.png.attrs
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50b_4881bdef-06b4-4dea-8d97-6125ad242677.png
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50b_4881bdef-06b4-4dea-8d97-6125ad242677.png.attrs
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50f_4881bdef-06b4-4dea-8d97-6125ad242677.png
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50f_4881bdef-06b4-4dea-8d97-6125ad242677.png.attrs
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50t_4881bdef-06b4-4dea-8d97-6125ad242677.png
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50t_4881bdef-06b4-4dea-8d97-6125ad242677.png.attrs
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt
delete mode 100644 tests/data/storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt.attrs
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png
rename tests/data/storage/{3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x0_4881bdef-06b4-4dea-8d97-6125ad242677.png.attrs => _pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png.attrs} (67%)
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/0x50_300_1SEi6Q6U72.png
rename tests/data/storage/{3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png.attrs => _pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/0x50_300_1SEi6Q6U72.png.attrs} (67%)
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/100x100_300_1SEi6Q6U72.png
rename tests/data/storage/{3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/0x50_4881bdef-06b4-4dea-8d97-6125ad242677.png.attrs => _pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/100x100_300_1SEi6Q6U72.png.attrs} (67%)
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x0_300_1SEi6Q6U72.png
rename tests/data/storage/{3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/100x100_4881bdef-06b4-4dea-8d97-6125ad242677.png.attrs => _pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x0_300_1SEi6Q6U72.png.attrs} (67%)
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png.attrs
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png.attrs
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png.attrs
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png
create mode 100644 tests/data/storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png.attrs
create mode 100644 tests/data/storage/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt
rename tests/data/storage/{2c1010aa-b8fe-41d9-a980-99534ca8a167/94568ca2-0bee-49d7-b749-06cb97956fd9/01562272-e67e-4925-9f37-02b5f899853c.txt.attrs => _pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt.attrs} (60%)
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/315d3131-c1f7-453a-8a91-f12c06207edc.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/315d3131-c1f7-453a-8a91-f12c06207edc.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/55aa6938-e53c-4b58-b446-146f3d80b2c4.txt
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/55aa6938-e53c-4b58-b446-146f3d80b2c4.txt.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/b635c395-6837-49e5-8535-b0a6ebfbdbf3.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/b635c395-6837-49e5-8535-b0a6ebfbdbf3.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/c2c58441-27f5-4574-96f8-6f79dae9ff4d.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/c2c58441-27f5-4574-96f8-6f79dae9ff4d.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/e526d938-c5ab-41cb-a334-85b9c3e37f72.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/e526d938-c5ab-41cb-a334-85b9c3e37f72.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/thumbs_315d3131-c1f7-453a-8a91-f12c06207edc.png/100x100_315d3131-c1f7-453a-8a91-f12c06207edc.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/thumbs_315d3131-c1f7-453a-8a91-f12c06207edc.png/100x100_315d3131-c1f7-453a-8a91-f12c06207edc.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/thumbs_b635c395-6837-49e5-8535-b0a6ebfbdbf3.png/100x100_b635c395-6837-49e5-8535-b0a6ebfbdbf3.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/thumbs_b635c395-6837-49e5-8535-b0a6ebfbdbf3.png/100x100_b635c395-6837-49e5-8535-b0a6ebfbdbf3.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/thumbs_c2c58441-27f5-4574-96f8-6f79dae9ff4d.png/100x100_c2c58441-27f5-4574-96f8-6f79dae9ff4d.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/thumbs_c2c58441-27f5-4574-96f8-6f79dae9ff4d.png/100x100_c2c58441-27f5-4574-96f8-6f79dae9ff4d.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/thumbs_e526d938-c5ab-41cb-a334-85b9c3e37f72.png/100x100_e526d938-c5ab-41cb-a334-85b9c3e37f72.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/054f9f24-0a0a-4e09-87b1-bc7ff2b336a2/thumbs_e526d938-c5ab-41cb-a334-85b9c3e37f72.png/100x100_e526d938-c5ab-41cb-a334-85b9c3e37f72.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/b84cd893-7119-43c9-8505-3c4e22da28a9/5e74d0b5-c183-4419-9d7c-a6a3d4a7faca.txt
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/b84cd893-7119-43c9-8505-3c4e22da28a9/5e74d0b5-c183-4419-9d7c-a6a3d4a7faca.txt.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/b84cd893-7119-43c9-8505-3c4e22da28a9/f80296fb-9fa5-4372-80f2-be196b973c7b.txt
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/b84cd893-7119-43c9-8505-3c4e22da28a9/f80296fb-9fa5-4372-80f2-be196b973c7b.txt.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/b84cd893-7119-43c9-8505-3c4e22da28a9/ff3b7633-6440-43d8-a957-1bfb3d3421ec.txt
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/b84cd893-7119-43c9-8505-3c4e22da28a9/ff3b7633-6440-43d8-a957-1bfb3d3421ec.txt.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/df55c8ff-45ef-4c82-8aed-6e2183fe1125/935a3325-f511-4d11-87f4-51034234a8d9.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/df55c8ff-45ef-4c82-8aed-6e2183fe1125/935a3325-f511-4d11-87f4-51034234a8d9.png.attrs
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/df55c8ff-45ef-4c82-8aed-6e2183fe1125/thumbs_935a3325-f511-4d11-87f4-51034234a8d9.png/100x100_935a3325-f511-4d11-87f4-51034234a8d9.png
delete mode 100644 tests/data/storage/f12f3eb6-b980-4bf6-b1e4-36de0450c8be/df55c8ff-45ef-4c82-8aed-6e2183fe1125/thumbs_935a3325-f511-4d11-87f4-51034234a8d9.png/100x100_935a3325-f511-4d11-87f4-51034234a8d9.png.attrs
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/300_WlbFWSGmW9.png
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/300_WlbFWSGmW9.png.attrs
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/logo_vcfJJG5TAh.svg
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/logo_vcfJJG5TAh.svg.attrs
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/test_QZFjKjXchk.txt
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/test_QZFjKjXchk.txt.attrs
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/test_d61b33QdDU.txt
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/test_d61b33QdDU.txt.attrs
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/thumbs_300_WlbFWSGmW9.png/100x100_300_WlbFWSGmW9.png
create mode 100644 tests/data/storage/wsmn24bux7wo113/84nmscqy84lsi1t/thumbs_300_WlbFWSGmW9.png/100x100_300_WlbFWSGmW9.png.attrs
create mode 100644 tests/data/storage/wsmn24bux7wo113/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png
create mode 100644 tests/data/storage/wsmn24bux7wo113/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png.attrs
create mode 100644 tests/data/storage/wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/100x100_300_Jsjq7RdBgA.png
create mode 100644 tests/data/storage/wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/100x100_300_Jsjq7RdBgA.png.attrs
create mode 100644 tests/data/storage/wzlqyes4orhoygb/7nwo8tuiatetxdm/test_JnXeKEwgwr.txt
create mode 100644 tests/data/storage/wzlqyes4orhoygb/7nwo8tuiatetxdm/test_JnXeKEwgwr.txt.attrs
create mode 100644 tests/data/storage/wzlqyes4orhoygb/lcl9d87w22ml6jy/300_UhLKX91HVb.png
create mode 100644 tests/data/storage/wzlqyes4orhoygb/lcl9d87w22ml6jy/300_UhLKX91HVb.png.attrs
create mode 100644 tests/data/storage/wzlqyes4orhoygb/lcl9d87w22ml6jy/test_FLurQTgrY8.txt
create mode 100644 tests/data/storage/wzlqyes4orhoygb/lcl9d87w22ml6jy/test_FLurQTgrY8.txt.attrs
create mode 100644 tests/data/storage/wzlqyes4orhoygb/lcl9d87w22ml6jy/thumbs_300_UhLKX91HVb.png/100x100_300_UhLKX91HVb.png
create mode 100644 tests/data/storage/wzlqyes4orhoygb/lcl9d87w22ml6jy/thumbs_300_UhLKX91HVb.png/100x100_300_UhLKX91HVb.png.attrs
create mode 100644 tests/data/storage/wzlqyes4orhoygb/mk5fmymtx4wsprk/300_JdfBOieXAW.png
create mode 100644 tests/data/storage/wzlqyes4orhoygb/mk5fmymtx4wsprk/300_JdfBOieXAW.png.attrs
create mode 100644 tests/data/storage/wzlqyes4orhoygb/mk5fmymtx4wsprk/thumbs_300_JdfBOieXAW.png/100x100_300_JdfBOieXAW.png
create mode 100644 tests/data/storage/wzlqyes4orhoygb/mk5fmymtx4wsprk/thumbs_300_JdfBOieXAW.png/100x100_300_JdfBOieXAW.png.attrs
create mode 100644 tokens/record.go
create mode 100644 tokens/record_test.go
delete mode 100644 tokens/user.go
delete mode 100644 tokens/user_test.go
create mode 100644 tools/types/types.go
create mode 100644 tools/types/types_test.go
create mode 100644 tools/types/untitled.js.erb
create mode 100644 ui/dist/assets/AuthMethodsDocs.6da908f0.js
create mode 100644 ui/dist/assets/AuthRefreshDocs.1d6e4e08.js
create mode 100644 ui/dist/assets/AuthWithOAuth2Docs.169fa55a.js
create mode 100644 ui/dist/assets/AuthWithPasswordDocs.f656a4b9.js
delete mode 100644 ui/dist/assets/CodeEditor.0b64cb4e.js
create mode 100644 ui/dist/assets/CodeEditor.45f24efe.js
create mode 100644 ui/dist/assets/ConfirmEmailChangeDocs.e9bf0cab.js
create mode 100644 ui/dist/assets/ConfirmPasswordResetDocs.f1aa1be6.js
create mode 100644 ui/dist/assets/ConfirmVerificationDocs.e35127c6.js
create mode 100644 ui/dist/assets/CreateApiDocs.60f76221.js
create mode 100644 ui/dist/assets/DeleteApiDocs.06551842.js
delete mode 100644 ui/dist/assets/FilterAutocompleteInput.37739e76.js
create mode 100644 ui/dist/assets/FilterAutocompleteInput.7da1d2a3.js
create mode 100644 ui/dist/assets/ListApiDocs.11b26f68.js
create mode 100644 ui/dist/assets/ListApiDocs.68f52edd.css
create mode 100644 ui/dist/assets/ListExternalAuthsDocs.960c39a0.js
rename ui/dist/assets/{PageAdminConfirmPasswordReset.8a4af6ed.js => PageAdminConfirmPasswordReset.19234b2d.js} (98%)
rename ui/dist/assets/{PageAdminRequestPasswordReset.34d90dd1.js => PageAdminRequestPasswordReset.7514fbab.js} (98%)
create mode 100644 ui/dist/assets/PageRecordConfirmEmailChange.75e01a7f.js
create mode 100644 ui/dist/assets/PageRecordConfirmPasswordReset.d1097e1b.js
create mode 100644 ui/dist/assets/PageRecordConfirmVerification.cdc4fa83.js
delete mode 100644 ui/dist/assets/PageUserConfirmEmailChange.df55986a.js
delete mode 100644 ui/dist/assets/PageUserConfirmPasswordReset.b3361442.js
delete mode 100644 ui/dist/assets/PageUserConfirmVerification.afbff8ec.js
create mode 100644 ui/dist/assets/RealtimeApiDocs.e9e53954.js
create mode 100644 ui/dist/assets/RequestEmailChangeDocs.1fe72b2e.js
create mode 100644 ui/dist/assets/RequestPasswordResetDocs.c8de2eb6.js
create mode 100644 ui/dist/assets/RequestVerificationDocs.2b01d123.js
create mode 100644 ui/dist/assets/SdkTabs.88269ae0.js
create mode 100644 ui/dist/assets/SdkTabs.9b0b7a06.css
create mode 100644 ui/dist/assets/UnlinkExternalAuthDocs.3445a27c.js
create mode 100644 ui/dist/assets/UpdateApiDocs.f4df7d8c.js
create mode 100644 ui/dist/assets/ViewApiDocs.d6f654f1.js
create mode 100644 ui/dist/assets/index.0a5eb9c8.css
delete mode 100644 ui/dist/assets/index.26987507.css
create mode 100644 ui/dist/assets/index.97f016a1.js
create mode 100644 ui/dist/assets/index.9c8b95cd.js
delete mode 100644 ui/dist/assets/index.a9121ab1.js
delete mode 100644 ui/dist/assets/index.e13041a6.js
create mode 100644 ui/src/components/base/HorizontalScroller.svelte
create mode 100644 ui/src/components/collections/CollectionAuthOptionsTab.svelte
create mode 100644 ui/src/components/collections/CollectionDocsPanel.svelte
create mode 100644 ui/src/components/collections/RuleField.svelte
create mode 100644 ui/src/components/collections/docs/AuthMethodsDocs.svelte
create mode 100644 ui/src/components/collections/docs/AuthRefreshDocs.svelte
create mode 100644 ui/src/components/collections/docs/AuthWithOAuth2Docs.svelte
create mode 100644 ui/src/components/collections/docs/AuthWithPasswordDocs.svelte
delete mode 100644 ui/src/components/collections/docs/CollectionDocsPanel.svelte
create mode 100644 ui/src/components/collections/docs/ConfirmEmailChangeDocs.svelte
create mode 100644 ui/src/components/collections/docs/ConfirmPasswordResetDocs.svelte
create mode 100644 ui/src/components/collections/docs/ConfirmVerificationDocs.svelte
create mode 100644 ui/src/components/collections/docs/ListExternalAuthsDocs.svelte
create mode 100644 ui/src/components/collections/docs/RequestEmailChangeDocs.svelte
create mode 100644 ui/src/components/collections/docs/RequestPasswordResetDocs.svelte
create mode 100644 ui/src/components/collections/docs/RequestVerificationDocs.svelte
create mode 100644 ui/src/components/collections/docs/UnlinkExternalAuthDocs.svelte
rename ui/src/components/{users => records}/ExternalAuthsList.svelte (82%)
rename ui/src/components/{users/PageUserConfirmEmailChange.svelte => records/PageRecordConfirmEmailChange.svelte} (91%)
rename ui/src/components/{users/PageUserConfirmPasswordReset.svelte => records/PageRecordConfirmPasswordReset.svelte} (90%)
rename ui/src/components/{users/PageUserConfirmVerification.svelte => records/PageRecordConfirmVerification.svelte} (87%)
create mode 100644 ui/src/components/records/fields/AuthFields.svelte
delete mode 100644 ui/src/components/records/fields/UserField.svelte
delete mode 100644 ui/src/components/settings/EmailAuthAccordion.svelte
delete mode 100644 ui/src/components/users/PageUsers.svelte
delete mode 100644 ui/src/components/users/UserSelect.svelte
delete mode 100644 ui/src/components/users/UserSelectOption.svelte
delete mode 100644 ui/src/components/users/UserUpsertPanel.svelte
create mode 100644 ui/src/scss/_docs_panel.scss
diff --git a/Makefile b/Makefile
index 6dc9b9a8..8dfe9041 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,8 @@ lint:
golangci-lint run -c ./golangci.yml ./...
test:
- go test -v --cover ./...
+ go test ./... -v --cover
test-report:
- go test -v --cover -coverprofile=coverage.out ./...
+ go test ./... -v --cover -coverprofile=coverage.out
go tool cover -html=coverage.out
diff --git a/README.md b/README.md
index ffbcc5e9..f263fc7c 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@ func main() {
return c.String(200, "Hello world!")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ apis.RequireAdminOrRecordAuth(),
},
})
diff --git a/apis/admin.go b/apis/admin.go
index 1273a147..3209911b 100644
--- a/apis/admin.go
+++ b/apis/admin.go
@@ -9,20 +9,19 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tokens"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
)
-// BindAdminApi registers the admin api endpoints and the corresponding handlers.
-func BindAdminApi(app core.App, rg *echo.Group) {
+// bindAdminApi registers the admin api endpoints and the corresponding handlers.
+func bindAdminApi(app core.App, rg *echo.Group) {
api := adminApi{app: app}
subGroup := rg.Group("/admins", ActivityLogger(app))
- subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly())
+ subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
- subGroup.POST("/refresh", api.refresh, RequireAdminAuth())
+ subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
subGroup.GET("/:id", api.view, RequireAdminAuth())
@@ -37,7 +36,7 @@ type adminApi struct {
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
if tokenErr != nil {
- return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
+ return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.AdminAuthEvent{
@@ -54,24 +53,24 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
})
}
-func (api *adminApi) refresh(c echo.Context) error {
+func (api *adminApi) authRefresh(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil {
- return rest.NewNotFoundError("Missing auth admin context.", nil)
+ return NewNotFoundError("Missing auth admin context.", nil)
}
return api.authResponse(c, admin)
}
-func (api *adminApi) emailAuth(c echo.Context) error {
+func (api *adminApi) authWithPassword(c echo.Context) error {
form := forms.NewAdminLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
if submitErr != nil {
- return rest.NewBadRequestError("Failed to authenticate.", submitErr)
+ return NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, admin)
@@ -80,11 +79,11 @@ func (api *adminApi) emailAuth(c echo.Context) error {
func (api *adminApi) requestPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
- return rest.NewBadRequestError("An error occurred while validating the form.", err)
+ return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show the result
@@ -101,12 +100,12 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
if submitErr != nil {
- return rest.NewBadRequestError("Failed to set new password.", submitErr)
+ return NewBadRequestError("Failed to set new password.", submitErr)
}
return api.authResponse(c, admin)
@@ -124,7 +123,7 @@ func (api *adminApi) list(c echo.Context) error {
ParseAndExec(c.QueryString(), &admins)
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
event := &core.AdminsListEvent{
@@ -141,12 +140,12 @@ func (api *adminApi) list(c echo.Context) error {
func (api *adminApi) view(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
event := &core.AdminViewEvent{
@@ -166,7 +165,7 @@ func (api *adminApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminCreateEvent{
@@ -179,7 +178,7 @@ func (api *adminApi) create(c echo.Context) error {
return func() error {
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to create admin.", err)
+ return NewBadRequestError("Failed to create admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -197,19 +196,19 @@ func (api *adminApi) create(c echo.Context) error {
func (api *adminApi) update(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
form := forms.NewAdminUpsert(api.app, admin)
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminUpdateEvent{
@@ -222,7 +221,7 @@ func (api *adminApi) update(c echo.Context) error {
return func() error {
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to update admin.", err)
+ return NewBadRequestError("Failed to update admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -240,12 +239,12 @@ func (api *adminApi) update(c echo.Context) error {
func (api *adminApi) delete(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
event := &core.AdminDeleteEvent{
@@ -255,7 +254,7 @@ func (api *adminApi) delete(c echo.Context) error {
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
- return rest.NewBadRequestError("Failed to delete admin.", err)
+ return NewBadRequestError("Failed to delete admin.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
diff --git a/apis/admin_test.go b/apis/admin_test.go
index a95583db..39f69589 100644
--- a/apis/admin_test.go
+++ b/apis/admin_test.go
@@ -14,39 +14,47 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
-func TestAdminAuth(t *testing.T) {
+func TestAdminAuthWithEmail(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
Method: http.MethodPost,
- Url: "/api/admins/auth-via-email",
+ Url: "/api/admins/auth-with-password",
Body: strings.NewReader(``),
ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
+ ExpectedContent: []string{`"data":{"identity":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
},
{
Name: "invalid data",
Method: http.MethodPost,
- Url: "/api/admins/auth-via-email",
+ Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "wrong email/password",
+ Name: "wrong email",
Method: http.MethodPost,
- Url: "/api/admins/auth-via-email",
- Body: strings.NewReader(`{"email":"missing@example.com","password":"wrong_pass"}`),
+ Url: "/api/admins/auth-with-password",
+ Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "wrong password",
+ Method: http.MethodPost,
+ Url: "/api/admins/auth-with-password",
+ Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid email/password (already authorized)",
Method: http.MethodPost,
- Url: "/api/admins/auth-via-email",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
+ Url: "/api/admins/auth-with-password",
+ Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
@@ -54,11 +62,11 @@ func TestAdminAuth(t *testing.T) {
{
Name: "valid email/password (guest)",
Method: http.MethodPost,
- Url: "/api/admins/auth-via-email",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
+ Url: "/api/admins/auth-with-password",
+ Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
- `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
+ `"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -158,21 +166,41 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "expired token",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA","password":"1234567890","passwordConfirm":"1234567890"}`),
+ Name: "expired token",
+ Method: http.MethodPost,
+ Url: "/api/admins/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
+ "password":"1234567890",
+ "passwordConfirm":"1234567890"
+ }`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`},
},
{
- Name: "valid token",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw","password":"1234567890","passwordConfirm":"1234567890"}`),
+ Name: "valid token + invalid password",
+ Method: http.MethodPost,
+ Url: "/api/admins/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
+ "password":"123456",
+ "passwordConfirm":"123456"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{"password":{"code":"validation_length_out_of_range"`},
+ },
+ {
+ Name: "valid token + valid password",
+ Method: http.MethodPost,
+ Url: "/api/admins/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
+ "password":"1234567891",
+ "passwordConfirm":"1234567891"
+ }`),
ExpectedStatus: 200,
ExpectedContent: []string{
- `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
+ `"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -193,30 +221,40 @@ func TestAdminRefresh(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/admins/refresh",
+ Url: "/api/admins/auth-refresh",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPost,
- Url: "/api/admins/refresh",
+ Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin",
+ Name: "authorized as admin (expired token)",
Method: http.MethodPost,
- Url: "/api/admins/refresh",
+ Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (valid token)",
+ Method: http.MethodPost,
+ Url: "/api/admins/auth-refresh",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
+ `"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -244,7 +282,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -254,16 +292,17 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
- `"totalItems":2`,
+ `"totalItems":3`,
`"items":[{`,
- `"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"sywbhecnh46rhm0"`,
+ `"id":"sbmbsdb40jyxf7h"`,
+ `"id":"9q2trqumvlyr3bd"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -274,15 +313,19 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins?page=2&perPage=1&sort=-created",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":1`,
- `"totalItems":2`,
+ `"totalItems":3`,
`"items":[{`,
- `"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
+ `"id":"sbmbsdb40jyxf7h"`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -293,7 +336,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins?filter=invalidfield~'test2'",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -301,9 +344,9 @@ func TestAdminsList(t *testing.T) {
{
Name: "authorized as admin + valid filter",
Method: http.MethodGet,
- Url: "/api/admins?filter=email~'test2'",
+ Url: "/api/admins?filter=email~'test3'",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -311,7 +354,11 @@ func TestAdminsList(t *testing.T) {
`"perPage":30`,
`"totalItems":1`,
`"items":[{`,
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"9q2trqumvlyr3bd"`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -329,36 +376,26 @@ func TestAdminView(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodGet,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
- {
- Name: "authorized as admin + invalid admin id",
- Method: http.MethodGet,
- Url: "/api/admins/invalid",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
{
Name: "authorized as admin + nonexisting admin id",
Method: http.MethodGet,
- Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
+ Url: "/api/admins/nonexisting",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -366,13 +403,17 @@ func TestAdminView(t *testing.T) {
{
Name: "authorized as admin + existing admin id",
Method: http.MethodGet,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"sbmbsdb40jyxf7h"`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminViewRequest": 1,
@@ -390,36 +431,26 @@ func TestAdminDelete(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodDelete,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodDelete,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin + invalid admin id",
+ Name: "authorized as admin + missing admin id",
Method: http.MethodDelete,
- Url: "/api/admins/invalid",
+ Url: "/api/admins/missing",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting admin id",
- Method: http.MethodDelete,
- Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -427,9 +458,9 @@ func TestAdminDelete(t *testing.T) {
{
Name: "authorized as admin + existing admin id",
Method: http.MethodDelete,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -442,15 +473,15 @@ func TestAdminDelete(t *testing.T) {
{
Name: "authorized as admin - try to delete the only remaining admin",
Method: http.MethodDelete,
- Url: "/api/admins/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ Url: "/api/admins/sywbhecnh46rhm0",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// delete all admins except the authorized one
adminModel := &models.Admin{}
_, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{
- "id": "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ "id": "sywbhecnh46rhm0",
})).Execute()
if err != nil {
t.Fatal(err)
@@ -508,7 +539,7 @@ func TestAdminCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -519,7 +550,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
@@ -530,7 +561,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -539,20 +570,36 @@ func TestAdminCreate(t *testing.T) {
Name: "authorized as admin + invalid data",
Method: http.MethodPost,
Url: "/api/admins",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
+ Body: strings.NewReader(`{
+ "email":"test@example.com",
+ "password":"1234",
+ "passwordConfirm":"4321",
+ "avatar":99
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"avatar":{"code":"validation_max_less_equal_than_required"`,
+ `"email":{"code":"validation_admin_email_exists"`,
+ `"password":{"code":"validation_length_out_of_range"`,
+ `"passwordConfirm":{"code":"validation_values_mismatch"`,
},
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
},
{
Name: "authorized as admin + valid data",
Method: http.MethodPost,
Url: "/api/admins",
- Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
+ Body: strings.NewReader(`{
+ "email":"testnew@example.com",
+ "password":"1234567890",
+ "passwordConfirm":"1234567890",
+ "avatar":3
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -560,6 +607,12 @@ func TestAdminCreate(t *testing.T) {
`"email":"testnew@example.com"`,
`"avatar":3`,
},
+ NotExpectedContent: []string{
+ `"password"`,
+ `"passwordConfirm"`,
+ `"tokenKey"`,
+ `"passwordHash"`,
+ },
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
@@ -579,38 +632,27 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin + invalid admin id",
+ Name: "authorized as admin + missing admin",
Method: http.MethodPatch,
- Url: "/api/admins/invalid",
+ Url: "/api/admins/missing",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting admin id",
- Method: http.MethodPatch,
- Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -618,14 +660,14 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + empty data",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"sbmbsdb40jyxf7h"`,
`"email":"test2@example.com"`,
`"avatar":2`,
},
@@ -639,10 +681,10 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + invalid formatted data",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -650,27 +692,49 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + invalid data",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
+ Url: "/api/admins/sbmbsdb40jyxf7h",
+ Body: strings.NewReader(`{
+ "email":"test@example.com",
+ "password":"1234",
+ "passwordConfirm":"4321",
+ "avatar":99
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"avatar":{"code":"validation_max_less_equal_than_required"`,
+ `"email":{"code":"validation_admin_email_exists"`,
+ `"password":{"code":"validation_length_out_of_range"`,
+ `"passwordConfirm":{"code":"validation_values_mismatch"`,
},
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
},
{
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
- Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":5}`),
+ Url: "/api/admins/sbmbsdb40jyxf7h",
+ Body: strings.NewReader(`{
+ "email":"testnew@example.com",
+ "password":"1234567891",
+ "passwordConfirm":"1234567891",
+ "avatar":5
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"sbmbsdb40jyxf7h"`,
`"email":"testnew@example.com"`,
`"avatar":5`,
},
+ NotExpectedContent: []string{
+ `"password"`,
+ `"passwordConfirm"`,
+ `"tokenKey"`,
+ `"passwordHash"`,
+ },
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
diff --git a/tools/rest/api_error.go b/apis/api_error.go
similarity index 94%
rename from tools/rest/api_error.go
rename to apis/api_error.go
index bef430de..f5f82391 100644
--- a/tools/rest/api_error.go
+++ b/apis/api_error.go
@@ -1,4 +1,4 @@
-package rest
+package apis
import (
"net/http"
@@ -8,7 +8,7 @@ import (
"github.com/pocketbase/pocketbase/tools/inflector"
)
-// ApiError defines the properties for a basic api error response.
+// ApiError defines the struct for a basic api error response.
type ApiError struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -23,6 +23,7 @@ func (e *ApiError) Error() string {
return e.Message
}
+// RawData returns the unformatted error data (could be an internal error, text, etc.)
func (e *ApiError) RawData() any {
return e.rawData
}
diff --git a/tools/rest/api_error_test.go b/apis/api_error_test.go
similarity index 92%
rename from tools/rest/api_error_test.go
rename to apis/api_error_test.go
index 89d52797..c9744f4b 100644
--- a/tools/rest/api_error_test.go
+++ b/apis/api_error_test.go
@@ -1,4 +1,4 @@
-package rest_test
+package apis_test
import (
"encoding/json"
@@ -6,11 +6,11 @@ import (
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/apis"
)
func TestNewApiErrorWithRawData(t *testing.T) {
- e := rest.NewApiError(
+ e := apis.NewApiError(
300,
"message_test",
"rawData_test",
@@ -33,7 +33,7 @@ func TestNewApiErrorWithRawData(t *testing.T) {
}
func TestNewApiErrorWithValidationData(t *testing.T) {
- e := rest.NewApiError(
+ e := apis.NewApiError(
300,
"message_test",
validation.Errors{
@@ -77,7 +77,7 @@ func TestNewNotFoundError(t *testing.T) {
}
for i, scenario := range scenarios {
- e := rest.NewNotFoundError(scenario.message, scenario.data)
+ e := apis.NewNotFoundError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -98,7 +98,7 @@ func TestNewBadRequestError(t *testing.T) {
}
for i, scenario := range scenarios {
- e := rest.NewBadRequestError(scenario.message, scenario.data)
+ e := apis.NewBadRequestError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -119,7 +119,7 @@ func TestNewForbiddenError(t *testing.T) {
}
for i, scenario := range scenarios {
- e := rest.NewForbiddenError(scenario.message, scenario.data)
+ e := apis.NewForbiddenError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -140,7 +140,7 @@ func TestNewUnauthorizedError(t *testing.T) {
}
for i, scenario := range scenarios {
- e := rest.NewUnauthorizedError(scenario.message, scenario.data)
+ e := apis.NewUnauthorizedError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
diff --git a/apis/base.go b/apis/base.go
index a9657b10..934594ff 100644
--- a/apis/base.go
+++ b/apis/base.go
@@ -2,6 +2,7 @@
package apis
import (
+ "errors"
"fmt"
"io/fs"
"log"
@@ -13,7 +14,6 @@ import (
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/ui"
"github.com/spf13/cast"
)
@@ -43,7 +43,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
return
}
- var apiErr *rest.ApiError
+ var apiErr *ApiError
switch v := err.(type) {
case *echo.HTTPError:
@@ -51,8 +51,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
log.Println(v.Internal)
}
msg := fmt.Sprintf("%v", v.Message)
- apiErr = rest.NewApiError(v.Code, msg, v)
- case *rest.ApiError:
+ apiErr = NewApiError(v.Code, msg, v)
+ case *ApiError:
if app.IsDebug() && v.RawData() != nil {
log.Println(v.RawData())
}
@@ -61,7 +61,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
if err != nil && app.IsDebug() {
log.Println(err)
}
- apiErr = rest.NewBadRequestError("", err)
+ apiErr = NewBadRequestError("", err)
}
// Send response
@@ -84,14 +84,14 @@ func InitApi(app core.App) (*echo.Echo, error) {
// default routes
api := e.Group("/api")
- BindSettingsApi(app, api)
- BindAdminApi(app, api)
- BindUserApi(app, api)
- BindCollectionApi(app, api)
- BindRecordApi(app, api)
- BindFileApi(app, api)
- BindRealtimeApi(app, api)
- BindLogsApi(app, api)
+ bindSettingsApi(app, api)
+ bindAdminApi(app, api)
+ bindCollectionApi(app, api)
+ bindRecordCrudApi(app, api)
+ bindRecordAuthApi(app, api)
+ bindFileApi(app, api)
+ bindRealtimeApi(app, api)
+ bindLogsApi(app, api)
// trigger the custom BeforeServe hook for the created api router
// allowing users to further adjust its options or register new routes
@@ -114,22 +114,31 @@ func InitApi(app core.App) (*echo.Echo, error) {
// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
// but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
//
+// If a file resource is missing and indexFallback is set, the request
+// will be forwarded to the base index.html (useful also for SPA).
+//
// @see https://github.com/labstack/echo/issues/2211
-func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.HandlerFunc {
+func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc {
return func(c echo.Context) error {
p := c.PathParam("*")
- if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
- tmpPath, err := url.PathUnescape(p)
- if err != nil {
- return fmt.Errorf("failed to unescape path variable: %w", err)
- }
- p = tmpPath
+
+ // escape url path
+ tmpPath, err := url.PathUnescape(p)
+ if err != nil {
+ return fmt.Errorf("failed to unescape path variable: %w", err)
}
+ p = tmpPath
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
- return c.FileFS(name, fileSystem)
+ fileErr := c.FileFS(name, fileSystem)
+
+ if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
+ return c.FileFS("index.html", fileSystem)
+ }
+
+ return fileErr
}
}
diff --git a/apis/base_test.go b/apis/base_test.go
index c9696939..b676b659 100644
--- a/apis/base_test.go
+++ b/apis/base_test.go
@@ -6,8 +6,8 @@ import (
"testing"
"github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/rest"
)
func Test404(t *testing.T) {
@@ -91,7 +91,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
Method: http.MethodGet,
Path: "/api-error",
Handler: func(c echo.Context) error {
- return rest.NewApiError(500, "test message", errors.New("internal_test"))
+ return apis.NewApiError(500, "test message", errors.New("internal_test"))
},
})
},
diff --git a/apis/collection.go b/apis/collection.go
index e06ec81a..861de01b 100644
--- a/apis/collection.go
+++ b/apis/collection.go
@@ -7,12 +7,11 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
-// BindCollectionApi registers the collection api endpoints and the corresponding handlers.
-func BindCollectionApi(app core.App, rg *echo.Group) {
+// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
+func bindCollectionApi(app core.App, rg *echo.Group) {
api := collectionApi{app: app}
subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth())
@@ -30,7 +29,7 @@ type collectionApi struct {
func (api *collectionApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(
- "id", "created", "updated", "name", "system",
+ "id", "created", "updated", "name", "system", "type",
)
collections := []*models.Collection{}
@@ -40,7 +39,7 @@ func (api *collectionApi) list(c echo.Context) error {
ParseAndExec(c.QueryString(), &collections)
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
event := &core.CollectionsListEvent{
@@ -57,7 +56,7 @@ func (api *collectionApi) list(c echo.Context) error {
func (api *collectionApi) view(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
event := &core.CollectionViewEvent{
@@ -77,7 +76,7 @@ func (api *collectionApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionCreateEvent{
@@ -90,7 +89,7 @@ func (api *collectionApi) create(c echo.Context) error {
return func() error {
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to create the collection.", err)
+ return NewBadRequestError("Failed to create the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -108,14 +107,14 @@ func (api *collectionApi) create(c echo.Context) error {
func (api *collectionApi) update(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
form := forms.NewCollectionUpsert(api.app, collection)
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionUpdateEvent{
@@ -128,7 +127,7 @@ func (api *collectionApi) update(c echo.Context) error {
return func() error {
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to update the collection.", err)
+ return NewBadRequestError("Failed to update the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -146,7 +145,7 @@ func (api *collectionApi) update(c echo.Context) error {
func (api *collectionApi) delete(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
event := &core.CollectionDeleteEvent{
@@ -156,7 +155,7 @@ func (api *collectionApi) delete(c echo.Context) error {
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
- return rest.NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
+ return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
@@ -174,7 +173,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
// load request data
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionsImportEvent{
@@ -189,7 +188,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
form.Collections = e.Collections // ensures that the form always has the latest changes
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to import the submitted collections.", err)
+ return NewBadRequestError("Failed to import the submitted collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
diff --git a/apis/collection_test.go b/apis/collection_test.go
index 2966c71e..13792f6b 100644
--- a/apis/collection_test.go
+++ b/apis/collection_test.go
@@ -2,6 +2,8 @@ package apis_test
import (
"net/http"
+ "os"
+ "path/filepath"
"strings"
"testing"
@@ -24,7 +26,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -34,19 +36,23 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
- `"totalItems":5`,
+ `"totalItems":7`,
`"items":[{`,
- `"id":"abe78266-fd4d-4aea-962d-8c0138ac522b"`,
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
- `"id":"3cd6fe92-70dc-4819-8542-4d036faabd89"`,
- `"id":"f12f3eb6-b980-4bf6-b1e4-36de0450c8be"`,
+ `"id":"_pb_users_auth_"`,
+ `"id":"v851q4r790rhknl"`,
+ `"id":"kpv709sk2lqbqk8"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"id":"sz5l5z67tg7gku0"`,
+ `"id":"wzlqyes4orhoygb"`,
+ `"id":"4d1blo5cuycfaca"`,
+ `"type":"auth"`,
+ `"type":"base"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -57,16 +63,16 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections?page=2&perPage=2&sort=-created",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
- `"totalItems":5`,
+ `"totalItems":7`,
`"items":[{`,
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
+ `"id":"4d1blo5cuycfaca"`,
+ `"id":"wzlqyes4orhoygb"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -77,7 +83,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections?filter=invalidfield~'demo2'",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -85,17 +91,20 @@ func TestCollectionsList(t *testing.T) {
{
Name: "authorized as admin + valid filter",
Method: http.MethodGet,
- Url: "/api/collections?filter=name~'demo2'",
+ Url: "/api/collections?filter=name~'demo'",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
- `"totalItems":1`,
+ `"totalItems":4`,
`"items":[{`,
- `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"id":"sz5l5z67tg7gku0"`,
+ `"id":"wzlqyes4orhoygb"`,
+ `"id":"4d1blo5cuycfaca"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -113,16 +122,16 @@ func TestCollectionView(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodGet,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -132,7 +141,7 @@ func TestCollectionView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections/missing",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -140,13 +149,14 @@ func TestCollectionView(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodGet,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"name":"demo1"`,
},
ExpectedEvents: map[string]int{
"OnCollectionViewRequest": 1,
@@ -155,13 +165,14 @@ func TestCollectionView(t *testing.T) {
{
Name: "authorized as admin + using the collection id",
Method: http.MethodGet,
- Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc",
+ Url: "/api/collections/wsmn24bux7wo113",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"name":"demo1"`,
},
ExpectedEvents: map[string]int{
"OnCollectionViewRequest": 1,
@@ -175,20 +186,29 @@ func TestCollectionView(t *testing.T) {
}
func TestCollectionDelete(t *testing.T) {
+ ensureDeletedFiles := func(app *tests.TestApp, collectionId string) {
+ storageDir := filepath.Join(app.DataDir(), "storage", collectionId)
+
+ entries, _ := os.ReadDir(storageDir)
+ if len(entries) != 0 {
+ t.Errorf("Expected empty/deleted dir, found %d", len(entries))
+ }
+ }
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodDelete,
- Url: "/api/collections/demo3",
+ Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodDelete,
- Url: "/api/collections/demo3",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -196,9 +216,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + nonexisting collection identifier",
Method: http.MethodDelete,
- Url: "/api/collections/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
+ Url: "/api/collections/missing",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -206,9 +226,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodDelete,
- Url: "/api/collections/demo3",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -217,13 +237,16 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ ensureDeletedFiles(app, "wsmn24bux7wo113")
+ },
},
{
Name: "authorized as admin + using the collection id",
Method: http.MethodDelete,
- Url: "/api/collections/3cd6fe92-70dc-4819-8542-4d036faabd89",
+ Url: "/api/collections/wsmn24bux7wo113",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -232,13 +255,16 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ ensureDeletedFiles(app, "wsmn24bux7wo113")
+ },
},
{
Name: "authorized as admin + trying to delete a system collection",
Method: http.MethodDelete,
- Url: "/api/collections/profiles",
+ Url: "/api/collections/nologin",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -249,9 +275,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + trying to delete a referenced collection",
Method: http.MethodDelete,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo2",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -280,7 +306,7 @@ func TestCollectionCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/collections",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -291,7 +317,7 @@ func TestCollectionCreate(t *testing.T) {
Url: "/api/collections",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -304,9 +330,9 @@ func TestCollectionCreate(t *testing.T) {
Name: "authorized as admin + invalid data (eg. existing name)",
Method: http.MethodPost,
Url: "/api/collections",
- Body: strings.NewReader(`{"name":"demo","schema":[{"type":"text","name":""}]}`),
+ Body: strings.NewReader(`{"name":"demo1","type":"base","schema":[{"type":"text","name":""}]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -319,16 +345,18 @@ func TestCollectionCreate(t *testing.T) {
Name: "authorized as admin + valid data",
Method: http.MethodPost,
Url: "/api/collections",
- Body: strings.NewReader(`{"name":"new","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
+ Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"name":"new"`,
+ `"type":"base"`,
`"system":false`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
+ `"options":{}`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
@@ -337,6 +365,154 @@ func TestCollectionCreate(t *testing.T) {
"OnCollectionAfterCreateRequest": 1,
},
},
+ {
+ Name: "creating auth collection without specified options",
+ Method: http.MethodPost,
+ Url: "/api/collections",
+ Body: strings.NewReader(`{"name":"new","type":"auth","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":`,
+ `"name":"new"`,
+ `"type":"auth"`,
+ `"system":false`,
+ `"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
+ `"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelBeforeCreate": 1,
+ "OnModelAfterCreate": 1,
+ "OnCollectionBeforeCreateRequest": 1,
+ "OnCollectionAfterCreateRequest": 1,
+ },
+ },
+ {
+ Name: "trying to create auth collection with reserved auth fields",
+ Method: http.MethodPost,
+ Url: "/api/collections",
+ Body: strings.NewReader(`{
+ "name":"new",
+ "type":"auth",
+ "schema":[
+ {"type":"text","name":"email"},
+ {"type":"text","name":"username"},
+ {"type":"text","name":"verified"},
+ {"type":"text","name":"emailVisibility"},
+ {"type":"text","name":"lastResetSentAt"},
+ {"type":"text","name":"lastVerificationSentAt"},
+ {"type":"text","name":"tokenKey"},
+ {"type":"text","name":"passwordHash"},
+ {"type":"text","name":"password"},
+ {"type":"text","name":"passwordConfirm"},
+ {"type":"text","name":"oldPassword"}
+ ]
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{"schema":{`,
+ `"0":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"1":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"2":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"3":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"4":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"5":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"6":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"7":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"8":{"name":{"code":"validation_reserved_auth_field_name"`,
+ },
+ },
+ {
+ Name: "creating base collection with reserved auth fields",
+ Method: http.MethodPost,
+ Url: "/api/collections",
+ Body: strings.NewReader(`{
+ "name":"new",
+ "type":"base",
+ "schema":[
+ {"type":"text","name":"email"},
+ {"type":"text","name":"username"},
+ {"type":"text","name":"verified"},
+ {"type":"text","name":"emailVisibility"},
+ {"type":"text","name":"lastResetSentAt"},
+ {"type":"text","name":"lastVerificationSentAt"},
+ {"type":"text","name":"tokenKey"},
+ {"type":"text","name":"passwordHash"},
+ {"type":"text","name":"password"},
+ {"type":"text","name":"passwordConfirm"},
+ {"type":"text","name":"oldPassword"}
+ ]
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"name":"new"`,
+ `"type":"base"`,
+ `"schema":[{`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelBeforeCreate": 1,
+ "OnModelAfterCreate": 1,
+ "OnCollectionBeforeCreateRequest": 1,
+ "OnCollectionAfterCreateRequest": 1,
+ },
+ },
+ {
+ Name: "trying to create base collection with reserved base fields",
+ Method: http.MethodPost,
+ Url: "/api/collections",
+ Body: strings.NewReader(`{
+ "name":"new",
+ "type":"base",
+ "schema":[
+ {"type":"text","name":"id"},
+ {"type":"text","name":"created"},
+ {"type":"text","name":"updated"},
+ {"type":"text","name":"expand"},
+ {"type":"text","name":"collectionId"},
+ {"type":"text","name":"collectionName"}
+ ]
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{"schema":{`,
+ `"0":{"name":{"code":"validation_not_in_invalid`,
+ `"1":{"name":{"code":"validation_not_in_invalid`,
+ `"2":{"name":{"code":"validation_not_in_invalid`,
+ `"3":{"name":{"code":"validation_not_in_invalid`,
+ `"4":{"name":{"code":"validation_not_in_invalid`,
+ `"5":{"name":{"code":"validation_not_in_invalid`,
+ },
+ },
+ {
+ Name: "trying to create auth collection with invalid options",
+ Method: http.MethodPost,
+ Url: "/api/collections",
+ Body: strings.NewReader(`{
+ "name":"new",
+ "type":"auth",
+ "schema":[{"type":"text","id":"12345789","name":"test"}],
+ "options":{"allowUsernameAuth": true}
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"options":{"minPasswordLength":{"code":"validation_required"`,
+ },
+ },
}
for _, scenario := range scenarios {
@@ -349,64 +525,80 @@ func TestCollectionUpdate(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin + empty data",
+ Name: "authorized as admin + missing collection",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
- Body: strings.NewReader(``),
+ Url: "/api/collections/missing",
+ Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin + empty body",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo1",
+ Body: strings.NewReader(`{}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"name":"demo1"`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
+ "OnCollectionBeforeUpdateRequest": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
},
},
{
Name: "authorized as admin + invalid data (eg. existing name)",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
- Body: strings.NewReader(`{"name":"demo2"}`),
+ Url: "/api/collections/demo1",
+ Body: strings.NewReader(`{
+ "name":"demo2",
+ "type":"auth"
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"name":{"code":"validation_collection_name_exists"`,
+ `"type":{"code":"validation_collection_type_change"`,
},
},
{
Name: "authorized as admin + valid data",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
Body: strings.NewReader(`{"name":"new"}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
+ `"id":`,
`"name":"new"`,
},
ExpectedEvents: map[string]int{
@@ -415,25 +607,139 @@ func TestCollectionUpdate(t *testing.T) {
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ // check if the record table was renamed
+ if !app.Dao().HasTable("new") {
+ t.Fatal("Couldn't find record table 'new'.")
+ }
+ },
+ },
+ {
+ Name: "trying to update auth collection with reserved auth fields",
+ Method: http.MethodPatch,
+ Url: "/api/collections/users",
+ Body: strings.NewReader(`{
+ "schema":[
+ {"type":"text","name":"email"},
+ {"type":"text","name":"username"},
+ {"type":"text","name":"verified"},
+ {"type":"text","name":"emailVisibility"},
+ {"type":"text","name":"lastResetSentAt"},
+ {"type":"text","name":"lastVerificationSentAt"},
+ {"type":"text","name":"tokenKey"},
+ {"type":"text","name":"passwordHash"},
+ {"type":"text","name":"password"},
+ {"type":"text","name":"passwordConfirm"},
+ {"type":"text","name":"oldPassword"}
+ ]
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{"schema":{`,
+ `"0":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"1":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"2":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"3":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"4":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"5":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"6":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"7":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"8":{"name":{"code":"validation_reserved_auth_field_name"`,
+ },
+ },
+ {
+ Name: "updating base collection with reserved auth fields",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo1",
+ Body: strings.NewReader(`{
+ "schema":[
+ {"type":"text","name":"email"},
+ {"type":"text","name":"username"},
+ {"type":"text","name":"verified"},
+ {"type":"text","name":"emailVisibility"},
+ {"type":"text","name":"lastResetSentAt"},
+ {"type":"text","name":"lastVerificationSentAt"},
+ {"type":"text","name":"tokenKey"},
+ {"type":"text","name":"passwordHash"},
+ {"type":"text","name":"password"},
+ {"type":"text","name":"passwordConfirm"},
+ {"type":"text","name":"oldPassword"}
+ ]
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"name":"demo1"`,
+ `"type":"base"`,
+ `"schema":[{`,
+ `"email"`,
+ `"username"`,
+ `"verified"`,
+ `"emailVisibility"`,
+ `"lastResetSentAt"`,
+ `"lastVerificationSentAt"`,
+ `"tokenKey"`,
+ `"passwordHash"`,
+ `"password"`,
+ `"passwordConfirm"`,
+ `"oldPassword"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelBeforeUpdate": 1,
+ "OnModelAfterUpdate": 1,
+ "OnCollectionBeforeUpdateRequest": 1,
+ "OnCollectionAfterUpdateRequest": 1,
+ },
},
{
- Name: "authorized as admin + valid data and id as identifier",
+ Name: "trying to update base collection with reserved base fields",
Method: http.MethodPatch,
- Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc",
- Body: strings.NewReader(`{"name":"new"}`),
+ Url: "/api/collections/demo1",
+ Body: strings.NewReader(`{
+ "name":"new",
+ "type":"base",
+ "schema":[
+ {"type":"text","name":"id"},
+ {"type":"text","name":"created"},
+ {"type":"text","name":"updated"},
+ {"type":"text","name":"expand"},
+ {"type":"text","name":"collectionId"},
+ {"type":"text","name":"collectionName"}
+ ]
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
- ExpectedStatus: 200,
+ ExpectedStatus: 400,
ExpectedContent: []string{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"name":"new"`,
+ `"data":{"schema":{`,
+ `"0":{"name":{"code":"validation_not_in_invalid`,
+ `"1":{"name":{"code":"validation_not_in_invalid`,
+ `"2":{"name":{"code":"validation_not_in_invalid`,
+ `"3":{"name":{"code":"validation_not_in_invalid`,
+ `"4":{"name":{"code":"validation_not_in_invalid`,
+ `"5":{"name":{"code":"validation_not_in_invalid`,
},
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnCollectionBeforeUpdateRequest": 1,
- "OnCollectionAfterUpdateRequest": 1,
+ },
+ {
+ Name: "trying to update auth collection with invalid options",
+ Method: http.MethodPatch,
+ Url: "/api/collections/users",
+ Body: strings.NewReader(`{
+ "options":{"minPasswordLength": 4}
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"options":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required"`,
},
},
}
@@ -457,7 +763,7 @@ func TestCollectionImport(t *testing.T) {
Method: http.MethodPut,
Url: "/api/collections/import",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -468,7 +774,7 @@ func TestCollectionImport(t *testing.T) {
Url: "/api/collections/import",
Body: strings.NewReader(`{"collections":[]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -480,8 +786,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
- if len(collections) != 5 {
- t.Fatalf("Expected %d collections, got %d", 5, len(collections))
+ expected := 7
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -491,7 +798,7 @@ func TestCollectionImport(t *testing.T) {
Url: "/api/collections/import",
Body: strings.NewReader(`{"deleteMissing": true, "collections":[{"name": "test123"}]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -500,14 +807,16 @@ func TestCollectionImport(t *testing.T) {
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
+ "OnModelBeforeDelete": 6,
},
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)
}
- if len(collections) != 5 {
- t.Fatalf("Expected %d collections, got %d", 5, len(collections))
+ expected := 7
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -531,7 +840,7 @@ func TestCollectionImport(t *testing.T) {
]
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -547,8 +856,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
- if len(collections) != 5 {
- t.Fatalf("Expected %d collections, got %d", 5, len(collections))
+ expected := 7
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -581,7 +891,7 @@ func TestCollectionImport(t *testing.T) {
]
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -595,8 +905,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
- if len(collections) != 7 {
- t.Fatalf("Expected %d collections, got %d", 7, len(collections))
+ expected := 9
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -608,45 +919,54 @@ func TestCollectionImport(t *testing.T) {
"deleteMissing": true,
"collections":[
{
- "id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "name":"profiles",
- "system":true,
- "listRule":"userId = @request.user.id",
- "viewRule":"created > 'test_change'",
- "createRule":"userId = @request.user.id",
- "updateRule":"userId = @request.user.id",
- "deleteRule":"userId = @request.user.id",
- "schema":[
+ "name": "new_import",
+ "schema": [
{
- "id":"koih1lqx",
- "name":"userId",
- "type":"user",
- "system":true,
- "required":true,
- "unique":true,
- "options":{
- "maxSelect":1,
- "cascadeDelete":true
- }
- },
- {
- "id":"69ycbg3q",
- "name":"rel",
- "type":"relation",
- "system":false,
- "required":false,
- "unique":false,
- "options":{
- "maxSelect":2,
- "collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "cascadeDelete":false
- }
+ "id": "koih1lqx",
+ "name": "test",
+ "type": "text"
}
]
},
{
- "id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
- "name":"demo",
+ "id": "kpv709sk2lqbqk8",
+ "system": true,
+ "name": "nologin",
+ "type": "auth",
+ "options": {
+ "allowEmailAuth": false,
+ "allowOAuth2Auth": false,
+ "allowUsernameAuth": false,
+ "exceptEmailDomains": [],
+ "manageRule": "@request.auth.collectionName = 'users'",
+ "minPasswordLength": 8,
+ "onlyEmailDomains": [],
+ "requireEmail": true
+ },
+ "listRule": "",
+ "viewRule": "",
+ "createRule": "",
+ "updateRule": "",
+ "deleteRule": "",
+ "schema": [
+ {
+ "id": "x8zzktwe",
+ "name": "name",
+ "type": "text",
+ "system": false,
+ "required": false,
+ "unique": false,
+ "options": {
+ "min": null,
+ "max": null,
+ "pattern": ""
+ }
+ }
+ ]
+ },
+ {
+ "id":"wsmn24bux7wo113",
+ "name":"demo1",
"schema":[
{
"id":"_2hlxbmp",
@@ -662,28 +982,18 @@ func TestCollectionImport(t *testing.T) {
}
}
]
- },
- {
- "name": "new_import",
- "schema": [
- {
- "id": "koih1lqx",
- "name": "test",
- "type": "text"
- }
- ]
}
]
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
- "OnModelBeforeDelete": 3,
- "OnModelAfterDelete": 3,
+ "OnModelBeforeDelete": 5,
+ "OnModelAfterDelete": 5,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
@@ -694,8 +1004,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
- if len(collections) != 3 {
- t.Fatalf("Expected %d collections, got %d", 3, len(collections))
+ expected := 3
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
diff --git a/apis/file.go b/apis/file.go
index 50c358b8..8bcca443 100644
--- a/apis/file.go
+++ b/apis/file.go
@@ -6,14 +6,13 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/rest"
)
-var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg"}
+var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
var defaultThumbSizes = []string{"100x100"}
-// BindFileApi registers the file api endpoints and the corresponding handlers.
-func BindFileApi(app core.App, rg *echo.Group) {
+// bindFileApi registers the file api endpoints and the corresponding handlers.
+func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{app: app}
subGroup := rg.Group("/files", ActivityLogger(app))
@@ -27,30 +26,30 @@ type fileApi struct {
func (api *fileApi) download(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
recordId := c.PathParam("recordId")
if recordId == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
- record, err := api.app.Dao().FindRecordById(collection, recordId, nil)
+ record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
if err != nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
filename := c.PathParam("filename")
fileField := record.FindFileFieldByFile(filename)
if fileField == nil {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
options, _ := fileField.Options.(*schema.FileOptions)
fs, err := api.app.NewFilesystem()
if err != nil {
- return rest.NewBadRequestError("Filesystem initialization failure.", err)
+ return NewBadRequestError("Filesystem initialization failure.", err)
}
defer fs.Close()
@@ -64,7 +63,7 @@ func (api *fileApi) download(c echo.Context) error {
// extract the original file meta attributes and check it existence
oAttrs, oAttrsErr := fs.Attributes(originalPath)
if oAttrsErr != nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
// check if it is an image
@@ -96,7 +95,7 @@ func (api *fileApi) download(c echo.Context) error {
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
return nil
diff --git a/apis/file_test.go b/apis/file_test.go
index d6d106f8..a2f10735 100644
--- a/apis/file_test.go
+++ b/apis/file_test.go
@@ -14,14 +14,15 @@ import (
func TestFileDownload(t *testing.T) {
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
- testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt")
- testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50t_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50b_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50f_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/0x50_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x0_4881bdef-06b4-4dea-8d97-6125ad242677.png")
+
+ testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt")
+ testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png")
+ testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png")
+ testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png")
+ testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png")
+ testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png")
+ testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/0x50_300_1SEi6Q6U72.png")
+ testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x0_300_1SEi6Q6U72.png")
testFile, fileErr := os.ReadFile(testFilePath)
if fileErr != nil {
@@ -67,28 +68,28 @@ func TestFileDownload(t *testing.T) {
{
Name: "missing collection",
Method: http.MethodGet,
- Url: "/api/files/missing/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
+ Url: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record",
Method: http.MethodGet,
- Url: "/api/files/demo/00000000-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
+ Url: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing file",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/00000000-06b4-4dea-8d97-6125ad242677.png",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "existing image",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -98,7 +99,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - missing thumb (should fallback to the original)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=999x999",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -108,7 +109,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop center)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropCenter)},
ExpectedEvents: map[string]int{
@@ -118,7 +119,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop top)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50t",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropTop)},
ExpectedEvents: map[string]int{
@@ -128,7 +129,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop bottom)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50b",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropBottom)},
ExpectedEvents: map[string]int{
@@ -138,7 +139,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (fit)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50f",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbFit)},
ExpectedEvents: map[string]int{
@@ -148,7 +149,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (zero width)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=0x50",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroWidth)},
ExpectedEvents: map[string]int{
@@ -158,7 +159,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (zero height)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x0",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroHeight)},
ExpectedEvents: map[string]int{
@@ -168,7 +169,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing non image file - thumb parameter should be ignored",
Method: http.MethodGet,
- Url: "/api/files/demo/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt?thumb=100x100",
+ Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
diff --git a/apis/logs.go b/apis/logs.go
index 1cec710e..7452fd65 100644
--- a/apis/logs.go
+++ b/apis/logs.go
@@ -7,12 +7,11 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
-// BindLogsApi registers the request logs api endpoints.
-func BindLogsApi(app core.App, rg *echo.Group) {
+// bindLogsApi registers the request logs api endpoints.
+func bindLogsApi(app core.App, rg *echo.Group) {
api := logsApi{app: app}
subGroup := rg.Group("/logs", RequireAdminAuth())
@@ -39,7 +38,7 @@ func (api *logsApi) requestsList(c echo.Context) error {
ParseAndExec(c.QueryString(), &[]*models.Request{})
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
return c.JSON(http.StatusOK, result)
@@ -55,13 +54,13 @@ func (api *logsApi) requestsStats(c echo.Context) error {
var err error
expr, err = search.FilterData(filter).BuildExpr(fieldResolver)
if err != nil {
- return rest.NewBadRequestError("Invalid filter format.", err)
+ return NewBadRequestError("Invalid filter format.", err)
}
}
stats, err := api.app.LogsDao().RequestsStats(expr)
if err != nil {
- return rest.NewBadRequestError("Failed to generate requests stats.", err)
+ return NewBadRequestError("Failed to generate requests stats.", err)
}
return c.JSON(http.StatusOK, stats)
@@ -70,12 +69,12 @@ func (api *logsApi) requestsStats(c echo.Context) error {
func (api *logsApi) requestView(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
request, err := api.app.LogsDao().FindRequestById(id)
if err != nil || request == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
return c.JSON(http.StatusOK, request)
diff --git a/apis/logs_test.go b/apis/logs_test.go
index 98db6c1a..648fb0e2 100644
--- a/apis/logs_test.go
+++ b/apis/logs_test.go
@@ -18,11 +18,11 @@ func TestRequestsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -32,7 +32,7 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -54,7 +54,7 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests?filter=status>200",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -87,11 +87,11 @@ func TestRequestView(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -101,7 +101,7 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -116,7 +116,7 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -145,11 +145,11 @@ func TestRequestsStats(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -159,7 +159,7 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -168,7 +168,7 @@ func TestRequestsStats(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
+ `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
},
},
{
@@ -176,7 +176,7 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/stats?filter=status>200",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -185,7 +185,7 @@ func TestRequestsStats(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `[{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
+ `[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
},
},
}
diff --git a/apis/middlewares.go b/apis/middlewares.go
index 06c6cb7b..542ce751 100644
--- a/apis/middlewares.go
+++ b/apis/middlewares.go
@@ -11,30 +11,32 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/tokens"
+ "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/routine"
+ "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// Common request context keys used by the middlewares and api handlers.
const (
- ContextUserKey string = "user"
ContextAdminKey string = "admin"
+ ContextAuthRecordKey string = "authRecord"
ContextCollectionKey string = "collection"
)
// RequireGuestOnly middleware requires a request to NOT have a valid
-// Authorization header set.
+// Authorization header.
//
-// This middleware is the opposite of [apis.RequireAdminOrUserAuth()].
+// This middleware is the opposite of [apis.RequireAdminOrRecordAuth()].
func RequireGuestOnly() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
- err := rest.NewBadRequestError("The request can be accessed only by guests.", nil)
+ err := NewBadRequestError("The request can be accessed only by guests.", nil)
- user, _ := c.Get(ContextUserKey).(*models.User)
- if user != nil {
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if record != nil {
return err
}
@@ -48,14 +50,55 @@ func RequireGuestOnly() echo.MiddlewareFunc {
}
}
-// RequireUserAuth middleware requires a request to have
-// a valid user Authorization header set (aka. `Authorization: User ...`).
-func RequireUserAuth() echo.MiddlewareFunc {
+// RequireRecordAuth middleware requires a request to have
+// a valid record auth Authorization header.
+//
+// The auth record could be from any collection.
+//
+// You can further filter the allowed record auth collections by
+// specifying their names.
+//
+// Example:
+// apis.RequireRecordAuth()
+// Or:
+// apis.RequireRecordAuth("users", "supervisors")
+//
+// To restrict the auth record only to the loaded context collection,
+// use [apis.RequireSameContextRecordAuth()] instead.
+func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
- user, _ := c.Get(ContextUserKey).(*models.User)
- if user == nil {
- return rest.NewUnauthorizedError("The request requires valid user authorization token to be set.", nil)
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if record == nil {
+ return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
+ }
+
+ // check record collection name
+ if len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
+ return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
+ }
+
+ return next(c)
+ }
+ }
+}
+
+//
+// RequireSameContextRecordAuth middleware requires a request to have
+// a valid record Authorization header.
+//
+// The auth record must be from the same collection already loaded in the context.
+func RequireSameContextRecordAuth() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if record == nil {
+ return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
+ }
+
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil || record.Collection().Id != collection.Id {
+ return NewForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", record.Collection().Name), nil)
}
return next(c)
@@ -64,13 +107,13 @@ func RequireUserAuth() echo.MiddlewareFunc {
}
// RequireAdminAuth middleware requires a request to have
-// a valid admin Authorization header set (aka. `Authorization: Admin ...`).
+// a valid admin Authorization header.
func RequireAdminAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil {
- return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
+ return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
}
return next(c)
@@ -79,14 +122,14 @@ func RequireAdminAuth() echo.MiddlewareFunc {
}
// RequireAdminAuthOnlyIfAny middleware requires a request to have
-// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
-// ONLY if the application has at least 1 existing Admin model.
+// a valid admin Authorization header ONLY if the application has
+// at least 1 existing Admin model.
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
totalAdmins, err := app.Dao().TotalAdmins()
if err != nil {
- return rest.NewBadRequestError("Failed to fetch admins info.", err)
+ return NewBadRequestError("Failed to fetch admins info.", err)
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -95,24 +138,29 @@ func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return next(c)
}
- return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
+ return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
}
}
}
-// RequireAdminOrUserAuth middleware requires a request to have
-// a valid admin or user Authorization header set
-// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
+// RequireAdminOrRecordAuth middleware requires a request to have
+// a valid admin or record Authorization header set.
+//
+// You can further filter the allowed auth record collections by providing their names.
//
// This middleware is the opposite of [apis.RequireGuestOnly()].
-func RequireAdminOrUserAuth() echo.MiddlewareFunc {
+func RequireAdminOrRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- user, _ := c.Get(ContextUserKey).(*models.User)
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if admin == nil && user == nil {
- return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
+ if admin == nil && record == nil {
+ return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
+ }
+
+ if record != nil && len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
+ return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
}
return next(c)
@@ -121,29 +169,33 @@ func RequireAdminOrUserAuth() echo.MiddlewareFunc {
}
// RequireAdminOrOwnerAuth middleware requires a request to have
-// a valid admin or user owner Authorization header set
-// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
+// a valid admin or auth record owner Authorization header set.
//
-// This middleware is similar to [apis.RequireAdminOrUserAuth()] but
-// for the user token expects to have the same id as the path parameter
-// `ownerIdParam` (default to "id").
+// This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
+// for the auth record token expects to have the same id as the path
+// parameter ownerIdParam (default to "id" if empty).
func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
+ admin, _ := c.Get(ContextAdminKey).(*models.Admin)
+ if admin != nil {
+ return next(c)
+ }
+
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if record == nil {
+ return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
+ }
+
if ownerIdParam == "" {
ownerIdParam = "id"
}
-
ownerId := c.PathParam(ownerIdParam)
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- loggedUser, _ := c.Get(ContextUserKey).(*models.User)
- if admin == nil && loggedUser == nil {
- return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
- }
-
- if admin == nil && loggedUser.Id != ownerId {
- return rest.NewForbiddenError("You are not allowed to perform this request.", nil)
+ // note: it is "safe" to compare only the record id since the auth
+ // record ids are treated as unique across all auth collections
+ if record.Id != ownerId {
+ return NewForbiddenError("You are not allowed to perform this request.", nil)
}
return next(c)
@@ -152,32 +204,41 @@ func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
}
// LoadAuthContext middleware reads the Authorization request header
-// and loads the token related user or admin instance into the
+// and loads the token related record or admin instance into the
// request's context.
//
-// This middleware is expected to be registered by default for all routes.
+// This middleware is expected to be already registered by default for all routes.
func LoadAuthContext(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Request().Header.Get("Authorization")
+ if token == "" {
+ return next(c)
+ }
- if token != "" {
- if strings.HasPrefix(token, "User ") {
- user, err := app.Dao().FindUserByToken(
- token[5:],
- app.Settings().UserAuthToken.Secret,
- )
- if err == nil && user != nil {
- c.Set(ContextUserKey, user)
- }
- } else if strings.HasPrefix(token, "Admin ") {
- admin, err := app.Dao().FindAdminByToken(
- token[6:],
- app.Settings().AdminAuthToken.Secret,
- )
- if err == nil && admin != nil {
- c.Set(ContextAdminKey, admin)
- }
+ // the schema is not required and it is only for
+ // compatibility with the defaults of some HTTP clients
+ token = strings.TrimPrefix(token, "Bearer ")
+
+ claims, _ := security.ParseUnverifiedJWT(token)
+ tokenType := cast.ToString(claims["type"])
+
+ switch tokenType {
+ case tokens.TypeAdmin:
+ admin, err := app.Dao().FindAdminByToken(
+ token,
+ app.Settings().AdminAuthToken.Secret,
+ )
+ if err == nil && admin != nil {
+ c.Set(ContextAdminKey, admin)
+ }
+ case tokens.TypeAuthRecord:
+ record, err := app.Dao().FindAuthRecordByToken(
+ token,
+ app.Settings().RecordAuthToken.Secret,
+ )
+ if err == nil && record != nil {
+ c.Set(ContextAuthRecordKey, record)
}
}
@@ -188,13 +249,19 @@ func LoadAuthContext(app core.App) echo.MiddlewareFunc {
// LoadCollectionContext middleware finds the collection with related
// path identifier and loads it into the request context.
-func LoadCollectionContext(app core.App) echo.MiddlewareFunc {
+//
+// Set optCollectionTypes to further filter the found collection by its type.
+func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if param := c.PathParam("collection"); param != "" {
collection, err := app.Dao().FindCollectionByNameOrId(param)
if err != nil || collection == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
+ }
+
+ if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
+ return NewBadRequestError("Invalid collection type.", nil)
}
c.Set(ContextCollectionKey, collection)
@@ -231,7 +298,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.Internal)
- case *rest.ApiError:
+ case *ApiError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.RawData())
@@ -242,8 +309,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
}
requestAuth := models.RequestAuthGuest
- if c.Get(ContextUserKey) != nil {
- requestAuth = models.RequestAuthUser
+ if c.Get(ContextAuthRecordKey) != nil {
+ requestAuth = models.RequestAuthRecord
} else if c.Get(ContextAdminKey) != nil {
requestAuth = models.RequestAuthAdmin
}
diff --git a/apis/middlewares_test.go b/apis/middlewares_test.go
index 451145ce..6dd81fdd 100644
--- a/apis/middlewares_test.go
+++ b/apis/middlewares_test.go
@@ -12,11 +12,11 @@ import (
func TestRequireGuestOnly(t *testing.T) {
scenarios := []tests.ApiScenario{
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -38,7 +38,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -60,7 +60,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -103,7 +103,7 @@ func TestRequireGuestOnly(t *testing.T) {
}
}
-func TestRequireUserAuth(t *testing.T) {
+func TestRequireRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -117,7 +117,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireUserAuth(),
+ apis.RequireRecordAuth(),
},
})
},
@@ -129,7 +129,7 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -139,7 +139,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireUserAuth(),
+ apis.RequireRecordAuth(),
},
})
},
@@ -151,7 +151,7 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -161,7 +161,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireUserAuth(),
+ apis.RequireRecordAuth(),
},
})
},
@@ -169,11 +169,11 @@ func TestRequireUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -183,7 +183,167 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireUserAuth(),
+ apis.RequireRecordAuth(),
+ },
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ {
+ Name: "valid record token with collection not in the restricted list",
+ Method: http.MethodGet,
+ Url: "/my/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireRecordAuth("demo1", "demo2"),
+ },
+ })
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "valid record token with collection in the restricted list",
+ Method: http.MethodGet,
+ Url: "/my/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireRecordAuth("demo1", "demo2", "users"),
+ },
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRequireSameContextRecordAuth(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ Url: "/my/users/test",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireSameContextRecordAuth(),
+ },
+ })
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "expired/invalid token",
+ Method: http.MethodGet,
+ Url: "/my/users/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireSameContextRecordAuth(),
+ },
+ })
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "valid admin token",
+ Method: http.MethodGet,
+ Url: "/my/users/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireSameContextRecordAuth(),
+ },
+ })
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "valid record token but from different collection",
+ Method: http.MethodGet,
+ Url: "/my/users/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireSameContextRecordAuth(),
+ },
+ })
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "valid record token",
+ Method: http.MethodGet,
+ Url: "/my/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireRecordAuth(),
},
})
},
@@ -223,7 +383,7 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -241,11 +401,11 @@ func TestRequireAdminAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -267,7 +427,7 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -342,7 +502,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -360,11 +520,11 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -386,7 +546,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -410,7 +570,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
}
}
-func TestRequireAdminOrUserAuth(t *testing.T) {
+func TestRequireAdminOrRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -424,7 +584,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ apis.RequireAdminOrRecordAuth(),
},
})
},
@@ -436,7 +596,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -446,7 +606,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ apis.RequireAdminOrRecordAuth(),
},
})
},
@@ -454,11 +614,11 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -468,7 +628,51 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ apis.RequireAdminOrRecordAuth(),
+ },
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ {
+ Name: "valid record token with collection not in the restricted list",
+ Method: http.MethodGet,
+ Url: "/my/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireAdminOrRecordAuth("demo1", "demo2", "clients"),
+ },
+ })
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "valid record token with collection in the restricted list",
+ Method: http.MethodGet,
+ Url: "/my/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireAdminOrRecordAuth("demo1", "demo2", "users"),
},
})
},
@@ -480,7 +684,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -490,7 +694,29 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ apis.RequireAdminOrRecordAuth(),
+ },
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ {
+ Name: "valid admin token + restricted collections list (should be ignored)",
+ Method: http.MethodGet,
+ Url: "/my/test",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/test",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireAdminOrRecordAuth("demo1", "demo2"),
},
})
},
@@ -509,7 +735,7 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "guest",
Method: http.MethodGet,
- Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
+ Url: "/my/test/4q1xlclmfloku33",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
@@ -528,9 +754,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "expired/invalid token",
Method: http.MethodGet,
- Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
+ Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -548,12 +774,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token (different user)",
+ Name: "valid record token (different user)",
Method: http.MethodGet,
- Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
+ Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImJnczgyMG4zNjF2ajFxZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.tW4NZWZ0mHBgvSZsQ0OOQhWajpUNFPCvNrOF9aCZLZs",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -571,11 +796,33 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token (owner)",
+ Name: "valid record token (different collection)",
Method: http.MethodGet,
- Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
+ Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/test/:id",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.RequireAdminOrOwnerAuth(""),
+ },
+ })
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "valid record token (owner)",
+ Method: http.MethodGet,
+ Url: "/my/test/4q1xlclmfloku33",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -595,9 +842,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "valid admin token",
Method: http.MethodGet,
- Url: "/my/test/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -620,3 +867,132 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
scenario.Test(t)
}
}
+
+func TestLoadCollectionContext(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "missing collection",
+ Method: http.MethodGet,
+ Url: "/my/missing",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.LoadCollectionContext(app),
+ },
+ })
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ Url: "/my/demo1",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.LoadCollectionContext(app),
+ },
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ {
+ Name: "valid record token",
+ Method: http.MethodGet,
+ Url: "/my/demo1",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.LoadCollectionContext(app),
+ },
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ {
+ Name: "valid admin token",
+ Method: http.MethodGet,
+ Url: "/my/demo1",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.LoadCollectionContext(app),
+ },
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ {
+ Name: "mismatched type",
+ Method: http.MethodGet,
+ Url: "/my/demo1",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.LoadCollectionContext(app, "auth"),
+ },
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "matched type",
+ Method: http.MethodGet,
+ Url: "/my/users",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ e.AddRoute(echo.Route{
+ Method: http.MethodGet,
+ Path: "/my/:collection",
+ Handler: func(c echo.Context) error {
+ return c.String(200, "test123")
+ },
+ Middlewares: []echo.MiddlewareFunc{
+ apis.LoadCollectionContext(app, "auth"),
+ },
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/realtime.go b/apis/realtime.go
index d10ba9f5..5ba3fdb6 100644
--- a/apis/realtime.go
+++ b/apis/realtime.go
@@ -15,13 +15,12 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
-// BindRealtimeApi registers the realtime api endpoints.
-func BindRealtimeApi(app core.App, rg *echo.Group) {
+// bindRealtimeApi registers the realtime api endpoints.
+func bindRealtimeApi(app core.App, rg *echo.Group) {
api := realtimeApi{app: app}
subGroup := rg.Group("/realtime", ActivityLogger(app))
@@ -113,25 +112,25 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// read request data
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
// validate request data
if err := form.Validate(); err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
// find subscription client
client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId)
if err != nil {
- return rest.NewNotFoundError("Missing or invalid client id.", err)
+ return NewNotFoundError("Missing or invalid client id.", err)
}
// check if the previous request was authorized
oldAuthId := extractAuthIdFromGetter(client)
newAuthId := extractAuthIdFromGetter(c)
if oldAuthId != "" && oldAuthId != newAuthId {
- return rest.NewForbiddenError("The current and the previous request authorization don't match.", nil)
+ return NewForbiddenError("The current and the previous request authorization don't match.", nil)
}
event := &core.RealtimeSubscribeEvent{
@@ -143,7 +142,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
// update auth state
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
- e.Client.Set(ContextUserKey, e.HttpContext.Get(ContextUserKey))
+ e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
// unsubscribe from any previous existing subscriptions
e.Client.Unsubscribe()
@@ -161,53 +160,52 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
return handlerErr
}
+// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
+func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
+ for _, client := range api.app.SubscriptionsBroker().Clients() {
+ clientModel, _ := client.Get(contextKey).(models.Model)
+ if clientModel != nil && clientModel.GetId() == newModel.GetId() {
+ client.Set(contextKey, newModel)
+ }
+ }
+
+ return nil
+}
+
+// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
+func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
+ for _, client := range api.app.SubscriptionsBroker().Clients() {
+ clientModel, _ := client.Get(contextKey).(models.Model)
+ if clientModel != nil && clientModel.GetId() == model.GetId() {
+ api.app.SubscriptionsBroker().Unregister(client.Id())
+ }
+ }
+
+ return nil
+}
+
func (api *realtimeApi) bindEvents() {
- userTable := (&models.User{}).TableName()
- adminTable := (&models.Admin{}).TableName()
-
- // update user/admin auth state
+ // update the clients that has admin or auth record association
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
- modelTable := e.Model.TableName()
-
- var contextKey string
- switch modelTable {
- case userTable:
- contextKey = ContextUserKey
- case adminTable:
- contextKey = ContextAdminKey
- default:
- return nil
+ if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
+ return api.updateClientsAuthModel(ContextAuthRecordKey, record)
}
- for _, client := range api.app.SubscriptionsBroker().Clients() {
- model, _ := client.Get(contextKey).(models.Model)
- if model != nil && model.GetId() == e.Model.GetId() {
- client.Set(contextKey, e.Model)
- }
+ if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
+ return api.updateClientsAuthModel(ContextAdminKey, admin)
}
return nil
})
- // remove user/admin client(s)
+ // remove the client(s) associated to the deleted admin or auth record
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
- modelTable := e.Model.TableName()
-
- var contextKey string
- switch modelTable {
- case userTable:
- contextKey = ContextUserKey
- case adminTable:
- contextKey = ContextAdminKey
- default:
- return nil
+ if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
+ return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
}
- for _, client := range api.app.SubscriptionsBroker().Clients() {
- model, _ := client.Get(contextKey).(models.Model)
- if model != nil && model.GetId() == e.Model.GetId() {
- api.app.SubscriptionsBroker().Unregister(client.Id())
- }
+ if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
+ return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
}
return nil
@@ -254,17 +252,17 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
// emulate request data
requestData := map[string]any{
- "method": "get",
+ "method": "GET",
"query": map[string]any{},
"data": map[string]any{},
- "user": nil,
+ "auth": nil,
}
- user, _ := client.Get(ContextUserKey).(*models.User)
- if user != nil {
- requestData["user"], _ = user.AsMap()
+ authRecord, _ := client.Get(ContextAuthRecordKey).(*models.Record)
+ if authRecord != nil {
+ requestData["auth"] = authRecord.PublicExport()
}
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData)
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return err
@@ -275,7 +273,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
return nil
}
- foundRecord, err := api.app.Dao().FindRecordById(record.Collection(), record.Id, ruleFunc)
+ foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
if err == nil && foundRecord != nil {
return true
}
@@ -303,6 +301,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
// know if the clients have access to view the expanded records
cleanRecord := *record
cleanRecord.SetExpand(nil)
+ cleanRecord.WithUnkownData(false)
+ cleanRecord.IgnoreEmailVisibility(false)
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
@@ -316,7 +316,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
Record: &cleanRecord,
}
- serializedData, err := json.Marshal(data)
+ dataBytes, err := json.Marshal(data)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
@@ -324,6 +324,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return err
}
+ encodedData := string(dataBytes)
+
for _, client := range clients {
for subscription, rule := range subscriptionRuleMap {
if !client.HasSubscription(subscription) {
@@ -336,7 +338,21 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
msg := subscriptions.Message{
Name: subscription,
- Data: string(serializedData),
+ Data: encodedData,
+ }
+
+ // ignore the auth record email visibility checks for
+ // auth owner, admin or manager
+ if collection.IsAuth() {
+ authId := extractAuthIdFromGetter(client)
+ if authId == data.Record.Id ||
+ api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
+ data.Record.IgnoreEmailVisibility(true) // ignore
+ if newData, err := json.Marshal(data); err == nil {
+ msg.Data = string(newData)
+ }
+ data.Record.IgnoreEmailVisibility(false) // restore
+ }
}
client.Channel() <- msg
@@ -351,9 +367,9 @@ type getter interface {
}
func extractAuthIdFromGetter(val getter) string {
- user, _ := val.Get(ContextUserKey).(*models.User)
- if user != nil {
- return user.Id
+ record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
+ if record != nil {
+ return record.Id
}
admin, _ := val.Get(ContextAdminKey).(*models.Admin)
diff --git a/apis/realtime_test.go b/apis/realtime_test.go
index 9de66139..6810ad22 100644
--- a/apis/realtime_test.go
+++ b/apis/realtime_test.go
@@ -46,7 +46,7 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient := func() {
client.Unsubscribe()
client.Set(apis.ContextAdminKey, nil)
- client.Set(apis.ContextUserKey, nil)
+ client.Set(apis.ContextAuthRecordKey, nil)
}
scenarios := []tests.ApiScenario{
@@ -113,7 +113,7 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -132,12 +132,12 @@ func TestRealtimeSubscribe(t *testing.T) {
},
},
{
- Name: "existing client - authorized user",
+ Name: "existing client - authorized record",
Method: http.MethodPost,
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -148,9 +148,9 @@ func TestRealtimeSubscribe(t *testing.T) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- user, _ := client.Get(apis.ContextUserKey).(*models.User)
- if user == nil {
- t.Errorf("Expected user auth model, got nil")
+ authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
+ if authRecord == nil {
+ t.Errorf("Expected auth record model, got nil")
}
resetClient()
},
@@ -161,21 +161,21 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- initialAuth := &models.User{}
+ initialAuth := &models.Record{}
initialAuth.RefreshId()
- client.Set(apis.ContextUserKey, initialAuth)
+ client.Set(apis.ContextAuthRecordKey, initialAuth)
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- user, _ := client.Get(apis.ContextUserKey).(*models.User)
- if user == nil {
- t.Errorf("Expected user auth model, got nil")
+ authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
+ if authRecord == nil {
+ t.Errorf("Expected auth record model, got nil")
}
resetClient()
},
@@ -187,55 +187,55 @@ func TestRealtimeSubscribe(t *testing.T) {
}
}
-func TestRealtimeUserDeleteEvent(t *testing.T) {
+func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
- user, err := testApp.Dao().FindUserByEmail("test@example.com")
+ authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextUserKey, user)
+ client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
- testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user})
+ testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
}
}
-func TestRealtimeUserUpdateEvent(t *testing.T) {
+func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
- user1, err := testApp.Dao().FindUserByEmail("test@example.com")
+ authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextUserKey, user1)
+ client.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
- // refetch the user and change its email
- user2, err := testApp.Dao().FindUserByEmail("test@example.com")
+ // refetch the authRecord and change its email
+ authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
- user2.Email = "new@example.com"
+ authRecord2.SetEmail("new@example.com")
- testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user2})
+ testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
- clientUser, _ := client.Get(apis.ContextUserKey).(*models.User)
- if clientUser.Email != user2.Email {
- t.Fatalf("Expected user with email %q, got %q", user2.Email, clientUser.Email)
+ clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
+ if clientAuthRecord.Email() != authRecord2.Email() {
+ t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
}
}
@@ -276,7 +276,7 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
client.Set(apis.ContextAdminKey, admin1)
testApp.SubscriptionsBroker().Register(client)
- // refetch the user and change its email
+ // refetch the authRecord and change its email
admin2, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
@@ -287,6 +287,6 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if clientAdmin.Email != admin2.Email {
- t.Fatalf("Expected user with email %q, got %q", admin2.Email, clientAdmin.Email)
+ t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
}
}
diff --git a/apis/record_auth.go b/apis/record_auth.go
new file mode 100644
index 00000000..76ae9d63
--- /dev/null
+++ b/apis/record_auth.go
@@ -0,0 +1,477 @@
+package apis
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/labstack/echo/v5"
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/forms"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/resolvers"
+ "github.com/pocketbase/pocketbase/tokens"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/routine"
+ "github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "golang.org/x/oauth2"
+)
+
+// bindRecordAuthApi registers the auth record api endpoints and
+// the corresponding handlers.
+func bindRecordAuthApi(app core.App, rg *echo.Group) {
+ api := recordAuthApi{app: app}
+
+ subGroup := rg.Group(
+ "/collections/:collection",
+ ActivityLogger(app),
+ LoadCollectionContext(app, models.CollectionTypeAuth),
+ )
+
+ subGroup.GET("/auth-methods", api.authMethods)
+ subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
+ subGroup.POST("/auth-with-oauth2", api.authWithOAuth2) // allow anyone so that we can link the OAuth2 profile with the authenticated record
+ subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
+ subGroup.POST("/request-password-reset", api.requestPasswordReset)
+ subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
+ subGroup.POST("/request-verification", api.requestVerification)
+ subGroup.POST("/confirm-verification", api.confirmVerification)
+ subGroup.POST("/request-email-change", api.requestEmailChange, RequireSameContextRecordAuth())
+ subGroup.POST("/confirm-email-change", api.confirmEmailChange)
+ subGroup.GET("/records/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
+ subGroup.DELETE("/records/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
+}
+
+type recordAuthApi struct {
+ app core.App
+}
+
+func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record, meta any) error {
+ token, tokenErr := tokens.NewRecordAuthToken(api.app, authRecord)
+ if tokenErr != nil {
+ return NewBadRequestError("Failed to create auth token.", tokenErr)
+ }
+
+ event := &core.RecordAuthEvent{
+ HttpContext: c,
+ Record: authRecord,
+ Token: token,
+ Meta: meta,
+ }
+
+ return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
+ admin, _ := e.HttpContext.Get(ContextAdminKey).(*models.Admin)
+
+ // 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 {
+ requestData := exportRequestData(e.HttpContext)
+ requestData["auth"] = e.Record.PublicExport()
+ failed := api.app.Dao().ExpandRecord(
+ e.Record,
+ expands,
+ expandFetch(api.app.Dao(), admin != nil, requestData),
+ )
+ if len(failed) > 0 && api.app.IsDebug() {
+ log.Println("Failed to expand relations: ", failed)
+ }
+ }
+
+ result := map[string]any{
+ "token": e.Token,
+ "record": e.Record,
+ }
+
+ if e.Meta != nil {
+ result["meta"] = e.Meta
+ }
+
+ return e.HttpContext.JSON(http.StatusOK, result)
+ })
+}
+
+func (api *recordAuthApi) authRefresh(c echo.Context) error {
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if record == nil {
+ return NewNotFoundError("Missing auth record context.", nil)
+ }
+
+ return api.authResponse(c, record, nil)
+}
+
+type providerInfo struct {
+ Name string `json:"name"`
+ State string `json:"state"`
+ CodeVerifier string `json:"codeVerifier"`
+ CodeChallenge string `json:"codeChallenge"`
+ CodeChallengeMethod string `json:"codeChallengeMethod"`
+ AuthUrl string `json:"authUrl"`
+}
+
+func (api *recordAuthApi) authMethods(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ authOptions := collection.AuthOptions()
+
+ result := struct {
+ UsernamePassword bool `json:"usernamePassword"`
+ EmailPassword bool `json:"emailPassword"`
+ AuthProviders []providerInfo `json:"authProviders"`
+ }{
+ UsernamePassword: authOptions.AllowUsernameAuth,
+ EmailPassword: authOptions.AllowEmailAuth,
+ AuthProviders: []providerInfo{},
+ }
+
+ if !authOptions.AllowOAuth2Auth {
+ return c.JSON(http.StatusOK, result)
+ }
+
+ nameConfigMap := api.app.Settings().NamedAuthProviderConfigs()
+ for name, config := range nameConfigMap {
+ if !config.Enabled {
+ continue
+ }
+
+ provider, err := auth.NewProviderByName(name)
+ if err != nil {
+ if api.app.IsDebug() {
+ log.Println(err)
+ }
+ continue // skip provider
+ }
+
+ if err := config.SetupProvider(provider); err != nil {
+ if api.app.IsDebug() {
+ log.Println(err)
+ }
+ continue // skip provider
+ }
+
+ state := security.RandomString(30)
+ codeVerifier := security.RandomString(43)
+ codeChallenge := security.S256Challenge(codeVerifier)
+ codeChallengeMethod := "S256"
+ result.AuthProviders = append(result.AuthProviders, providerInfo{
+ Name: name,
+ State: state,
+ CodeVerifier: codeVerifier,
+ CodeChallenge: codeChallenge,
+ CodeChallengeMethod: codeChallengeMethod,
+ AuthUrl: provider.BuildAuthUrl(
+ state,
+ oauth2.SetAuthURLParam("code_challenge", codeChallenge),
+ oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
+ ) + "&redirect_uri=", // empty redirect_uri so that users can append their url
+ })
+ }
+
+ return c.JSON(http.StatusOK, result)
+}
+
+func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ if !collection.AuthOptions().AllowOAuth2Auth {
+ return NewBadRequestError("The collection is not configured to allow OAuth2 authentication.", nil)
+ }
+
+ var fallbackAuthRecord *models.Record
+
+ loggedAuthRecord, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if loggedAuthRecord != nil && loggedAuthRecord.Collection().Id == collection.Id {
+ fallbackAuthRecord = loggedAuthRecord
+ }
+
+ form := forms.NewRecordOAuth2Login(api.app, collection, fallbackAuthRecord)
+ if readErr := c.Bind(form); readErr != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ }
+
+ record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
+ return createForm.DrySubmit(func(txDao *daos.Dao) error {
+ requestData := exportRequestData(c)
+ requestData["data"] = form.CreateData
+
+ createRuleFunc := func(q *dbx.SelectQuery) error {
+ admin, _ := c.Get(ContextAdminKey).(*models.Admin)
+ if admin != nil {
+ return nil // either admin or the rule is empty
+ }
+
+ if collection.CreateRule == nil {
+ return errors.New("Only admins can create new accounts with OAuth2")
+ }
+
+ if *collection.CreateRule != "" {
+ resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true)
+ expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
+ if err != nil {
+ return err
+ }
+ resolver.UpdateQuery(q)
+ q.AndWhere(expr)
+ }
+
+ return nil
+ }
+
+ if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil {
+ return fmt.Errorf("Failed create rule constraint: %v", err)
+ }
+
+ return nil
+ })
+ })
+ if submitErr != nil {
+ return NewBadRequestError("Failed to authenticate.", submitErr)
+ }
+
+ return api.authResponse(c, record, authData)
+}
+
+func (api *recordAuthApi) authWithPassword(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ form := forms.NewRecordPasswordLogin(api.app, collection)
+ if readErr := c.Bind(form); readErr != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ }
+
+ record, submitErr := form.Submit()
+ if submitErr != nil {
+ return NewBadRequestError("Failed to authenticate.", submitErr)
+ }
+
+ return api.authResponse(c, record, nil)
+}
+
+func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ authOptions := collection.AuthOptions()
+ if !authOptions.AllowUsernameAuth && !authOptions.AllowEmailAuth {
+ return NewBadRequestError("The collection is not configured to allow password authentication.", nil)
+ }
+
+ form := forms.NewRecordPasswordResetRequest(api.app, collection)
+ if err := c.Bind(form); err != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
+ }
+
+ if err := form.Validate(); err != nil {
+ return NewBadRequestError("An error occurred while validating the form.", err)
+ }
+
+ // run in background because we don't need to show
+ // the result to the user (prevents users enumeration)
+ routine.FireAndForget(func() {
+ if err := form.Submit(); err != nil && api.app.IsDebug() {
+ log.Println(err)
+ }
+ })
+
+ return c.NoContent(http.StatusNoContent)
+}
+
+func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ form := forms.NewRecordPasswordResetConfirm(api.app, collection)
+ if readErr := c.Bind(form); readErr != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ }
+
+ record, submitErr := form.Submit()
+ if submitErr != nil {
+ return NewBadRequestError("Failed to set new password.", submitErr)
+ }
+
+ return api.authResponse(c, record, nil)
+}
+
+func (api *recordAuthApi) requestVerification(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ form := forms.NewRecordVerificationRequest(api.app, collection)
+ if err := c.Bind(form); err != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
+ }
+
+ if err := form.Validate(); err != nil {
+ return NewBadRequestError("An error occurred while validating the form.", err)
+ }
+
+ // run in background because we don't need to show
+ // the result to the user (prevents users enumeration)
+ routine.FireAndForget(func() {
+ if err := form.Submit(); err != nil && api.app.IsDebug() {
+ log.Println(err)
+ }
+ })
+
+ return c.NoContent(http.StatusNoContent)
+}
+
+func (api *recordAuthApi) confirmVerification(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ form := forms.NewRecordVerificationConfirm(api.app, collection)
+ if readErr := c.Bind(form); readErr != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ }
+
+ record, submitErr := form.Submit()
+ if submitErr != nil {
+ return NewBadRequestError("An error occurred while submitting the form.", submitErr)
+ }
+
+ // don't return an auth response if the collection doesn't allow email or username authentication
+ authOptions := collection.AuthOptions()
+ if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
+ return c.NoContent(http.StatusNoContent)
+ }
+
+ return api.authResponse(c, record, nil)
+}
+
+func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if record == nil {
+ return NewUnauthorizedError("The request requires valid auth record.", nil)
+ }
+
+ form := forms.NewRecordEmailChangeRequest(api.app, record)
+ if err := c.Bind(form); err != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
+ }
+
+ if err := form.Submit(); err != nil {
+ return NewBadRequestError("Failed to request email change.", err)
+ }
+
+ return c.NoContent(http.StatusNoContent)
+}
+
+func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ form := forms.NewRecordEmailChangeConfirm(api.app, collection)
+ if readErr := c.Bind(form); readErr != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ }
+
+ record, submitErr := form.Submit()
+ if submitErr != nil {
+ return NewBadRequestError("Failed to confirm email change.", submitErr)
+ }
+
+ return api.authResponse(c, record, nil)
+}
+
+func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ id := c.PathParam("id")
+ if id == "" {
+ return NewNotFoundError("", nil)
+ }
+
+ record, err := api.app.Dao().FindRecordById(collection.Id, id)
+ if err != nil || record == nil {
+ return NewNotFoundError("", err)
+ }
+
+ externalAuths, err := api.app.Dao().FindAllExternalAuthsByRecord(record)
+ if err != nil {
+ return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
+ }
+
+ event := &core.RecordListExternalAuthsEvent{
+ HttpContext: c,
+ Record: record,
+ ExternalAuths: externalAuths,
+ }
+
+ return api.app.OnRecordListExternalAuths().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
+ return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
+ })
+}
+
+func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
+ collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
+ if collection == nil {
+ return NewNotFoundError("Missing collection context.", nil)
+ }
+
+ id := c.PathParam("id")
+ provider := c.PathParam("provider")
+ if id == "" || provider == "" {
+ return NewNotFoundError("", nil)
+ }
+
+ record, err := api.app.Dao().FindRecordById(collection.Id, id)
+ if err != nil || record == nil {
+ return NewNotFoundError("", err)
+ }
+
+ externalAuth, err := api.app.Dao().FindExternalAuthByRecordAndProvider(record, provider)
+ if err != nil {
+ return NewNotFoundError("Missing external auth provider relation.", err)
+ }
+
+ event := &core.RecordUnlinkExternalAuthEvent{
+ HttpContext: c,
+ Record: record,
+ ExternalAuth: externalAuth,
+ }
+
+ handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
+ if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
+ return NewBadRequestError("Cannot unlink the external auth provider.", err)
+ }
+
+ return e.HttpContext.NoContent(http.StatusNoContent)
+ })
+
+ if handlerErr == nil {
+ api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
+ }
+
+ return handlerErr
+}
diff --git a/apis/record_auth_test.go b/apis/record_auth_test.go
new file mode 100644
index 00000000..97dc98a3
--- /dev/null
+++ b/apis/record_auth_test.go
@@ -0,0 +1,1115 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestRecordAuthMethodsList(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "missing collection",
+ Method: http.MethodGet,
+ Url: "/api/collections/missing/auth-methods",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "non auth collection",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/auth-methods",
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth collection with all auth methods allowed",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/auth-methods",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"usernamePassword":true`,
+ `"emailPassword":true`,
+ `"authProviders":[{`,
+ `"name":"gitlab"`,
+ `"state":`,
+ `"codeVerifier":`,
+ `"codeChallenge":`,
+ `"codeChallengeMethod":`,
+ `"authUrl":`,
+ `redirect_uri="`, // ensures that the redirect_uri is the last url param
+ },
+ },
+ {
+ Name: "auth collection with only email/password auth allowed",
+ Method: http.MethodGet,
+ Url: "/api/collections/clients/auth-methods",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"usernamePassword":false`,
+ `"emailPassword":true`,
+ `"authProviders":[]`,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(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,
+ Url: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{"identity`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "empty body params",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{"identity":"","password":""}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"identity":{`,
+ `"password":{`,
+ },
+ },
+
+ // username
+ {
+ Name: "invalid username and valid password",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"invalid",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "valid username and invalid password",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test2_username",
+ "password":"invalid"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "valid username and valid password in restricted collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/nologin/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test_username",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "valid username and valid password in allowed collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test2_username",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"id":"oap640cot4yru2s"`,
+ `"email":"test2@example.com"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ },
+ },
+
+ // email
+ {
+ Name: "invalid email and valid password",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"missing@example.com",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "valid email and invalid password",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test@example.com",
+ "password":"invalid"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "valid email and valid password in restricted collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/nologin/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "valid email and valid password in allowed collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-with-password",
+ 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{
+ "OnRecordAuthRequest": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthRefresh(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-refresh",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "admin",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-refresh",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record + not an auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/auth-refresh",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record + different auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/clients/auth-refresh?expand=rel,missing",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record + same auth collection as the token",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/auth-refresh?expand=rel,missing",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"emailVisibility":false`,
+ `"email":"test@example.com"`, // the owner can always view their email address
+ `"expand":`,
+ `"rel":`,
+ `"id":"llvuca81nly1qls"`,
+ },
+ NotExpectedContent: []string{
+ `"missing":`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthRequestPasswordReset(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/request-password-reset",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
+ },
+ {
+ Name: "invalid data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "missing auth record",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email":"missing@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ },
+ {
+ Name: "existing auth record",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnModelBeforeUpdate": 1,
+ "OnModelAfterUpdate": 1,
+ "OnMailerBeforeRecordResetPasswordSend": 1,
+ "OnMailerAfterRecordResetPasswordSend": 1,
+ },
+ },
+ {
+ Name: "existing auth record (after already sent)",
+ Method: http.MethodPost,
+ Url: "/api/collections/clients/request-password-reset",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ // simulate recent password request sent
+ authRecord, err := app.Dao().FindFirstRecordByData("clients", "email", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ authRecord.SetLastResetSentAt(types.NowDateTime())
+ dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
+ if err := dao.Save(authRecord); err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ Name: "existing auth record in a collection with disabled password login",
+ Method: http.MethodPost,
+ Url: "/api/collections/nologin/request-password-reset",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthConfirmPasswordReset(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"password":{"code":"validation_required"`,
+ `"passwordConfirm":{"code":"validation_required"`,
+ `"token":{"code":"validation_required"`,
+ },
+ },
+ {
+ Name: "invalid data format",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{"password`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "expired token and invalid password",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY",
+ "password":"1234567",
+ "passwordConfirm":"7654321"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_invalid_token"`,
+ `"password":{"code":"validation_length_out_of_range"`,
+ `"passwordConfirm":{"code":"validation_values_mismatch"`,
+ },
+ },
+ {
+ Name: "non auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/confirm-password-reset?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
+ "password":"12345678",
+ "passwordConfirm":"12345678"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "different auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/clients/confirm-password-reset?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
+ "password":"12345678",
+ "passwordConfirm":"12345678"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{"token":{"code":"validation_token_collection_mismatch"`,
+ },
+ },
+ {
+ Name: "valid token and data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-password-reset?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
+ "password":"12345678",
+ "passwordConfirm":"12345678"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"email":"test@example.com"`,
+ `"expand":`,
+ `"rel":`,
+ `"id":"llvuca81nly1qls"`,
+ },
+ NotExpectedContent: []string{
+ `"missing":`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthRequestVerification(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/request-verification",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-verification",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
+ },
+ {
+ Name: "invalid data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "missing auth record",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"missing@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ },
+ {
+ Name: "already verified auth record",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test2@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ },
+ {
+ Name: "existing auth record",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnModelBeforeUpdate": 1,
+ "OnModelAfterUpdate": 1,
+ "OnMailerBeforeRecordVerificationSend": 1,
+ "OnMailerAfterRecordVerificationSend": 1,
+ },
+ },
+ {
+ Name: "existing auth record (after already sent)",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ // simulate recent verification sent
+ authRecord, err := app.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ authRecord.SetLastVerificationSentAt(types.NowDateTime())
+ dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
+ if err := dao.Save(authRecord); err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthConfirmVerification(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_required"`,
+ },
+ },
+ {
+ Name: "invalid data format",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(`{"password`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "expired token",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.Avbt9IP8sBisVz_2AGrlxLDvangVq4PhL2zqQVYLKlE"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_invalid_token"`,
+ },
+ },
+ {
+ Name: "non auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "different auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/clients/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{"token":{"code":"validation_token_collection_mismatch"`,
+ },
+ },
+ {
+ Name: "valid token",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"email":"test@example.com"`,
+ `"verified":true`,
+ `"expand":`,
+ `"rel":`,
+ `"id":"llvuca81nly1qls"`,
+ },
+ NotExpectedContent: []string{
+ `"missing":`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ },
+ },
+ {
+ Name: "valid token (already verified)",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"oap640cot4yru2s"`,
+ `"email":"test2@example.com"`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ `"expand":`, // no rel id attached
+ `"missing":`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ },
+ },
+ {
+ Name: "valid verification token from a collection without allowed login",
+ Method: http.MethodPost,
+ Url: "/api/collections/nologin/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.coREjeTDS3_Go7DP1nxHtevIX5rujwHU-_mRB6oOm3w"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedContent: []string{},
+ ExpectedEvents: map[string]int{
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthRequestEmailChange(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "admin authentication",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "record authentication but from different auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/clients/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "invalid data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":`,
+ `"newEmail":{"code":"validation_required"`,
+ },
+ },
+ {
+ Name: "valid data (existing email)",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"test2@example.com"}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":`,
+ `"newEmail":{"code":"validation_record_email_exists"`,
+ },
+ },
+ {
+ Name: "valid data (new email)",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnMailerBeforeRecordChangeEmailSend": 1,
+ "OnMailerAfterRecordChangeEmailSend": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthConfirmEmailChange(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/confirm-email-change",
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":`,
+ `"token":{"code":"validation_required"`,
+ `"password":{"code":"validation_required"`,
+ },
+ },
+ {
+ Name: "invalid data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{"token`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "expired token and correct password",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjE2NDA5OTE2NjF9.D20jh5Ss7SZyXRUXjjEyLCYo9Ky0N5cE5dKB_MGJ8G8",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{`,
+ `"code":"validation_invalid_token"`,
+ },
+ },
+ {
+ Name: "valid token and incorrect password",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
+ "password":"1234567891"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"password":{`,
+ `"code":"validation_invalid_password"`,
+ },
+ },
+ {
+ Name: "valid token and correct password",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"email":"change@example.com"`,
+ `"verified":true`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ },
+ },
+ {
+ Name: "valid token and correct password in different auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/clients/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_token_collection_mismatch"`,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthListExternalsAuths(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "admin + nonexisting record id",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/missing/external-auths",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "admin + existing record id and no external auths",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/oap640cot4yru2s/external-auths",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{`[]`},
+ ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1},
+ },
+ {
+ Name: "admin + existing user id and 2 external auths",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"clmflokuq1xl341"`,
+ `"id":"dlmflokuq1xl342"`,
+ `"recordId":"4q1xlclmfloku33"`,
+ `"collectionId":"_pb_users_auth_"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1},
+ },
+ {
+ Name: "auth record + trying to list another user external auths",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record + trying to list another user external auths from different collection",
+ Method: http.MethodGet,
+ Url: "/api/collections/clients/records/o1y0dd0spd786md/external-auths",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record + owner without external auths",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/oap640cot4yru2s/external-auths",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{`[]`},
+ ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1},
+ },
+ {
+ Name: "authorized as user - owner with 2 external auths",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"clmflokuq1xl341"`,
+ `"id":"dlmflokuq1xl342"`,
+ `"recordId":"4q1xlclmfloku33"`,
+ `"collectionId":"_pb_users_auth_"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodDelete,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "admin - nonexisting recod id",
+ Method: http.MethodDelete,
+ Url: "/api/collections/users/records/missing/external-auths/google",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "admin - nonlinked provider",
+ Method: http.MethodDelete,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/facebook",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "admin - linked provider",
+ Method: http.MethodDelete,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 204,
+ ExpectedContent: []string{},
+ ExpectedEvents: map[string]int{
+ "OnModelAfterDelete": 1,
+ "OnModelBeforeDelete": 1,
+ "OnRecordAfterUnlinkExternalAuthRequest": 1,
+ "OnRecordBeforeUnlinkExternalAuthRequest": 1,
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
+ if err != nil {
+ t.Fatal(err)
+ }
+ auth, _ := app.Dao().FindExternalAuthByRecordAndProvider(record, "google")
+ if auth != nil {
+ t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
+ }
+ },
+ },
+ {
+ Name: "auth record - trying to unlink another user external auth",
+ Method: http.MethodDelete,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record - trying to unlink another user external auth from different collection",
+ Method: http.MethodDelete,
+ Url: "/api/collections/clients/records/o1y0dd0spd786md/external-auths/google",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record - owner with existing external auth",
+ Method: http.MethodDelete,
+ Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 204,
+ ExpectedContent: []string{},
+ ExpectedEvents: map[string]int{
+ "OnModelAfterDelete": 1,
+ "OnModelBeforeDelete": 1,
+ "OnRecordAfterUnlinkExternalAuthRequest": 1,
+ "OnRecordBeforeUnlinkExternalAuthRequest": 1,
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
+ if err != nil {
+ t.Fatal(err)
+ }
+ auth, _ := app.Dao().FindExternalAuthByRecordAndProvider(record, "google")
+ if auth != nil {
+ t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
+ }
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record.go b/apis/record_crud.go
similarity index 63%
rename from apis/record.go
rename to apis/record_crud.go
index 8493ac8e..ea8790f7 100644
--- a/apis/record.go
+++ b/apis/record_crud.go
@@ -13,27 +13,27 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
const expandQueryParam = "expand"
-// BindRecordApi registers the record api endpoints and the corresponding handlers.
-func BindRecordApi(app core.App, rg *echo.Group) {
+// bindRecordCrudApi registers the record crud api endpoints and
+// the corresponding handlers.
+func bindRecordCrudApi(app core.App, rg *echo.Group) {
api := recordApi{app: app}
subGroup := rg.Group(
- "/collections/:collection/records",
+ "/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app),
)
- subGroup.GET("", api.list)
- subGroup.POST("", api.create)
- subGroup.GET("/:id", api.view)
- subGroup.PATCH("/:id", api.update)
- subGroup.DELETE("/:id", api.delete)
+ subGroup.GET("/records", api.list)
+ subGroup.POST("/records", api.create)
+ subGroup.GET("/records/:id", api.view)
+ subGroup.PATCH("/records/:id", api.update)
+ subGroup.DELETE("/records/:id", api.delete)
}
type recordApi struct {
@@ -43,13 +43,13 @@ type recordApi struct {
func (api *recordApi) list(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
// forbid users and guests to query special filter/sort fields
@@ -57,13 +57,18 @@ func (api *recordApi) list(c echo.Context) error {
return err
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
- fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ fieldsResolver := resolvers.NewRecordFieldResolver(
+ api.app.Dao(),
+ collection,
+ requestData,
+ // hidden fields are searchable only by admins
+ admin != nil,
+ )
searchProvider := search.NewProvider(fieldsResolver).
- Query(api.app.Dao().RecordQuery(collection)).
- CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
+ Query(api.app.Dao().RecordQuery(collection))
if admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
@@ -72,7 +77,7 @@ func (api *recordApi) list(c echo.Context) error {
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
if err != nil {
- return rest.NewBadRequestError("Invalid filter parameters.", err)
+ return NewBadRequestError("Invalid filter parameters.", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
@@ -83,13 +88,22 @@ func (api *recordApi) list(c echo.Context) error {
failed := api.app.Dao().ExpandRecords(
records,
expands,
- api.expandFunc(c, requestData),
+ expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
+ if collection.IsAuth() {
+ err := autoIgnoreAuthRecordsEmailVisibility(
+ api.app.Dao(), records, admin != nil, requestData,
+ )
+ if err != nil && api.app.IsDebug() {
+ log.Println("IgnoreEmailVisibility failure:", err)
+ }
+ }
+
result.Items = records
event := &core.RecordsListEvent{
@@ -107,25 +121,25 @@ func (api *recordApi) list(c echo.Context) error {
func (api *recordApi) view(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ViewRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
@@ -136,21 +150,30 @@ func (api *recordApi) view(c echo.Context) error {
return nil
}
- record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
+ record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
- return rest.NewNotFoundError("", fetchErr)
+ return NewNotFoundError("", fetchErr)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
record,
strings.Split(c.QueryParam(expandQueryParam), ","),
- api.expandFunc(c, requestData),
+ expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
+ if collection.IsAuth() {
+ err := autoIgnoreAuthRecordsEmailVisibility(
+ api.app.Dao(), []*models.Record{record}, admin != nil, requestData,
+ )
+ if err != nil && api.app.IsDebug() {
+ log.Println("IgnoreEmailVisibility failure:", err)
+ }
+ }
+
event := &core.RecordViewEvent{
HttpContext: c,
Record: record,
@@ -164,21 +187,27 @@ func (api *recordApi) view(c echo.Context) error {
func (api *recordApi) create(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.CreateRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
+
+ hasFullManageAccess := admin != nil
// temporary save the record and check it against the create rule
- if admin == nil && collection.CreateRule != nil && *collection.CreateRule != "" {
- ruleFunc := func(q *dbx.SelectQuery) error {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ if admin == nil && collection.CreateRule != nil {
+ createRuleFunc := func(q *dbx.SelectQuery) error {
+ if *collection.CreateRule == "" {
+ return nil // no create rule to resolve
+ }
+
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -190,25 +219,32 @@ func (api *recordApi) create(c echo.Context) error {
testRecord := models.NewRecord(collection)
testForm := forms.NewRecordUpsert(api.app, testRecord)
- if err := testForm.LoadData(c.Request()); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ 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 {
- _, fetchErr := txDao.FindRecordById(collection, testRecord.Id, ruleFunc)
- return fetchErr
+ foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
+ if err != nil {
+ return err
+ }
+ hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
+ return nil
})
+
if testErr != nil {
- return rest.NewBadRequestError("Failed to create record.", testErr)
+ return NewBadRequestError("Failed to create record.", fmt.Errorf("DrySubmit error: %v", testErr))
}
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(api.app, record)
+ form.SetFullManageAccess(hasFullManageAccess)
// load request
- if err := form.LoadData(c.Request()); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ if err := form.LoadRequest(c.Request(), ""); err != nil {
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordCreateEvent{
@@ -221,19 +257,28 @@ func (api *recordApi) create(c echo.Context) error {
return func() error {
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to create record.", err)
+ return NewBadRequestError("Failed to create record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
- api.expandFunc(e.HttpContext, requestData),
+ expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
+ if collection.IsAuth() {
+ err := autoIgnoreAuthRecordsEmailVisibility(
+ api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData,
+ )
+ if err != nil && api.app.IsDebug() {
+ log.Println("IgnoreEmailVisibility failure:", err)
+ }
+ }
+
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -249,25 +294,25 @@ func (api *recordApi) create(c echo.Context) error {
func (api *recordApi) update(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.UpdateRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -279,16 +324,17 @@ func (api *recordApi) update(c echo.Context) error {
}
// fetch record
- record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
+ record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
- return rest.NewNotFoundError("", fetchErr)
+ return NewNotFoundError("", fetchErr)
}
form := forms.NewRecordUpsert(api.app, record)
+ form.SetFullManageAccess(admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
// load request
- if err := form.LoadData(c.Request()); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ if err := form.LoadRequest(c.Request(), ""); err != nil {
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordUpdateEvent{
@@ -301,19 +347,28 @@ func (api *recordApi) update(c echo.Context) error {
return func() error {
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to update record.", err)
+ return NewBadRequestError("Failed to update record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
- api.expandFunc(e.HttpContext, requestData),
+ expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
+ if collection.IsAuth() {
+ err := autoIgnoreAuthRecordsEmailVisibility(
+ api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData,
+ )
+ if err != nil && api.app.IsDebug() {
+ log.Println("IgnoreEmailVisibility failure:", err)
+ }
+ }
+
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -329,25 +384,25 @@ func (api *recordApi) update(c echo.Context) error {
func (api *recordApi) delete(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.DeleteRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
return err
@@ -358,9 +413,9 @@ func (api *recordApi) delete(c echo.Context) error {
return nil
}
- record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
+ record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
- return rest.NewNotFoundError("", fetchErr)
+ return NewNotFoundError("", fetchErr)
}
event := &core.RecordDeleteEvent{
@@ -371,7 +426,7 @@ func (api *recordApi) delete(c echo.Context) error {
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
// delete the record
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
- return rest.NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
+ return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
@@ -384,29 +439,6 @@ func (api *recordApi) delete(c echo.Context) error {
return handlerErr
}
-func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
- result := map[string]any{}
- queryParams := map[string]any{}
- bodyData := map[string]any{}
- method := c.Request().Method
-
- echo.BindQueryParams(c, &queryParams)
-
- rest.BindBody(c, &bodyData)
-
- result["method"] = method
- result["query"] = queryParams
- result["data"] = bodyData
- result["user"] = nil
-
- loggedUser, _ := c.Get(ContextUserKey).(*models.User)
- if loggedUser != nil {
- result["user"], _ = loggedUser.AsMap()
- }
-
- return result
-}
-
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
@@ -418,37 +450,9 @@ func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
- return rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
+ return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
-
-func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
-
- return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
- return api.app.Dao().FindRecordsByIds(relCollection, relIds, func(q *dbx.SelectQuery) error {
- if admin != nil {
- return nil // admin can access everything
- }
-
- if relCollection.ViewRule == nil {
- return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
- }
-
- if *relCollection.ViewRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), relCollection, requestData)
- expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
- if err != nil {
- return err
- }
- resolver.UpdateQuery(q)
- q.AndWhere(expr)
- }
-
- return nil
- })
- }
-}
diff --git a/apis/record_crud_test.go b/apis/record_crud_test.go
new file mode 100644
index 00000000..4e47b7a8
--- /dev/null
+++ b/apis/record_crud_test.go
@@ -0,0 +1,1725 @@
+package apis_test
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordCrudList(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "missing collection",
+ Method: http.MethodGet,
+ Url: "/api/collections/missing/records",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "unauthenticated trying to access nil rule collection (aka. need admin auth)",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authenticated record trying to access nil rule collection (aka. need admin auth)",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "public collection but with admin only filter/sort (aka. @collection)",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "public collection but with ENCODED admin only filter/sort (aka. @collection)",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo2/records?filter=%40collection.demo2.title%3D%27test1%27",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "public collection",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo2/records",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":3`,
+ `"items":[{`,
+ `"id":"0yxhwia2amd8gec"`,
+ `"id":"achvryl401bhse3"`,
+ `"id":"llvuca81nly1qls"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "public collection (using the collection id)",
+ Method: http.MethodGet,
+ Url: "/api/collections/sz5l5z67tg7gku0/records",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":3`,
+ `"items":[{`,
+ `"id":"0yxhwia2amd8gec"`,
+ `"id":"achvryl401bhse3"`,
+ `"id":"llvuca81nly1qls"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "authorized as admin trying to access nil rule collection (aka. need admin auth)",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":3`,
+ `"items":[{`,
+ `"id":"al1h9ijdeojtsjy"`,
+ `"id":"84nmscqy84lsi1t"`,
+ `"id":"imy661ixudk5izi"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "valid query params",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records?filter=text~'test'&sort=-bool",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":2`,
+ `"items":[{`,
+ `"id":"al1h9ijdeojtsjy"`,
+ `"id":"84nmscqy84lsi1t"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "invalid filter",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records?filter=invalid~'test'",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "expand relations",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":2`,
+ `"totalPages":2`,
+ `"totalItems":3`,
+ `"items":[{`,
+ `"collectionName":"demo1"`,
+ `"id":"84nmscqy84lsi1t"`,
+ `"id":"al1h9ijdeojtsjy"`,
+ `"expand":{`,
+ `"rel_one":""`,
+ `"rel_one":{"`,
+ `"rel_many":[{`,
+ `"rel":{`,
+ `"rel":""`,
+ `"json":[1,2,3]`,
+ `"select_many":["optionB","optionC"]`,
+ `"select_many":["optionB"]`,
+ // subrel items
+ `"id":"0yxhwia2amd8gec"`,
+ `"id":"llvuca81nly1qls"`,
+ // email visibility should be ignored for admins even in expanded rels
+ `"email":"test@example.com"`,
+ `"email":"test2@example.com"`,
+ `"email":"test3@example.com"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "authenticated record model that DOESN'T match the collection list rule",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo3/records",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "authenticated record that matches the collection list rule",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo3/records",
+ RequestHeaders: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":4`,
+ `"items":[{`,
+ `"id":"1tmknxy2868d869"`,
+ `"id":"lcl9d87w22ml6jy"`,
+ `"id":"7nwo8tuiatetxdm"`,
+ `"id":"mk5fmymtx4wsprk"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+
+ // auth collection checks
+ // -----------------------------------------------------------
+ {
+ Name: "check email visibility as guest",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":3`,
+ `"items":[{`,
+ `"id":"phhq3wr65cap535"`,
+ `"id":"dc49k6jgejn40h3"`,
+ `"id":"oos036e9xvqeexy"`,
+ `"email":"test2@example.com"`,
+ `"emailVisibility":true`,
+ `"emailVisibility":false`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ `"email":"test@example.com"`,
+ `"email":"test3@example.com"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "check email visibility as any authenticated record",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records",
+ RequestHeaders: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":3`,
+ `"items":[{`,
+ `"id":"phhq3wr65cap535"`,
+ `"id":"dc49k6jgejn40h3"`,
+ `"id":"oos036e9xvqeexy"`,
+ `"email":"test2@example.com"`,
+ `"emailVisibility":true`,
+ `"emailVisibility":false`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ `"email":"test@example.com"`,
+ `"email":"test3@example.com"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "check email visibility as manage auth record",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/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":3`,
+ `"items":[{`,
+ `"id":"phhq3wr65cap535"`,
+ `"id":"dc49k6jgejn40h3"`,
+ `"id":"oos036e9xvqeexy"`,
+ `"email":"test@example.com"`,
+ `"email":"test2@example.com"`,
+ `"email":"test3@example.com"`,
+ `"emailVisibility":true`,
+ `"emailVisibility":false`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "check email visibility as admin",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":3`,
+ `"items":[{`,
+ `"id":"phhq3wr65cap535"`,
+ `"id":"dc49k6jgejn40h3"`,
+ `"id":"oos036e9xvqeexy"`,
+ `"email":"test@example.com"`,
+ `"email":"test2@example.com"`,
+ `"email":"test3@example.com"`,
+ `"emailVisibility":true`,
+ `"emailVisibility":false`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ {
+ Name: "check self email visibility resolver",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records",
+ RequestHeaders: map[string]string{
+ // nologin, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoia3B2NzA5c2sybHFicWs4IiwiZXhwIjoyMjA4OTg1MjYxfQ.DOYSon3x1-C0hJbwjEU6dp2-6oLeEa8bOlkyP1CinyM",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":3`,
+ `"items":[{`,
+ `"id":"phhq3wr65cap535"`,
+ `"id":"dc49k6jgejn40h3"`,
+ `"id":"oos036e9xvqeexy"`,
+ `"email":"test2@example.com"`,
+ `"email":"test@example.com"`,
+ `"emailVisibility":true`,
+ `"emailVisibility":false`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ `"email":"test3@example.com"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudView(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "missing collection",
+ Method: http.MethodGet,
+ Url: "/api/collections/missing/records/0yxhwia2amd8gec",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "missing record",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo2/records/missing",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "unauthenticated trying to access nil rule collection (aka. need admin auth)",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authenticated record trying to access nil rule collection (aka. need admin auth)",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authenticated record that doesn't match the collection view rule",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/bgs820n361vj1qd",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "public collection view",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"0yxhwia2amd8gec"`,
+ `"collectionName":"demo2"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ {
+ Name: "public collection view (using the collection id)",
+ Method: http.MethodGet,
+ Url: "/api/collections/sz5l5z67tg7gku0/records/0yxhwia2amd8gec",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"0yxhwia2amd8gec"`,
+ `"collectionName":"demo2"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ {
+ Name: "authorized as admin trying to access nil rule collection view (aka. need admin auth)",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"imy661ixudk5izi"`,
+ `"collectionName":"demo1"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ {
+ Name: "authenticated record that does match the collection view rule",
+ Method: http.MethodGet,
+ Url: "/api/collections/users/records/4q1xlclmfloku33",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"4q1xlclmfloku33"`,
+ `"collectionName":"users"`,
+ // owners can always view their email
+ `"emailVisibility":false`,
+ `"email":"test@example.com"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ {
+ Name: "expand relations",
+ Method: http.MethodGet,
+ Url: "/api/collections/demo1/records/al1h9ijdeojtsjy?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"al1h9ijdeojtsjy"`,
+ `"collectionName":"demo1"`,
+ `"rel_many":[{`,
+ `"rel_one":{`,
+ `"collectionName":"users"`,
+ `"id":"bgs820n361vj1qd"`,
+ `"expand":{"rel":{`,
+ `"id":"0yxhwia2amd8gec"`,
+ `"collectionName":"demo2"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+
+ // auth collection checks
+ // -----------------------------------------------------------
+ {
+ Name: "check email visibility as guest",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records/oos036e9xvqeexy",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"oos036e9xvqeexy"`,
+ `"emailVisibility":false`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ `"email":"test3@example.com"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ {
+ Name: "check email visibility as any authenticated record",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records/oos036e9xvqeexy",
+ RequestHeaders: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"oos036e9xvqeexy"`,
+ `"emailVisibility":false`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ `"email":"test3@example.com"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ {
+ Name: "check email visibility as manage auth record",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records/oos036e9xvqeexy",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"oos036e9xvqeexy"`,
+ `"emailVisibility":false`,
+ `"email":"test3@example.com"`,
+ `"verified":true`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ {
+ Name: "check email visibility as admin",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records/oos036e9xvqeexy",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"oos036e9xvqeexy"`,
+ `"emailVisibility":false`,
+ `"email":"test3@example.com"`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ {
+ Name: "check self email visibility resolver",
+ Method: http.MethodGet,
+ Url: "/api/collections/nologin/records/dc49k6jgejn40h3",
+ RequestHeaders: map[string]string{
+ // nologin, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoia3B2NzA5c2sybHFicWs4IiwiZXhwIjoyMjA4OTg1MjYxfQ.DOYSon3x1-C0hJbwjEU6dp2-6oLeEa8bOlkyP1CinyM",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"dc49k6jgejn40h3"`,
+ `"email":"test@example.com"`,
+ `"emailVisibility":false`,
+ `"verified":false`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudDelete(t *testing.T) {
+ ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
+ storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
+
+ entries, _ := os.ReadDir(storageDir)
+ if len(entries) != 0 {
+ t.Errorf("Expected empty/deleted dir, found %d", len(entries))
+ }
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "missing collection",
+ Method: http.MethodDelete,
+ Url: "/api/collections/missing/records/0yxhwia2amd8gec",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "missing record",
+ Method: http.MethodDelete,
+ Url: "/api/collections/demo2/records/missing",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "unauthenticated trying to delete nil rule collection (aka. need admin auth)",
+ Method: http.MethodDelete,
+ Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authenticated record trying to delete nil rule collection (aka. need admin auth)",
+ Method: http.MethodDelete,
+ Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authenticated record that doesn't match the collection delete rule",
+ Method: http.MethodDelete,
+ Url: "/api/collections/users/records/bgs820n361vj1qd",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "public collection record delete",
+ Method: http.MethodDelete,
+ Url: "/api/collections/nologin/records/dc49k6jgejn40h3",
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnModelAfterDelete": 1,
+ "OnModelBeforeDelete": 1,
+ "OnRecordAfterDeleteRequest": 1,
+ "OnRecordBeforeDeleteRequest": 1,
+ },
+ },
+ {
+ Name: "public collection record delete (using the collection id as identifier)",
+ Method: http.MethodDelete,
+ Url: "/api/collections/kpv709sk2lqbqk8/records/dc49k6jgejn40h3",
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnModelAfterDelete": 1,
+ "OnModelBeforeDelete": 1,
+ "OnRecordAfterDeleteRequest": 1,
+ "OnRecordBeforeDeleteRequest": 1,
+ },
+ },
+ {
+ Name: "authorized as admin trying to delete nil rule collection view (aka. need admin auth)",
+ Method: http.MethodDelete,
+ Url: "/api/collections/clients/records/o1y0dd0spd786md",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnModelAfterDelete": 1,
+ "OnModelBeforeDelete": 1,
+ "OnRecordAfterDeleteRequest": 1,
+ "OnRecordBeforeDeleteRequest": 1,
+ },
+ },
+ {
+ Name: "authenticated record that does 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",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnModelAfterDelete": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeDelete": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnRecordAfterDeleteRequest": 1,
+ "OnRecordBeforeDeleteRequest": 1,
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ ensureDeletedFiles(app, "_pb_users_auth_", "4q1xlclmfloku33")
+
+ // check if all the external auths records were deleted
+ collection, _ := app.Dao().FindCollectionByNameOrId("users")
+ record := models.NewRecord(collection)
+ record.Id = "4q1xlclmfloku33"
+ externalAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
+ if err != nil {
+ t.Errorf("Failed to fetch external auths: %v", err)
+ }
+ if len(externalAuths) > 0 {
+ t.Errorf("Expected the linked external auths to be deleted, got %d", len(externalAuths))
+ }
+ },
+ },
+
+ // cascade delete 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": 1, // self_rel_many update of test1 record
+ "OnModelBeforeDelete": 1, // rel_one_cascade of test1 record
+ },
+ },
+ {
+ Name: "delete a record with non-cascade references",
+ Method: http.MethodDelete,
+ Url: "/api/collections/demo3/records/1tmknxy2868d869",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnModelBeforeDelete": 1,
+ "OnModelAfterDelete": 1,
+ "OnModelBeforeUpdate": 2,
+ "OnModelAfterUpdate": 2,
+ "OnRecordBeforeDeleteRequest": 1,
+ "OnRecordAfterDeleteRequest": 1,
+ },
+ },
+ {
+ Name: "delete a record with cascade references",
+ Method: http.MethodDelete,
+ Url: "/api/collections/users/records/oap640cot4yru2s",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "OnModelBeforeDelete": 2,
+ "OnModelAfterDelete": 2,
+ "OnModelBeforeUpdate": 2,
+ "OnModelAfterUpdate": 2,
+ "OnRecordBeforeDeleteRequest": 1,
+ "OnRecordAfterDeleteRequest": 1,
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ recId := "84nmscqy84lsi1t"
+ rec, _ := app.Dao().FindRecordById("demo1", recId, nil)
+ if rec != nil {
+ t.Errorf("Expected record %s to be cascade deleted", recId)
+ }
+ ensureDeletedFiles(app, "wsmn24bux7wo113", recId)
+ ensureDeletedFiles(app, "_pb_users_auth_", "oap640cot4yru2s")
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudCreate(t *testing.T) {
+ formData, mp, err := tests.MockMultipartData(map[string]string{
+ "title": "title_test",
+ }, "files")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "missing collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/missing/records",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "guest trying to access nil-rule collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/records",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record trying to access nil-rule collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo1/records",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "submit invalid format",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo2/records",
+ Body: strings.NewReader(`{"`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "submit nil body",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo2/records",
+ Body: nil,
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "guest submit in public collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo2/records",
+ Body: strings.NewReader(`{"title":"new"}`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":`,
+ `"title":"new"`,
+ `"active":false`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeCreateRequest": 1,
+ "OnRecordAfterCreateRequest": 1,
+ "OnModelBeforeCreate": 1,
+ "OnModelAfterCreate": 1,
+ },
+ },
+ {
+ Name: "guest trying to submit in restricted collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo3/records",
+ Body: strings.NewReader(`{"title":"test123"}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record submit in restricted collection (rule failure check)",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo3/records",
+ Body: strings.NewReader(`{"title":"test123"}`),
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record submit in restricted collection (rule pass check) + expand relations",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo4/records?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
+ Body: strings.NewReader(`{
+ "title":"test123",
+ "rel_one_no_cascade":"mk5fmymtx4wsprk",
+ "rel_one_no_cascade_required":"7nwo8tuiatetxdm",
+ "rel_one_cascade":"mk5fmymtx4wsprk",
+ "rel_many_no_cascade":"mk5fmymtx4wsprk",
+ "rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"],
+ "rel_many_cascade":"lcl9d87w22ml6jy"
+ }`),
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":`,
+ `"title":"test123"`,
+ `"rel_one_no_cascade":"mk5fmymtx4wsprk"`,
+ `"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`,
+ `"rel_one_cascade":"mk5fmymtx4wsprk"`,
+ `"rel_many_no_cascade":["mk5fmymtx4wsprk"]`,
+ `"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"]`,
+ `"rel_many_cascade":["lcl9d87w22ml6jy"]`,
+ },
+ NotExpectedContent: []string{
+ // the users auth records don't have access to view the demo3 expands
+ `"expand":{`,
+ `"missing"`,
+ `"id":"mk5fmymtx4wsprk"`,
+ `"id":"7nwo8tuiatetxdm"`,
+ `"id":"lcl9d87w22ml6jy"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeCreateRequest": 1,
+ "OnRecordAfterCreateRequest": 1,
+ "OnModelBeforeCreate": 1,
+ "OnModelAfterCreate": 1,
+ },
+ },
+ {
+ Name: "admin submit in restricted collection (rule skip check) + expand relations",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo4/records?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
+ Body: strings.NewReader(`{
+ "title":"test123",
+ "rel_one_no_cascade":"mk5fmymtx4wsprk",
+ "rel_one_no_cascade_required":"7nwo8tuiatetxdm",
+ "rel_one_cascade":"mk5fmymtx4wsprk",
+ "rel_many_no_cascade":"mk5fmymtx4wsprk",
+ "rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"],
+ "rel_many_cascade":"lcl9d87w22ml6jy"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":`,
+ `"title":"test123"`,
+ `"rel_one_no_cascade":"mk5fmymtx4wsprk"`,
+ `"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`,
+ `"rel_one_cascade":"mk5fmymtx4wsprk"`,
+ `"rel_many_no_cascade":["mk5fmymtx4wsprk"]`,
+ `"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"]`,
+ `"rel_many_cascade":["lcl9d87w22ml6jy"]`,
+ `"expand":{`,
+ `"id":"mk5fmymtx4wsprk"`,
+ `"id":"7nwo8tuiatetxdm"`,
+ `"id":"lcl9d87w22ml6jy"`,
+ },
+ NotExpectedContent: []string{
+ `"missing"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeCreateRequest": 1,
+ "OnRecordAfterCreateRequest": 1,
+ "OnModelBeforeCreate": 1,
+ "OnModelAfterCreate": 1,
+ },
+ },
+ {
+ Name: "submit via multipart form data",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo3/records",
+ Body: formData,
+ RequestHeaders: map[string]string{
+ "Content-Type": mp.FormDataContentType(),
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"`,
+ `"title":"title_test"`,
+ `"files":["`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeCreateRequest": 1,
+ "OnRecordAfterCreateRequest": 1,
+ "OnModelBeforeCreate": 1,
+ "OnModelAfterCreate": 1,
+ },
+ },
+
+ // ID checks
+ // -----------------------------------------------------------
+ {
+ Name: "invalid custom insertion id (less than 15 chars)",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo3/records",
+ Body: strings.NewReader(`{
+ "id": "12345678901234",
+ "title": "test"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"id":{"code":"validation_length_invalid"`,
+ },
+ },
+ {
+ Name: "invalid custom insertion id (more than 15 chars)",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo3/records",
+ Body: strings.NewReader(`{
+ "id": "1234567890123456",
+ "title": "test"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"id":{"code":"validation_length_invalid"`,
+ },
+ },
+ {
+ Name: "valid custom insertion id (exactly 15 chars)",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo3/records",
+ Body: strings.NewReader(`{
+ "id": "123456789012345",
+ "title": "test"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"123456789012345"`,
+ `"title":"test"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeCreateRequest": 1,
+ "OnRecordAfterCreateRequest": 1,
+ "OnModelBeforeCreate": 1,
+ "OnModelAfterCreate": 1,
+ },
+ },
+ {
+ Name: "valid custom insertion id existing in another non-auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo3/records",
+ Body: strings.NewReader(`{
+ "id": "0yxhwia2amd8gec",
+ "title": "test"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"0yxhwia2amd8gec"`,
+ `"title":"test"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeCreateRequest": 1,
+ "OnRecordAfterCreateRequest": 1,
+ "OnModelBeforeCreate": 1,
+ "OnModelAfterCreate": 1,
+ },
+ },
+ {
+ Name: "valid custom insertion auth id duplicating in another auth collection",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/records",
+ Body: strings.NewReader(`{
+ "id":"o1y0dd0spd786md",
+ "title":"test",
+ "password":"1234567890",
+ "passwordConfirm":"1234567890"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeCreateRequest": 1,
+ },
+ },
+
+ // auth records
+ // -----------------------------------------------------------
+ {
+ Name: "auth record with invalid data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/records",
+ Body: strings.NewReader(`{
+ "id":"o1y0pd786mq",
+ "username":"Users75657",
+ "email":"invalid",
+ "password":"1234567",
+ "passwordConfirm":"1234560"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"id":{"code":"validation_length_invalid"`,
+ `"username":{"code":"validation_invalid_username"`, // for duplicated case-insensitive username
+ `"email":{"code":"validation_is_email"`,
+ `"password":{"code":"validation_length_out_of_range"`,
+ `"passwordConfirm":{"code":"validation_values_mismatch"`,
+ },
+ NotExpectedContent: []string{
+ // schema fields are not checked if the base fields has errors
+ `"rel":{"code":`,
+ },
+ },
+ {
+ Name: "auth record with valid base fields but invalid schema data",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/records",
+ Body: strings.NewReader(`{
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "rel":"invalid"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"rel":{"code":`,
+ },
+ },
+ {
+ Name: "auth record with valid data and explicitly verified state by guest",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/records",
+ Body: strings.NewReader(`{
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "verified":true
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"verified":{"code":`,
+ },
+ },
+ {
+ Name: "auth record with valid data and explicitly verified state by random user",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/records",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ Body: strings.NewReader(`{
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "emailVisibility":true,
+ "verified":true
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"verified":{"code":`,
+ },
+ NotExpectedContent: []string{
+ `"emailVisibility":{"code":`,
+ },
+ },
+ {
+ Name: "auth record with valid data by admin",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/records",
+ Body: strings.NewReader(`{
+ "id":"o1o1y0pd78686mq",
+ "username":"test.valid",
+ "email":"new@example.com",
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "rel":"achvryl401bhse3",
+ "emailVisibility":true,
+ "verified":true
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"o1o1y0pd78686mq"`,
+ `"username":"test.valid"`,
+ `"email":"new@example.com"`,
+ `"rel":"achvryl401bhse3"`,
+ `"emailVisibility":true`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"password"`,
+ `"passwordConfirm"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelAfterCreate": 1,
+ "OnModelBeforeCreate": 1,
+ "OnRecordAfterCreateRequest": 1,
+ "OnRecordBeforeCreateRequest": 1,
+ },
+ },
+ {
+ Name: "auth record with valid data by auth record with manage access",
+ Method: http.MethodPost,
+ Url: "/api/collections/nologin/records",
+ Body: strings.NewReader(`{
+ "email":"new@example.com",
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "name":"test_name",
+ "emailVisibility":true,
+ "verified":true
+ }`),
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"`,
+ `"username":"`,
+ `"email":"new@example.com"`,
+ `"name":"test_name"`,
+ `"emailVisibility":true`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"password"`,
+ `"passwordConfirm"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelAfterCreate": 1,
+ "OnModelBeforeCreate": 1,
+ "OnRecordAfterCreateRequest": 1,
+ "OnRecordBeforeCreateRequest": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudUpdate(t *testing.T) {
+ formData, mp, err := tests.MockMultipartData(map[string]string{
+ "title": "title_test",
+ }, "files")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "missing collection",
+ Method: http.MethodPatch,
+ Url: "/api/collections/missing/records/0yxhwia2amd8gec",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "guest trying to access nil-rule collection record",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record trying to access nil-rule collection",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "submit invalid body",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: strings.NewReader(`{"`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "submit nil body",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: nil,
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "submit empty body (aka. no fields change)",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: strings.NewReader(`{}`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"collectionName":"demo2"`,
+ `"id":"0yxhwia2amd8gec"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnRecordBeforeUpdateRequest": 1,
+ },
+ },
+ {
+ Name: "guest submit in public collection",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: strings.NewReader(`{"title":"new"}`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"0yxhwia2amd8gec"`,
+ `"title":"new"`,
+ `"active":true`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeUpdateRequest": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnModelAfterUpdate": 1,
+ },
+ },
+ {
+ Name: "guest trying to submit in restricted collection",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ Body: strings.NewReader(`{"title":"new"}`),
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record submit in restricted collection (rule failure check)",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ Body: strings.NewReader(`{"title":"new"}`),
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "auth record submit in restricted collection (rule pass check) + expand relations",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo4/records/i9naidtvr6qsgb4?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
+ Body: strings.NewReader(`{
+ "title":"test123",
+ "rel_one_no_cascade":"mk5fmymtx4wsprk",
+ "rel_one_no_cascade_required":"7nwo8tuiatetxdm",
+ "rel_one_cascade":"mk5fmymtx4wsprk",
+ "rel_many_no_cascade":"mk5fmymtx4wsprk",
+ "rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"],
+ "rel_many_cascade":"lcl9d87w22ml6jy"
+ }`),
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"i9naidtvr6qsgb4"`,
+ `"title":"test123"`,
+ `"rel_one_no_cascade":"mk5fmymtx4wsprk"`,
+ `"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`,
+ `"rel_one_cascade":"mk5fmymtx4wsprk"`,
+ `"rel_many_no_cascade":["mk5fmymtx4wsprk"]`,
+ `"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"]`,
+ `"rel_many_cascade":["lcl9d87w22ml6jy"]`,
+ },
+ NotExpectedContent: []string{
+ // the users auth records don't have access to view the demo3 expands
+ `"expand":{`,
+ `"missing"`,
+ `"id":"mk5fmymtx4wsprk"`,
+ `"id":"7nwo8tuiatetxdm"`,
+ `"id":"lcl9d87w22ml6jy"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeUpdateRequest": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnModelAfterUpdate": 1,
+ },
+ },
+ {
+ Name: "admin submit in restricted collection (rule skip check) + expand relations",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo4/records/i9naidtvr6qsgb4?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
+ Body: strings.NewReader(`{
+ "title":"test123",
+ "rel_one_no_cascade":"mk5fmymtx4wsprk",
+ "rel_one_no_cascade_required":"7nwo8tuiatetxdm",
+ "rel_one_cascade":"mk5fmymtx4wsprk",
+ "rel_many_no_cascade":"mk5fmymtx4wsprk",
+ "rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"],
+ "rel_many_cascade":"lcl9d87w22ml6jy"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"i9naidtvr6qsgb4"`,
+ `"title":"test123"`,
+ `"rel_one_no_cascade":"mk5fmymtx4wsprk"`,
+ `"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`,
+ `"rel_one_cascade":"mk5fmymtx4wsprk"`,
+ `"rel_many_no_cascade":["mk5fmymtx4wsprk"]`,
+ `"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"]`,
+ `"rel_many_cascade":["lcl9d87w22ml6jy"]`,
+ `"expand":{`,
+ `"id":"mk5fmymtx4wsprk"`,
+ `"id":"7nwo8tuiatetxdm"`,
+ `"id":"lcl9d87w22ml6jy"`,
+ },
+ NotExpectedContent: []string{
+ `"missing"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeUpdateRequest": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnModelAfterUpdate": 1,
+ },
+ },
+ {
+ Name: "submit via multipart form data",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ Body: formData,
+ RequestHeaders: map[string]string{
+ "Content-Type": mp.FormDataContentType(),
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"mk5fmymtx4wsprk"`,
+ `"title":"title_test"`,
+ `"files":["`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordBeforeUpdateRequest": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnModelAfterUpdate": 1,
+ },
+ },
+ {
+ Name: "try to change the id of an existing record",
+ Method: http.MethodPatch,
+ Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ Body: strings.NewReader(`{
+ "id": "mk5fmymtx4wspra"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"id":{"code":"validation_in_invalid"`,
+ },
+ },
+
+ // auth records
+ // -----------------------------------------------------------
+ {
+ Name: "auth record with invalid data",
+ Method: http.MethodPatch,
+ Url: "/api/collections/users/records/bgs820n361vj1qd",
+ Body: strings.NewReader(`{
+ "username":"Users75657",
+ "email":"invalid",
+ "password":"1234567",
+ "passwordConfirm":"1234560",
+ "verified":false
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"username":{"code":"validation_invalid_username"`, // for duplicated case-insensitive username
+ `"email":{"code":"validation_is_email"`,
+ `"password":{"code":"validation_length_out_of_range"`,
+ `"passwordConfirm":{"code":"validation_values_mismatch"`,
+ },
+ NotExpectedContent: []string{
+ // admins are allowed to change the verified state
+ `"verified"`,
+ // schema fields are not checked if the base fields has errors
+ `"rel":{"code":`,
+ },
+ },
+ {
+ Name: "auth record with valid base fields but invalid schema data",
+ Method: http.MethodPatch,
+ Url: "/api/collections/users/records/bgs820n361vj1qd",
+ Body: strings.NewReader(`{
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "rel":"invalid"
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"rel":{"code":`,
+ },
+ },
+ {
+ Name: "try to change account managing fields by guest",
+ Method: http.MethodPatch,
+ Url: "/api/collections/nologin/records/phhq3wr65cap535",
+ Body: strings.NewReader(`{
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "emailVisibility":true,
+ "verified":true
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"verified":{"code":`,
+ `"oldPassword":{"code":`,
+ },
+ NotExpectedContent: []string{
+ `"emailVisibility":{"code":`,
+ },
+ },
+ {
+ Name: "try to change account managing fields by auth record (owner)",
+ Method: http.MethodPatch,
+ Url: "/api/collections/users/records/4q1xlclmfloku33",
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ Body: strings.NewReader(`{
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "emailVisibility":true,
+ "verified":true
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"verified":{"code":`,
+ `"oldPassword":{"code":`,
+ },
+ NotExpectedContent: []string{
+ `"emailVisibility":{"code":`,
+ },
+ },
+ {
+ Name: "try to change account managing fields by auth record with managing rights",
+ Method: http.MethodPatch,
+ Url: "/api/collections/nologin/records/phhq3wr65cap535",
+ Body: strings.NewReader(`{
+ "email":"new@example.com",
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "name":"test_name",
+ "emailVisibility":true,
+ "verified":true
+ }`),
+ RequestHeaders: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"new@example.com"`,
+ `"name":"test_name"`,
+ `"emailVisibility":true`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"password"`,
+ `"passwordConfirm"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnRecordBeforeUpdateRequest": 1,
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ record, _ := app.Dao().FindRecordById("nologin", "phhq3wr65cap535")
+ if !record.ValidatePassword("12345678") {
+ t.Fatal("Password update failed.")
+ }
+ },
+ },
+ {
+ Name: "update auth record with valid data by admin",
+ Method: http.MethodPatch,
+ Url: "/api/collections/users/records/oap640cot4yru2s",
+ Body: strings.NewReader(`{
+ "username":"test.valid",
+ "email":"new@example.com",
+ "password":"12345678",
+ "passwordConfirm":"12345678",
+ "rel":"achvryl401bhse3",
+ "emailVisibility":true,
+ "verified":false
+ }`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"username":"test.valid"`,
+ `"email":"new@example.com"`,
+ `"rel":"achvryl401bhse3"`,
+ `"emailVisibility":true`,
+ `"verified":false`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"password"`,
+ `"passwordConfirm"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnRecordBeforeUpdateRequest": 1,
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ record, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
+ if !record.ValidatePassword("12345678") {
+ t.Fatal("Password update failed.")
+ }
+ },
+ },
+ {
+ Name: "update auth record with valid data by guest (empty update filter)",
+ Method: http.MethodPatch,
+ Url: "/api/collections/nologin/records/dc49k6jgejn40h3",
+ Body: strings.NewReader(`{
+ "username":"test_new",
+ "emailVisibility":true,
+ "name":"test"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"username":"test_new"`,
+ `"email":"test@example.com"`, // the email should be visible since we updated the emailVisibility
+ `"emailVisibility":true`,
+ `"verified":false`,
+ `"name":"test"`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"password"`,
+ `"passwordConfirm"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnRecordBeforeUpdateRequest": 1,
+ },
+ },
+ {
+ Name: "success password change with oldPassword",
+ Method: http.MethodPatch,
+ Url: "/api/collections/nologin/records/dc49k6jgejn40h3",
+ Body: strings.NewReader(`{
+ "password":"123456789",
+ "passwordConfirm":"123456789",
+ "oldPassword":"1234567890"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"dc49k6jgejn40h3"`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"password"`,
+ `"passwordConfirm"`,
+ `"passwordHash"`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ "OnRecordAfterUpdateRequest": 1,
+ "OnRecordBeforeUpdateRequest": 1,
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ record, _ := app.Dao().FindRecordById("nologin", "dc49k6jgejn40h3")
+ if !record.ValidatePassword("123456789") {
+ t.Fatal("Password update failed.")
+ }
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_helpers.go b/apis/record_helpers.go
new file mode 100644
index 00000000..c2b8c98c
--- /dev/null
+++ b/apis/record_helpers.go
@@ -0,0 +1,186 @@
+package apis
+
+import (
+ "fmt"
+
+ "github.com/labstack/echo/v5"
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/resolvers"
+ "github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/tools/search"
+ "github.com/spf13/cast"
+)
+
+// exportRequestData exports a map with common request fields.
+//
+// @todo consider changing the map to a typed struct after v0.8 and the
+// IN operator support.
+func exportRequestData(c echo.Context) map[string]any {
+ result := map[string]any{}
+ queryParams := map[string]any{}
+ bodyData := map[string]any{}
+ method := c.Request().Method
+
+ echo.BindQueryParams(c, &queryParams)
+
+ rest.BindBody(c, &bodyData)
+
+ result["method"] = method
+ result["query"] = queryParams
+ result["data"] = bodyData
+ result["auth"] = nil
+
+ auth, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if auth != nil {
+ result["auth"] = auth.PublicExport()
+ }
+
+ return result
+}
+
+// expandFetch is the records fetch function that is used to expand related records.
+func expandFetch(
+ dao *daos.Dao,
+ isAdmin bool,
+ requestData map[string]any,
+) daos.ExpandFetchFunc {
+ return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
+ records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
+ if isAdmin {
+ return nil // admins can access everything
+ }
+
+ if relCollection.ViewRule == nil {
+ return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
+ }
+
+ if *relCollection.ViewRule != "" {
+ resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
+ expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
+ if err != nil {
+ return err
+ }
+ resolver.UpdateQuery(q)
+ q.AndWhere(expr)
+ }
+
+ return nil
+ })
+
+ if err == nil && len(records) > 0 {
+ autoIgnoreAuthRecordsEmailVisibility(dao, records, isAdmin, requestData)
+ }
+
+ return records, err
+ }
+}
+
+// autoIgnoreAuthRecordsEmailVisibility ignores the email visibility check for
+// the provided record if the current auth model is admin, owner or a "manager".
+//
+// Note: Expects all records to be from the same auth collection!
+func autoIgnoreAuthRecordsEmailVisibility(
+ dao *daos.Dao,
+ records []*models.Record,
+ isAdmin bool,
+ requestData map[string]any,
+) error {
+ if len(records) == 0 || !records[0].Collection().IsAuth() {
+ return nil // nothing to check
+ }
+
+ if isAdmin {
+ for _, rec := range records {
+ rec.IgnoreEmailVisibility(true)
+ }
+ return nil
+ }
+
+ collection := records[0].Collection()
+
+ mappedRecords := make(map[string]*models.Record, len(records))
+ recordIds := make([]any, 0, len(records))
+ for _, rec := range records {
+ mappedRecords[rec.Id] = rec
+ recordIds = append(recordIds, rec.Id)
+ }
+
+ if auth, ok := requestData["auth"].(map[string]any); ok && mappedRecords[cast.ToString(auth["id"])] != nil {
+ mappedRecords[cast.ToString(auth["id"])].IgnoreEmailVisibility(true)
+ }
+
+ authOptions := collection.AuthOptions()
+ if authOptions.ManageRule == nil || *authOptions.ManageRule == "" {
+ return nil // no manage rule to check
+ }
+
+ // fetch the ids of the managed records
+ // ---
+ managedIds := []string{}
+
+ query := dao.RecordQuery(collection).
+ Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
+ AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
+
+ resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
+ expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
+ if err != nil {
+ return err
+ }
+ resolver.UpdateQuery(query)
+ query.AndWhere(expr)
+
+ if err := query.Column(&managedIds); err != nil {
+ return err
+ }
+ // ---
+
+ // ignore the email visibility check for the managed records
+ for _, id := range managedIds {
+ if rec, ok := mappedRecords[id]; ok {
+ rec.IgnoreEmailVisibility(true)
+ }
+ }
+
+ return nil
+}
+
+// hasAuthManageAccess checks whether the client is allowed to have full
+// [forms.RecordUpsert] auth management permissions
+// (aka. allowing to change system auth fields without oldPassword).
+func hasAuthManageAccess(
+ dao *daos.Dao,
+ record *models.Record,
+ requestData map[string]any,
+) bool {
+ if !record.Collection().IsAuth() {
+ return false
+ }
+
+ manageRule := record.Collection().AuthOptions().ManageRule
+
+ if manageRule == nil || *manageRule == "" {
+ return false // only for admins (manageRule can't be empty)
+ }
+
+ if auth, ok := requestData["auth"].(map[string]any); !ok || cast.ToString(auth["id"]) == "" {
+ return false // no auth record
+ }
+
+ ruleFunc := func(q *dbx.SelectQuery) error {
+ resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
+ expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
+ if err != nil {
+ return err
+ }
+ resolver.UpdateQuery(q)
+ q.AndWhere(expr)
+ return nil
+ }
+
+ _, findErr := dao.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
+
+ return findErr == nil
+}
diff --git a/apis/record_test.go b/apis/record_test.go
deleted file mode 100644
index e0ed740d..00000000
--- a/apis/record_test.go
+++ /dev/null
@@ -1,1052 +0,0 @@
-package apis_test
-
-import (
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestRecordsList(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodGet,
- Url: "/api/collections/missing/records",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "public collection but with admin only filter/sort (aka. @collection)",
- Method: http.MethodGet,
- Url: "/api/collections/demo3/records?filter=@collection.demo.title='test'",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "public collection but with ENCODED admin only filter/sort (aka. @collection)",
- Method: http.MethodGet,
- Url: "/api/collections/demo3/records?filter=%40collection.demo.title%3D%27test%27",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":3`,
- `"items":[{`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "public collection",
- Method: http.MethodGet,
- Url: "/api/collections/demo3/records",
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":1`,
- `"items":[{`,
- `"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "using the collection id as identifier",
- Method: http.MethodGet,
- Url: "/api/collections/3cd6fe92-70dc-4819-8542-4d036faabd89/records",
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":1`,
- `"items":[{`,
- `"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "valid query params",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records?filter=title%7E%27test%27&sort=-title",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":2`,
- `"items":[{`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "invalid filter",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records?filter=invalid~'test'",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expand relations",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records?expand=manyrels,onerel&perPage=2&sort=created",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":2`,
- `"totalItems":2`,
- `"items":[{`,
- `"@expand":{`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"manyrels":[{`,
- `"manyrels":[]`,
- `"cascaderel":"`,
- `"onerel":{"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc","@collectionName":"demo",`,
- `"json":[1,2,3]`,
- `"select":["a","b"]`,
- `"select":[]`,
- `"user":""`,
- `"bool":true`,
- `"number":456`,
- `"user":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "authorized as user that DOESN'T match the collection list rule",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records",
- RequestHeaders: map[string]string{
- // test@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":0`,
- `"items":[]`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "authorized as user that matches the collection list rule",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records",
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":2`,
- `"items":[{`,
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordView(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodGet,
- Url: "/api/collections/missing/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record (unauthorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "invalid record id (authorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/invalid",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record (authorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "mismatched collection-record pair (unauthorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "mismatched collection-record pair (authorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "access record as admin",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- {
- Name: "access record as admin (using the collection id as identifier)",
- Method: http.MethodGet,
- Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- {
- Name: "access record as admin (test rule skipping)",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
- `"@collectionName":"demo2"`,
- `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
- `"manyrels":[]`,
- `"onerel":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- {
- Name: "access record as user (filter mismatch)",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "access record as user (filter match)",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
- `"@collectionName":"demo2"`,
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`,
- `"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- {
- Name: "expand relations",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=manyrels,onerel",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
- `"@collectionName":"demo2"`,
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"@expand":{`,
- `"manyrels":[{`,
- `"onerel":{`,
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordDelete(t *testing.T) {
- ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
- storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
-
- entries, _ := os.ReadDir(storageDir)
- if len(entries) != 0 {
- t.Errorf("Expected empty/deleted dir, found %d", len(entries))
- }
- }
-
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodDelete,
- Url: "/api/collections/missing/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record (unauthorized)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record (authorized)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "mismatched collection-record pair (unauthorized)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "mismatched collection-record pair (authorized)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "access record as admin",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelAfterUpdate": 1, // nullify related record
- "OnModelBeforeUpdate": 1, // nullify related record
- "OnModelBeforeDelete": 3, // +2 cascade delete related records
- "OnModelAfterDelete": 3, // +2 cascade delete related records
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
- },
- },
- {
- Name: "access record as admin (using the collection id as identifier)",
- Method: http.MethodDelete,
- Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelAfterUpdate": 1, // nullify related record
- "OnModelBeforeUpdate": 1, // nullify related record
- "OnModelBeforeDelete": 3, // +2 cascade delete related records
- "OnModelAfterDelete": 3, // +2 cascade delete related records
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
- },
- },
- {
- Name: "deleting record as admin (test rule skipping)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
- },
- },
- {
- Name: "deleting record as user (filter mismatch)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "deleting record as user (filter match)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
- },
- },
- {
- Name: "trying to delete record while being part of a non-cascade required relation",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/848a1dea-5ddd-42d6-a00d-030547bffcfe",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- },
- },
- {
- Name: "cascade delete referenced records",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnModelBeforeDelete": 3,
- "OnModelAfterDelete": 3,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- recId := "63c2ab80-84ab-4057-a592-4604a731f78f"
- col, _ := app.Dao().FindCollectionByNameOrId("demo2")
- rec, _ := app.Dao().FindRecordById(col, recId, nil)
- if rec != nil {
- t.Errorf("Expected record %s to be cascade deleted", recId)
- }
- ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordCreate(t *testing.T) {
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "new",
- }, "file")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodPost,
- Url: "/api/collections/missing/records",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest trying to access nil-rule collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo/records",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "user trying to access nil-rule collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo/records",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit invalid format",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: strings.NewReader(`{"`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit nil body",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: nil,
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest submit in public collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: strings.NewReader(`{"title":"new"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"title":"new"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
- {
- Name: "user submit in restricted collection (rule failure check)",
- Method: http.MethodPost,
- Url: "/api/collections/demo2/records",
- Body: strings.NewReader(`{
- "cascaderel": "577bd676-aacb-4072-b7da-99d00ee210a4",
- "onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
- "manyrels": ["577bd676-aacb-4072-b7da-99d00ee210a4"],
- "text": "test123",
- "bool": "false",
- "number": 1
- }`),
- RequestHeaders: map[string]string{
- // test@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "user submit in restricted collection (rule pass check) + expand relations",
- Method: http.MethodPost,
- Url: "/api/collections/demo2/records?expand=missing,onerel,manyrels,selfrel",
- Body: strings.NewReader(`{
- "cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4",
- "onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
- "manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
- "selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
- "text":"test123",
- "bool":true,
- "number":1
- }`),
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
- `"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"text":"test123"`,
- `"bool":true`,
- `"number":1`,
- `"@expand":{`,
- `"selfrel":{`,
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- },
- NotExpectedContent: []string{
- // user don't have access to view the below expands
- `"manyrels":[{`,
- `"onerel":{`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
- {
- Name: "admin submit in restricted collection (rule skip check) + expand relations",
- Method: http.MethodPost,
- Url: "/api/collections/demo2/records?expand=missing,onerel,manyrels,selfrel",
- Body: strings.NewReader(`{
- "cascaderel": "577bd676-aacb-4072-b7da-99d00ee210a4",
- "onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
- "manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
- "selfrel":"94568ca2-0bee-49d7-b749-06cb97956fd9",
- "text": "test123",
- "bool": false,
- "number": 1
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
- `"text":"test123"`,
- `"bool":false`,
- `"number":1`,
- `"@expand":{`,
- `"manyrels":[{`,
- `"onerel":{`,
- `"selfrel":{`,
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
- {
- Name: "invalid custom insertion id (less than 15 chars)",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: strings.NewReader(`{
- "id": "12345678901234",
- "title": "test"
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"id":{"code":"validation_length_invalid"`,
- },
- },
- {
- Name: "invalid custom insertion id (more than 15 chars)",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: strings.NewReader(`{
- "id": "1234567890123456",
- "title": "test"
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"id":{"code":"validation_length_invalid"`,
- },
- },
- {
- Name: "valid custom insertion id (exactly 15 chars)",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: strings.NewReader(`{
- "id": "123456789012345",
- "title": "test"
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"123456789012345"`,
- `"title":"test"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
-
- {
- Name: "submit via multipart form data",
- Method: http.MethodPost,
- Url: "/api/collections/demo/records",
- Body: formData,
- RequestHeaders: map[string]string{
- "Content-Type": mp.FormDataContentType(),
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"`,
- `"title":"new"`,
- `"file":"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordUpdate(t *testing.T) {
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "new",
- }, "file")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodPatch,
- Url: "/api/collections/missing/records/2c542824-9de1-42fe-8924-e57c86267760",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record",
- Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/00000000-9de1-42fe-8924-e57c86267760",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest trying to edit nil-rule collection record",
- Method: http.MethodPatch,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "user trying to edit nil-rule collection record",
- Method: http.MethodPatch,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit invalid format",
- Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
- Body: strings.NewReader(`{"`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit nil body",
- Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
- Body: nil,
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest submit in public collection",
- Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
- Body: strings.NewReader(`{"title":"new"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
- `"title":"new"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "user submit in restricted collection (rule failure check)",
- Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- Body: strings.NewReader(`{"text": "test_new"}`),
- RequestHeaders: map[string]string{
- // test@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "user submit in restricted collection (rule pass check) + expand relations",
- Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=missing,onerel,manyrels,selfrel",
- Body: strings.NewReader(`{
- "text":"test_new",
- "selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
- "bool":true
- }`),
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`,
- `"bool":true`,
- `"text":"test_new"`,
- `"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"@expand":{`,
- `"selfrel":{`,
- },
- NotExpectedContent: []string{
- // user don't have access to view the below expands
- `"manyrels":[{`,
- `"onerel":{`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "user submit in restricted collection (rule pass check) + expand relations (no view rule access when bool is false)",
- Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=missing,onerel,manyrels,selfrel",
- Body: strings.NewReader(`{
- "selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
- "bool":false
- }`),
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"bool":false`,
- `"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- },
- NotExpectedContent: []string{
- `"@expand":{`,
- `"manyrels":[{`, // admin only
- `"onerel":{`, // admin only
- `"selfrel":{`, // bool=true view rule
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "admin submit in restricted collection (rule skip check) + expand relations",
- Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=onerel,manyrels,selfrel,missing",
- Body: strings.NewReader(`{
- "text":"test_new",
- "number":1,
- "selfrel":"94568ca2-0bee-49d7-b749-06cb97956fd9"
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"text":"test_new"`,
- `"number":1`,
- `"@expand":{`,
- `"manyrels":[{`,
- `"onerel":{`,
- `"selfrel":{`,
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "submit via multipart form data",
- Method: http.MethodPatch,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- Body: formData,
- RequestHeaders: map[string]string{
- "Content-Type": mp.FormDataContentType(),
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- `"title":"new"`,
- `"file":"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
diff --git a/apis/settings.go b/apis/settings.go
index 415eea23..56035a1f 100644
--- a/apis/settings.go
+++ b/apis/settings.go
@@ -7,12 +7,11 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/security"
)
-// BindSettingsApi registers the settings api endpoints.
-func BindSettingsApi(app core.App, rg *echo.Group) {
+// bindSettingsApi registers the settings api endpoints.
+func bindSettingsApi(app core.App, rg *echo.Group) {
api := settingsApi{app: app}
subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth())
@@ -29,7 +28,7 @@ type settingsApi struct {
func (api *settingsApi) list(c echo.Context) error {
settings, err := api.app.Settings().RedactClone()
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
event := &core.SettingsListEvent{
@@ -47,7 +46,7 @@ func (api *settingsApi) set(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.SettingsUpdateEvent{
@@ -61,12 +60,12 @@ func (api *settingsApi) set(c echo.Context) error {
return func() error {
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("An error occurred while submitting the form.", err)
+ return NewBadRequestError("An error occurred while submitting the form.", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
@@ -83,23 +82,23 @@ func (api *settingsApi) set(c echo.Context) error {
func (api *settingsApi) testS3(c echo.Context) error {
if !api.app.Settings().S3.Enabled {
- return rest.NewBadRequestError("S3 storage is not enabled.", nil)
+ return NewBadRequestError("S3 storage is not enabled.", nil)
}
fs, err := api.app.NewFilesystem()
if err != nil {
- return rest.NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
+ return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
}
defer fs.Close()
testFileKey := "pb_test_" + security.RandomString(5) + "/test.txt"
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
- return rest.NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
+ return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
}
if err := fs.Delete(testFileKey); err != nil {
- return rest.NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
+ return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
@@ -110,18 +109,18 @@ func (api *settingsApi) testEmail(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
// send
if err := form.Submit(); err != nil {
if fErr, ok := err.(validation.Errors); ok {
// form error
- return rest.NewBadRequestError("Failed to send the test email.", fErr)
+ return NewBadRequestError("Failed to send the test email.", fErr)
}
// mailer error
- return rest.NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
+ return NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
diff --git a/apis/settings_test.go b/apis/settings_test.go
index 55020ad1..70f21264 100644
--- a/apis/settings_test.go
+++ b/apis/settings_test.go
@@ -19,11 +19,11 @@ func TestSettingsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/settings",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -33,7 +33,7 @@ func TestSettingsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/settings",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -43,15 +43,16 @@ func TestSettingsList(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
- `"userAuthToken":{`,
- `"userPasswordResetToken":{`,
- `"userEmailChangeToken":{`,
- `"userVerificationToken":{`,
+ `"recordAuthToken":{`,
+ `"recordPasswordResetToken":{`,
+ `"recordEmailChangeToken":{`,
+ `"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
+ `"twitterAuth":{`,
`"discordAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
@@ -68,7 +69,7 @@ func TestSettingsList(t *testing.T) {
}
func TestSettingsSet(t *testing.T) {
- validData := `{"meta":{"appName":"update_test"},"emailAuth":{"minPasswordLength": 12}}`
+ validData := `{"meta":{"appName":"update_test"}}`
scenarios := []tests.ApiScenario{
{
@@ -80,12 +81,12 @@ func TestSettingsSet(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodPatch,
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -96,7 +97,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -106,10 +107,10 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
- `"userAuthToken":{`,
- `"userPasswordResetToken":{`,
- `"userEmailChangeToken":{`,
- `"userVerificationToken":{`,
+ `"recordAuthToken":{`,
+ `"recordPasswordResetToken":{`,
+ `"recordEmailChangeToken":{`,
+ `"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -119,7 +120,6 @@ func TestSettingsSet(t *testing.T) {
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"Acme"`,
- `"minPasswordLength":8`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -132,15 +132,14 @@ func TestSettingsSet(t *testing.T) {
Name: "authorized as admin submitting invalid data",
Method: http.MethodPatch,
Url: "/api/settings",
- Body: strings.NewReader(`{"meta":{"appName":""},"emailAuth":{"minPasswordLength": 3}}`),
+ Body: strings.NewReader(`{"meta":{"appName":""}}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
- `"emailAuth":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required","message":"Must be no less than 5."}}`,
- `"meta":{"appName":{"code":"validation_required","message":"Cannot be blank."}}`,
+ `"meta":{"appName":{"code":"validation_required"`,
},
},
{
@@ -149,7 +148,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -159,20 +158,20 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
- `"userAuthToken":{`,
- `"userPasswordResetToken":{`,
- `"userEmailChangeToken":{`,
- `"userVerificationToken":{`,
+ `"recordAuthToken":{`,
+ `"recordPasswordResetToken":{`,
+ `"recordEmailChangeToken":{`,
+ `"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
+ `"twitterAuth":{`,
`"discordAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
- `"minPasswordLength":12`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -198,11 +197,11 @@ func TestSettingsTestS3(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -212,12 +211,11 @@ func TestSettingsTestS3(t *testing.T) {
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
- // @todo consider creating a test S3 filesystem
}
for _, scenario := range scenarios {
@@ -239,7 +237,7 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{
@@ -247,7 +245,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -258,7 +256,7 @@ func TestSettingsTestEmail(t *testing.T) {
Url: "/api/settings/test/email",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -269,7 +267,7 @@ func TestSettingsTestEmail(t *testing.T) {
Url: "/api/settings/test/email",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -286,7 +284,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -304,8 +302,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeUserVerificationSend": 1,
- "OnMailerAfterUserVerificationSend": 1,
+ "OnMailerBeforeRecordVerificationSend": 1,
+ "OnMailerAfterRecordVerificationSend": 1,
},
},
{
@@ -317,7 +315,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -335,8 +333,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeUserResetPasswordSend": 1,
- "OnMailerAfterUserResetPasswordSend": 1,
+ "OnMailerBeforeRecordResetPasswordSend": 1,
+ "OnMailerAfterRecordResetPasswordSend": 1,
},
},
{
@@ -348,7 +346,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -366,8 +364,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeUserChangeEmailSend": 1,
- "OnMailerAfterUserChangeEmailSend": 1,
+ "OnMailerBeforeRecordChangeEmailSend": 1,
+ "OnMailerAfterRecordChangeEmailSend": 1,
},
},
}
diff --git a/apis/user.go b/apis/user.go
deleted file mode 100644
index c7f00eea..00000000
--- a/apis/user.go
+++ /dev/null
@@ -1,519 +0,0 @@
-package apis
-
-import (
- "log"
- "net/http"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tokens"
- "github.com/pocketbase/pocketbase/tools/auth"
- "github.com/pocketbase/pocketbase/tools/rest"
- "github.com/pocketbase/pocketbase/tools/routine"
- "github.com/pocketbase/pocketbase/tools/search"
- "github.com/pocketbase/pocketbase/tools/security"
- "golang.org/x/oauth2"
-)
-
-// BindUserApi registers the user api endpoints and the corresponding handlers.
-func BindUserApi(app core.App, rg *echo.Group) {
- api := userApi{app: app}
-
- subGroup := rg.Group("/users", ActivityLogger(app))
- subGroup.GET("/auth-methods", api.authMethods)
- subGroup.POST("/auth-via-oauth2", api.oauth2Auth, RequireGuestOnly())
- subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly())
- subGroup.POST("/request-password-reset", api.requestPasswordReset)
- subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
- subGroup.POST("/request-verification", api.requestVerification)
- subGroup.POST("/confirm-verification", api.confirmVerification)
- subGroup.POST("/request-email-change", api.requestEmailChange, RequireUserAuth())
- subGroup.POST("/confirm-email-change", api.confirmEmailChange)
- subGroup.POST("/refresh", api.refresh, RequireUserAuth())
- // crud
- subGroup.GET("", api.list, RequireAdminAuth())
- subGroup.POST("", api.create)
- subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id"))
- subGroup.PATCH("/:id", api.update, RequireAdminAuth())
- subGroup.DELETE("/:id", api.delete, RequireAdminOrOwnerAuth("id"))
- subGroup.GET("/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
- subGroup.DELETE("/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
-}
-
-type userApi struct {
- app core.App
-}
-
-func (api *userApi) authResponse(c echo.Context, user *models.User, meta any) error {
- token, tokenErr := tokens.NewUserAuthToken(api.app, user)
- if tokenErr != nil {
- return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
- }
-
- event := &core.UserAuthEvent{
- HttpContext: c,
- User: user,
- Token: token,
- Meta: meta,
- }
-
- return api.app.OnUserAuthRequest().Trigger(event, func(e *core.UserAuthEvent) error {
- result := map[string]any{
- "token": e.Token,
- "user": e.User,
- }
-
- if e.Meta != nil {
- result["meta"] = e.Meta
- }
-
- return e.HttpContext.JSON(http.StatusOK, result)
- })
-}
-
-func (api *userApi) refresh(c echo.Context) error {
- user, _ := c.Get(ContextUserKey).(*models.User)
- if user == nil {
- return rest.NewNotFoundError("Missing auth user context.", nil)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-type providerInfo struct {
- Name string `json:"name"`
- State string `json:"state"`
- CodeVerifier string `json:"codeVerifier"`
- CodeChallenge string `json:"codeChallenge"`
- CodeChallengeMethod string `json:"codeChallengeMethod"`
- AuthUrl string `json:"authUrl"`
-}
-
-func (api *userApi) authMethods(c echo.Context) error {
- result := struct {
- EmailPassword bool `json:"emailPassword"`
- AuthProviders []providerInfo `json:"authProviders"`
- }{
- EmailPassword: true,
- AuthProviders: []providerInfo{},
- }
-
- settings := api.app.Settings()
-
- result.EmailPassword = settings.EmailAuth.Enabled
-
- nameConfigMap := settings.NamedAuthProviderConfigs()
-
- for name, config := range nameConfigMap {
- if !config.Enabled {
- continue
- }
-
- provider, err := auth.NewProviderByName(name)
- if err != nil {
- if api.app.IsDebug() {
- log.Println(err)
- }
-
- // skip provider
- continue
- }
-
- if err := config.SetupProvider(provider); err != nil {
- if api.app.IsDebug() {
- log.Println(err)
- }
-
- // skip provider
- continue
- }
-
- state := security.RandomString(30)
- codeVerifier := security.RandomString(43)
- codeChallenge := security.S256Challenge(codeVerifier)
- codeChallengeMethod := "S256"
- result.AuthProviders = append(result.AuthProviders, providerInfo{
- Name: name,
- State: state,
- CodeVerifier: codeVerifier,
- CodeChallenge: codeChallenge,
- CodeChallengeMethod: codeChallengeMethod,
- AuthUrl: provider.BuildAuthUrl(
- state,
- oauth2.SetAuthURLParam("code_challenge", codeChallenge),
- oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
- ) + "&redirect_uri=", // empty redirect_uri so that users can append their url
- })
- }
-
- return c.JSON(http.StatusOK, result)
-}
-
-func (api *userApi) oauth2Auth(c echo.Context) error {
- form := forms.NewUserOauth2Login(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, authData, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("Failed to authenticate.", submitErr)
- }
-
- return api.authResponse(c, user, authData)
-}
-
-func (api *userApi) emailAuth(c echo.Context) error {
- if !api.app.Settings().EmailAuth.Enabled {
- return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
- }
-
- form := forms.NewUserEmailLogin(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("Failed to authenticate.", submitErr)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-func (api *userApi) requestPasswordReset(c echo.Context) error {
- form := forms.NewUserPasswordResetRequest(api.app)
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Validate(); err != nil {
- return rest.NewBadRequestError("An error occurred while validating the form.", err)
- }
-
- // run in background because we don't need to show
- // the result to the user (prevents users enumeration)
- routine.FireAndForget(func() {
- if err := form.Submit(); err != nil && api.app.IsDebug() {
- log.Println(err)
- }
- })
-
- return c.NoContent(http.StatusNoContent)
-}
-
-func (api *userApi) confirmPasswordReset(c echo.Context) error {
- form := forms.NewUserPasswordResetConfirm(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("Failed to set new password.", submitErr)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-func (api *userApi) requestEmailChange(c echo.Context) error {
- loggedUser, _ := c.Get(ContextUserKey).(*models.User)
- if loggedUser == nil {
- return rest.NewUnauthorizedError("The request requires valid authorized user.", nil)
- }
-
- form := forms.NewUserEmailChangeRequest(api.app, loggedUser)
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Submit(); err != nil {
- return rest.NewBadRequestError("Failed to request email change.", err)
- }
-
- return c.NoContent(http.StatusNoContent)
-}
-
-func (api *userApi) confirmEmailChange(c echo.Context) error {
- form := forms.NewUserEmailChangeConfirm(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("Failed to confirm email change.", submitErr)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-func (api *userApi) requestVerification(c echo.Context) error {
- form := forms.NewUserVerificationRequest(api.app)
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Validate(); err != nil {
- return rest.NewBadRequestError("An error occurred while validating the form.", err)
- }
-
- // run in background because we don't need to show
- // the result to the user (prevents users enumeration)
- routine.FireAndForget(func() {
- if err := form.Submit(); err != nil && api.app.IsDebug() {
- log.Println(err)
- }
- })
-
- return c.NoContent(http.StatusNoContent)
-}
-
-func (api *userApi) confirmVerification(c echo.Context) error {
- form := forms.NewUserVerificationConfirm(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("An error occurred while submitting the form.", submitErr)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-// -------------------------------------------------------------------
-// CRUD
-// -------------------------------------------------------------------
-
-func (api *userApi) list(c echo.Context) error {
- fieldResolver := search.NewSimpleFieldResolver(
- "id", "created", "updated", "email", "verified",
- )
-
- users := []*models.User{}
-
- result, searchErr := search.NewProvider(fieldResolver).
- Query(api.app.Dao().UserQuery()).
- ParseAndExec(c.QueryString(), &users)
- if searchErr != nil {
- return rest.NewBadRequestError("", searchErr)
- }
-
- // eager load user profiles (if any)
- if err := api.app.Dao().LoadProfiles(users); err != nil {
- return rest.NewBadRequestError("", err)
- }
-
- event := &core.UsersListEvent{
- HttpContext: c,
- Users: users,
- Result: result,
- }
-
- return api.app.OnUsersListRequest().Trigger(event, func(e *core.UsersListEvent) error {
- return e.HttpContext.JSON(http.StatusOK, e.Result)
- })
-}
-
-func (api *userApi) view(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- event := &core.UserViewEvent{
- HttpContext: c,
- User: user,
- }
-
- return api.app.OnUserViewRequest().Trigger(event, func(e *core.UserViewEvent) error {
- return e.HttpContext.JSON(http.StatusOK, e.User)
- })
-}
-
-func (api *userApi) create(c echo.Context) error {
- if !api.app.Settings().EmailAuth.Enabled {
- return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
- }
-
- user := &models.User{}
- form := forms.NewUserUpsert(api.app, user)
-
- // load request
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
- }
-
- event := &core.UserCreateEvent{
- HttpContext: c,
- User: user,
- }
-
- // create the user
- submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
- return func() error {
- return api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
- if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to create user.", err)
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.User)
- })
- }
- })
-
- if submitErr == nil {
- api.app.OnUserAfterCreateRequest().Trigger(event)
- }
-
- return submitErr
-}
-
-func (api *userApi) update(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- form := forms.NewUserUpsert(api.app, user)
-
- // load request
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
- }
-
- event := &core.UserUpdateEvent{
- HttpContext: c,
- User: user,
- }
-
- // update the user
- submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
- return func() error {
- return api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
- if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to update user.", err)
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.User)
- })
- }
- })
-
- if submitErr == nil {
- api.app.OnUserAfterUpdateRequest().Trigger(event)
- }
-
- return submitErr
-}
-
-func (api *userApi) delete(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- event := &core.UserDeleteEvent{
- HttpContext: c,
- User: user,
- }
-
- handlerErr := api.app.OnUserBeforeDeleteRequest().Trigger(event, func(e *core.UserDeleteEvent) error {
- // delete the user model
- if err := api.app.Dao().DeleteUser(e.User); err != nil {
- return rest.NewBadRequestError("Failed to delete user. Make sure that the user is not part of a required relation reference.", err)
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
-
- if handlerErr == nil {
- api.app.OnUserAfterDeleteRequest().Trigger(event)
- }
-
- return handlerErr
-}
-
-func (api *userApi) listExternalAuths(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- externalAuths, err := api.app.Dao().FindAllExternalAuthsByUserId(user.Id)
- if err != nil {
- return rest.NewBadRequestError("Failed to fetch the external auths for the specified user.", err)
- }
-
- event := &core.UserListExternalAuthsEvent{
- HttpContext: c,
- User: user,
- ExternalAuths: externalAuths,
- }
-
- return api.app.OnUserListExternalAuths().Trigger(event, func(e *core.UserListExternalAuthsEvent) error {
- return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
- })
-}
-
-func (api *userApi) unlinkExternalAuth(c echo.Context) error {
- id := c.PathParam("id")
- provider := c.PathParam("provider")
- if id == "" || provider == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- externalAuth, err := api.app.Dao().FindExternalAuthByUserIdAndProvider(user.Id, provider)
- if err != nil {
- return rest.NewNotFoundError("Missing external auth provider relation.", err)
- }
-
- event := &core.UserUnlinkExternalAuthEvent{
- HttpContext: c,
- User: user,
- ExternalAuth: externalAuth,
- }
-
- handlerErr := api.app.OnUserBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.UserUnlinkExternalAuthEvent) error {
- if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
- return rest.NewBadRequestError("Cannot unlink the external auth provider. Make sure that the user has other linked auth providers OR has an email address.", err)
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
-
- if handlerErr == nil {
- api.app.OnUserAfterUnlinkExternalAuthRequest().Trigger(event)
- }
-
- return handlerErr
-}
diff --git a/apis/user_test.go b/apis/user_test.go
deleted file mode 100644
index d1576f77..00000000
--- a/apis/user_test.go
+++ /dev/null
@@ -1,1113 +0,0 @@
-package apis_test
-
-import (
- "net/http"
- "strings"
- "testing"
- "time"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestUsersAuthMethods(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Method: http.MethodGet,
- Url: "/api/users/auth-methods",
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"emailPassword":true`,
- `"authProviders":[{`,
- `"authProviders":[{`,
- `"name":"gitlab"`,
- `"state":`,
- `"codeVerifier":`,
- `"codeChallenge":`,
- `"codeChallengeMethod":`,
- `"authUrl":`,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserEmailAuth(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "authorized as user",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "invalid body format",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- Body: strings.NewReader(`{"email":"","password":""}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"email":{`,
- `"password":{`,
- },
- },
- {
- Name: "disabled email/pass auth with valid data",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- Body: strings.NewReader(`{"email":"test@example.com","password":"123456"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.Settings().EmailAuth.Enabled = false
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "valid data",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- Body: strings.NewReader(`{"email":"test2@example.com","password":"123456"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token"`,
- `"user"`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"email":"test2@example.com"`,
- `"verified":false`, // unverified user should be able to authenticate
- },
- ExpectedEvents: map[string]int{"OnUserAuthRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserRequestPasswordReset(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/users/request-password-reset",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/users/request-password-reset",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing user",
- Method: http.MethodPost,
- Url: "/api/users/request-password-reset",
- Body: strings.NewReader(`{"email":"missing@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "existing user",
- Method: http.MethodPost,
- Url: "/api/users/request-password-reset",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnMailerBeforeUserResetPasswordSend": 1,
- "OnMailerAfterUserResetPasswordSend": 1,
- },
- },
- {
- Name: "existing user (after already sent)",
- Method: http.MethodPost,
- Url: "/api/users/request-password-reset",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // simulate recent password request
- user, err := app.Dao().FindUserByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
- user.LastResetSentAt = types.NowDateTime()
- dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
- if err := dao.Save(user); err != nil {
- t.Fatal(err)
- }
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserConfirmPasswordReset(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/users/confirm-password-reset",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"password":{"code":"validation_required","message":"Cannot be blank."},"passwordConfirm":{"code":"validation_required","message":"Cannot be blank."},"token":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "invalid data format",
- Method: http.MethodPost,
- Url: "/api/users/confirm-password-reset",
- Body: strings.NewReader(`{"password`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expired token",
- Method: http.MethodPost,
- Url: "/api/users/confirm-password-reset",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQxMDMxMjAwfQ.t2lVe0ny9XruQsSFQdXqBi0I85i6vIUAQjFXZY5HPxc","password":"123456789","passwordConfirm":"123456789"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{`,
- `"code":"validation_invalid_token"`,
- },
- },
- {
- Name: "valid token and data",
- Method: http.MethodPost,
- Url: "/api/users/confirm-password-reset",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTU2MDAwfQ.V1gEbY4caEIF6IhQAJ8KZD4RvOGvTCFuYg1fTRSvhe0","password":"123456789","passwordConfirm":"123456789"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"user":`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"email":"test@example.com"`,
- },
- ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserRequestVerification(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing user",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email":"missing@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "existing already verified user",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "existing unverified user",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email":"test2@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnMailerBeforeUserVerificationSend": 1,
- "OnMailerAfterUserVerificationSend": 1,
- },
- },
- {
- Name: "existing unverified user (after already sent)",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email":"test2@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // simulate recent verification sent
- user, err := app.Dao().FindUserByEmail("test2@example.com")
- if err != nil {
- t.Fatal(err)
- }
- user.LastVerificationSentAt = types.NowDateTime()
- dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
- if err := dao.Save(user); err != nil {
- t.Fatal(err)
- }
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserConfirmVerification(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // empty data
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-verification",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"token":{"code":"validation_required"`,
- },
- },
- // invalid data
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-verification",
- Body: strings.NewReader(`{"token`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- // expired token
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-verification",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTY0MTAzMTIwMH0.YCqyREksfqn7cWu-innNNTbWQCr9DgYr7dduM2wxrtQ"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{`,
- `"code":"validation_invalid_token"`,
- },
- },
- // valid token
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-verification",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTk1NjAwMH0.OsxRKuZrNTnwyVjvCwB4jY8TbT-NPZ-UFCpRhCvuv2U"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"user":`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"email":"test2@example.com"`,
- `"verified":true`,
- },
- ExpectedEvents: map[string]int{
- "OnUserAuthRequest": 1,
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserRequestEmailChange(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // unauthorized
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as admin
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // invalid data
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail`),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- // empty data
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"newEmail":{"code":"validation_required"`,
- },
- },
- // valid data (existing email)
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"test2@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"newEmail":{"code":"validation_user_email_exists"`,
- },
- },
- // valid data (new email)
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnMailerBeforeUserChangeEmailSend": 1,
- "OnMailerAfterUserChangeEmailSend": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserConfirmEmailChange(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // empty data
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"token":{"code":"validation_required"`,
- `"password":{"code":"validation_required"`,
- },
- },
- // invalid data
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(`{"token`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- // expired token and correct password
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjAwfQ.DOqNtSDcXbWix8OsK13X-tjfWi6jZNlAzIZiwG_YDOs","password":"123456"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{`,
- `"code":"validation_invalid_token"`,
- },
- },
- // valid token and incorrect password
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"654321"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"password":{`,
- `"code":"validation_invalid_password"`,
- },
- },
- // valid token and correct password
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"123456"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"user":`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"email":"change@example.com"`,
- `"verified":true`,
- },
- ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserRefresh(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // unauthorized
- {
- Method: http.MethodPost,
- Url: "/api/users/refresh",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as admin
- {
- Method: http.MethodPost,
- Url: "/api/users/refresh",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as user
- {
- Method: http.MethodPost,
- Url: "/api/users/refresh",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"user":`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- },
- ExpectedEvents: map[string]int{"OnUserAuthRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUsersList(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // unauthorized
- {
- Method: http.MethodGet,
- Url: "/api/users",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as user
- {
- Method: http.MethodGet,
- Url: "/api/users",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as admin
- {
- Method: http.MethodGet,
- Url: "/api/users",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":4`,
- `"items":[{`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
- `"id":"cx9u0dh2udo8xol"`,
- },
- ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
- },
- // authorized as admin + paging and sorting
- {
- Method: http.MethodGet,
- Url: "/api/users?page=2&perPage=2&sort=-created",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":2`,
- `"perPage":2`,
- `"totalItems":4`,
- `"items":[{`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- },
- ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
- },
- // authorized as admin + invalid filter
- {
- Method: http.MethodGet,
- Url: "/api/users?filter=invalidfield~'test2'",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as admin + valid filter
- {
- Method: http.MethodGet,
- Url: "/api/users?filter=verified=true",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":3`,
- `"items":[{`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
- `"id":"cx9u0dh2udo8xol"`,
- },
- ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserView(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodGet,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting user id",
- Method: http.MethodGet,
- Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + existing user id",
- Method: http.MethodGet,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- },
- ExpectedEvents: map[string]int{"OnUserViewRequest": 1},
- },
- {
- Name: "authorized as user - trying to view another user",
- Method: http.MethodGet,
- Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user - owner",
- Method: http.MethodGet,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- },
- ExpectedEvents: map[string]int{"OnUserViewRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserDelete(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodDelete,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting user id",
- Method: http.MethodDelete,
- Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + existing user id",
- Method: http.MethodDelete,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnUserBeforeDeleteRequest": 1,
- "OnUserAfterDeleteRequest": 1,
- "OnModelBeforeDelete": 2, // cascade delete to related Record model
- "OnModelAfterDelete": 2, // cascade delete to related Record model
- },
- },
- {
- Name: "authorized as user - trying to delete another user",
- Method: http.MethodDelete,
- Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user - owner",
- Method: http.MethodDelete,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnUserBeforeDeleteRequest": 1,
- "OnUserAfterDeleteRequest": 1,
- "OnModelBeforeDelete": 2, // cascade delete to related Record model
- "OnModelAfterDelete": 2, // cascade delete to related Record model
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserCreate(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/users",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"email":{"code":"validation_required"`,
- `"password":{"code":"validation_required"`,
- },
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/users",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"email":{"code":"validation_user_email_exists"`,
- `"password":{"code":"validation_length_out_of_range"`,
- `"passwordConfirm":{"code":"validation_values_mismatch"`,
- },
- },
- {
- Name: "valid data but with disabled email/pass auth",
- Method: http.MethodPost,
- Url: "/api/users",
- Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.Settings().EmailAuth.Enabled = false
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "valid data",
- Method: http.MethodPost,
- Url: "/api/users",
- Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"email":"newuser@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnUserBeforeCreateRequest": 1,
- "OnUserAfterCreateRequest": 1,
- "OnModelBeforeCreate": 2, // +1 for the created profile record
- "OnModelAfterCreate": 2, // +1 for the created profile record
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserUpdate(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(`{"email":"new@example.com"}`),
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user (owner)",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(`{"email":"new@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - invalid/missing user id",
- Method: http.MethodPatch,
- Url: "/api/users/invalid",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - empty data",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"email":"test@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnUserBeforeUpdateRequest": 1,
- "OnUserAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "authorized as admin - invalid data",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(`{"email":"test2@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"email":{"code":"validation_user_email_exists"`,
- },
- },
- {
- Name: "authorized as admin - valid data",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(`{"email":"new@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"email":"new@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnUserBeforeUpdateRequest": 1,
- "OnUserAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserListExternalsAuths(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodGet,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting user id",
- Method: http.MethodGet,
- Url: "/api/users/000000000000000/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + existing user id and no external auths",
- Method: http.MethodGet,
- Url: "/api/users/97cc3d3d-6ba2-383f-b42a-7bc84d27410c/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `[]`,
- },
- ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
- },
- {
- Name: "authorized as admin + existing user id and 2 external auths",
- Method: http.MethodGet,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"abcdefghijklmn1"`,
- `"id":"abcdefghijklmn0"`,
- `"userId":"cx9u0dh2udo8xol"`,
- },
- ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
- },
- {
- Name: "authorized as user - trying to list another user external auths",
- Method: http.MethodGet,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user - owner without external auths",
- Method: http.MethodGet,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `[]`,
- },
- ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
- },
- {
- Name: "authorized as user - owner with 2 external auths",
- Method: http.MethodGet,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImN4OXUwZGgydWRvOHhvbCIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.NgFYG2D7PftFW1tcfe5E2oDi_AVakDR9J6WI6VUZQfw",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"abcdefghijklmn1"`,
- `"id":"abcdefghijklmn0"`,
- `"userId":"cx9u0dh2udo8xol"`,
- },
- ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserUnlinkExternalsAuth(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - nonexisting user id",
- Method: http.MethodDelete,
- Url: "/api/users/000000000000000/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - nonexisting provider",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/facebook",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - existing provider",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedContent: []string{},
- ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnUserAfterUnlinkExternalAuthRequest": 1,
- "OnUserBeforeUnlinkExternalAuthRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- auth, _ := app.Dao().FindExternalAuthByUserIdAndProvider("cx9u0dh2udo8xol", "google")
- if auth != nil {
- t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
- }
- },
- },
- {
- Name: "authorized as user - trying to unlink another user external auth",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user - owner with existing external auth",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImN4OXUwZGgydWRvOHhvbCIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.NgFYG2D7PftFW1tcfe5E2oDi_AVakDR9J6WI6VUZQfw",
- },
- ExpectedStatus: 204,
- ExpectedContent: []string{},
- ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnUserAfterUnlinkExternalAuthRequest": 1,
- "OnUserBeforeUnlinkExternalAuthRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- auth, _ := app.Dao().FindExternalAuthByUserIdAndProvider("cx9u0dh2udo8xol", "google")
- if auth != nil {
- t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
- }
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
diff --git a/cmd/temp_upgrade.go b/cmd/temp_upgrade.go
new file mode 100644
index 00000000..127e1682
--- /dev/null
+++ b/cmd/temp_upgrade.go
@@ -0,0 +1,444 @@
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/fatih/color"
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cobra"
+)
+
+// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
+//
+// NB! It will be removed in v0.9.0!
+func NewTempUpgradeCommand(app core.App) *cobra.Command {
+ command := &cobra.Command{
+ Use: "upgrade",
+ Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes",
+ Long: `
+Upgrades your existing pb_data to be compatible with the v0.8.x changes
+Prerequisites and caveats:
+- already upgraded to v0.7.*
+- no existing users collection
+- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc.
+`,
+ Run: func(command *cobra.Command, args []string) {
+ if err := upgrade(app); err != nil {
+ color.Red("Error: %v", err)
+ }
+ },
+ }
+
+ return command
+}
+
+func upgrade(app core.App) error {
+ if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil {
+ return errors.New("It seems that you've already upgraded or have an existing 'users' collection.")
+ }
+
+ return app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
+ if err := migrateCollections(txDao); err != nil {
+ return err
+ }
+
+ if err := migrateUsers(app, txDao); err != nil {
+ return err
+ }
+
+ if err := resetMigrationsTable(txDao); err != nil {
+ return err
+ }
+
+ bold := color.New(color.Bold).Add(color.FgGreen)
+ bold.Println("The pb_data upgrade completed successfully!")
+ bold.Println("You can now start the application as usual with the 'serve' command.")
+ bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.")
+ fmt.Println()
+
+ return nil
+ })
+}
+
+// -------------------------------------------------------------------
+
+func migrateCollections(txDao *daos.Dao) error {
+ // add new collection columns
+ if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil {
+ return err
+ }
+ if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil {
+ return err
+ }
+
+ ruleReplacements := []struct {
+ old string
+ new string
+ }{
+ {"expand", "expand2"},
+ {"collecitonId", "collectionId2"},
+ {"collecitonName", "collectionName2"},
+ {"profile.userId", "profile.id"},
+
+ // @collection.*
+ {"@collection.profiles.userId", "@collection.users.id"},
+ {"@collection.profiles.username", "@collection.users.username2"},
+ {"@collection.profiles.email", "@collection.users.email2"},
+ {"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"},
+ {"@collection.profiles.verified", "@collection.users.verified2"},
+ {"@collection.profiles.tokenKey", "@collection.users.tokenKey2"},
+ {"@collection.profiles.passwordHash", "@collection.users.passwordHash2"},
+ {"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"},
+ {"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"},
+ {"@collection.profiles.", "@collection.users."},
+
+ // @request.*
+ {"@request.user.profile.userId", "@request.auth.id"},
+ {"@request.user.profile.username", "@request.auth.username2"},
+ {"@request.user.profile.email", "@request.auth.email2"},
+ {"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"},
+ {"@request.user.profile.verified", "@request.auth.verified2"},
+ {"@request.user.profile.tokenKey", "@request.auth.tokenKey2"},
+ {"@request.user.profile.passwordHash", "@request.auth.passwordHash2"},
+ {"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"},
+ {"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"},
+ {"@request.user.profile.", "@request.auth."},
+ {"@request.user", "@request.auth"},
+ }
+
+ collections := []*models.Collection{}
+ if err := txDao.CollectionQuery().All(&collections); err != nil {
+ return err
+ }
+
+ for _, collection := range collections {
+ collection.Type = models.CollectionTypeBase
+ collection.NormalizeOptions()
+
+ // rename profile fields
+ // ---
+ fieldsToRename := []string{
+ "collectionId",
+ "collectionName",
+ "expand",
+ }
+ if collection.Name == "profiles" {
+ fieldsToRename = append(fieldsToRename,
+ "username",
+ "email",
+ "emailVisibility",
+ "verified",
+ "tokenKey",
+ "passwordHash",
+ "lastResetSentAt",
+ "lastVerificationSentAt",
+ )
+ }
+ for _, name := range fieldsToRename {
+ f := collection.Schema.GetFieldByName(name)
+ if f != nil {
+ color.Blue("[%s - renamed field]", collection.Name)
+ color.Yellow(" - old: %s", f.Name)
+ color.Green(" - new: %s2", f.Name)
+ fmt.Println()
+ f.Name += "2"
+ }
+ }
+ // ---
+
+ // replace rule fields
+ // ---
+ rules := map[string]*string{
+ "ListRule": collection.ListRule,
+ "ViewRule": collection.ViewRule,
+ "CreateRule": collection.CreateRule,
+ "UpdateRule": collection.UpdateRule,
+ "DeleteRule": collection.DeleteRule,
+ }
+
+ for ruleKey, rule := range rules {
+ if rule == nil || *rule == "" {
+ continue
+ }
+
+ originalRule := *rule
+
+ for _, replacement := range ruleReplacements {
+ re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`)
+ *rule = re.ReplaceAllString(*rule, replacement.new)
+ }
+
+ *rule = replaceReversedLikes(*rule)
+
+ if originalRule != *rule {
+ color.Blue("[%s - replaced %s]:", collection.Name, ruleKey)
+ color.Yellow(" - old: %s", strings.TrimSpace(originalRule))
+ color.Green(" - new: %s", strings.TrimSpace(*rule))
+ fmt.Println()
+ }
+ }
+ // ---
+
+ if err := txDao.SaveCollection(collection); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func migrateUsers(app core.App, txDao *daos.Dao) error {
+ color.Blue(`[merging "_users" and "profiles"]:`)
+
+ profilesCollection, err := txDao.FindCollectionByNameOrId("profiles")
+ if err != nil {
+ return err
+ }
+
+ originalProfilesCollectionId := profilesCollection.Id
+
+ // change the profiles collection id to something else since we will be using
+ // it for the new users collection in order to avoid renaming the storage dir
+ _, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf(
+ `UPDATE {{_collections}}
+ SET id = '%s'
+ WHERE id = '%s';
+ `,
+ (originalProfilesCollectionId + "__old__"),
+ originalProfilesCollectionId,
+ )).Execute()
+ if idRenameErr != nil {
+ return idRenameErr
+ }
+
+ // refresh profiles collection
+ profilesCollection, err = txDao.FindCollectionByNameOrId("profiles")
+ if err != nil {
+ return err
+ }
+
+ usersSchema, _ := profilesCollection.Schema.Clone()
+ userIdField := usersSchema.GetFieldByName("userId")
+ if userIdField != nil {
+ usersSchema.RemoveField(userIdField.Id)
+ }
+
+ usersCollection := &models.Collection{}
+ usersCollection.MarkAsNew()
+ usersCollection.Id = originalProfilesCollectionId
+ usersCollection.Name = "users"
+ usersCollection.Type = models.CollectionTypeAuth
+ usersCollection.Schema = *usersSchema
+ usersCollection.CreateRule = types.Pointer("")
+ if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" {
+ *profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id")
+ usersCollection.ListRule = profilesCollection.ListRule
+ }
+ if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" {
+ *profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id")
+ usersCollection.ViewRule = profilesCollection.ViewRule
+ }
+ if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" {
+ *profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id")
+ usersCollection.UpdateRule = profilesCollection.UpdateRule
+ }
+ if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" {
+ *profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id")
+ usersCollection.DeleteRule = profilesCollection.DeleteRule
+ }
+
+ // set auth options
+ settings := app.Settings()
+ authOptions := usersCollection.AuthOptions()
+ authOptions.ManageRule = nil
+ authOptions.AllowOAuth2Auth = true
+ authOptions.AllowUsernameAuth = false
+ authOptions.AllowEmailAuth = settings.EmailAuth.Enabled
+ authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength
+ authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains
+ authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains
+ // twitter currently is the only provider that doesn't return an email
+ authOptions.RequireEmail = !settings.TwitterAuth.Enabled
+
+ usersCollection.SetOptions(authOptions)
+
+ if err := txDao.SaveCollection(usersCollection); err != nil {
+ return err
+ }
+
+ // copy the original users
+ _, usersErr := txDao.DB().NewQuery(`
+ INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt)
+ SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt
+ FROM {{_users}};
+ `).Execute()
+ if usersErr != nil {
+ return usersErr
+ }
+
+ // generate the profile fields copy statements
+ sets := []string{"id = p.id"}
+ for _, f := range usersSchema.Fields() {
+ sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name))
+ }
+
+ // copy profile fields
+ _, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(`
+ UPDATE {{users}} as u
+ SET %s
+ FROM {{profiles}} as p
+ WHERE u.id = p.userId;
+ `, strings.Join(sets, ", "))).Execute()
+ if copyProfileErr != nil {
+ return copyProfileErr
+ }
+
+ profileRecords, err := txDao.FindRecordsByExpr("profiles")
+ if err != nil {
+ return err
+ }
+
+ // update all profiles and users fields to point to the new users collection
+ collections := []*models.Collection{}
+ if err := txDao.CollectionQuery().All(&collections); err != nil {
+ return err
+ }
+ for _, collection := range collections {
+ var hasChanges bool
+
+ for _, f := range collection.Schema.Fields() {
+ f.InitOptions()
+
+ if f.Type == schema.FieldTypeUser {
+ if collection.Name == "profiles" && f.Name == "userId" {
+ continue
+ }
+
+ hasChanges = true
+
+ // change the user field to a relation field
+ options, _ := f.Options.(*schema.UserOptions)
+ f.Type = schema.FieldTypeRelation
+ f.Options = &schema.RelationOptions{
+ CollectionId: usersCollection.Id,
+ MaxSelect: &options.MaxSelect,
+ CascadeDelete: options.CascadeDelete,
+ }
+
+ for _, p := range profileRecords {
+ pId := p.Id
+ pUserId := p.GetString("userId")
+ // replace all user record id references with the profile id
+ _, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(`
+ UPDATE %s
+ SET [[%s]] = REPLACE([[%s]], '%s', '%s')
+ WHERE [[%s]] LIKE ('%%%s%%');
+ `, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute()
+ if replaceErr != nil {
+ return replaceErr
+ }
+ }
+ }
+ }
+
+ if hasChanges {
+ if err := txDao.Save(collection); err != nil {
+ return err
+ }
+ }
+ }
+
+ if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil {
+ return err
+ }
+
+ // drop _users table
+ if _, err := txDao.DB().DropTable("_users").Execute(); err != nil {
+ return err
+ }
+
+ // drop profiles table
+ if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil {
+ return err
+ }
+
+ // delete profiles collection
+ if err := txDao.Delete(profilesCollection); err != nil {
+ return err
+ }
+
+ color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`)
+ fmt.Println()
+
+ return nil
+}
+
+func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error {
+ _, alterErr := txDao.DB().NewQuery(`
+ -- crate new externalAuths table
+ CREATE TABLE {{_newExternalAuths}} (
+ [[id]] TEXT PRIMARY KEY,
+ [[collectionId]] TEXT NOT NULL,
+ [[recordId]] TEXT NOT NULL,
+ [[provider]] TEXT NOT NULL,
+ [[providerId]] TEXT NOT NULL,
+ [[created]] TEXT DEFAULT "" NOT NULL,
+ [[updated]] TEXT DEFAULT "" NOT NULL,
+ ---
+ FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
+ );
+
+ -- copy all data from the old table to the new one
+ INSERT INTO {{_newExternalAuths}}
+ SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated
+ FROM {{_externalAuths}} auth
+ INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]];
+
+ -- drop old table
+ DROP TABLE {{_externalAuths}};
+
+ -- rename new table
+ ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}};
+
+ -- create named indexes
+ CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
+ CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
+ `).Execute()
+
+ return alterErr
+}
+
+func resetMigrationsTable(txDao *daos.Dao) error {
+ // reset the migration state to the new init
+ _, err := txDao.DB().Delete("_migrations", dbx.HashExp{
+ "file": "1661586591_add_externalAuths_table.go",
+ }).Execute()
+
+ return err
+}
+
+var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`)
+
+func replaceReversedLikes(rule string) string {
+ parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1)
+
+ for _, p := range parts {
+ if len(p) != 4 {
+ continue
+ }
+
+ newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1])
+
+ rule = strings.ReplaceAll(rule, p[0], newPart)
+ }
+
+ return rule
+}
diff --git a/core/app.go b/core/app.go
index 8e0ea52c..75dde9ed 100644
--- a/core/app.go
+++ b/core/app.go
@@ -126,38 +126,38 @@ type App interface {
// admin password reset email was successfully sent.
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
- // OnMailerBeforeUserResetPasswordSend hook is triggered right before
- // sending a password reset email to a user.
+ // OnMailerBeforeRecordResetPasswordSend hook is triggered right before
+ // sending a password reset email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
- OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
+ OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerAfterUserResetPasswordSend hook is triggered after
- // a user password reset email was successfully sent.
- OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
+ // OnMailerAfterRecordResetPasswordSend hook is triggered after
+ // an auth record password reset email was successfully sent.
+ OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerBeforeUserVerificationSend hook is triggered right before
- // sending a verification email to a user.
+ // OnMailerBeforeRecordVerificationSend hook is triggered right before
+ // sending a verification email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
- OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
+ OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerAfterUserVerificationSend hook is triggered after a user
- // verification email was successfully sent.
- OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]
+ // OnMailerAfterRecordVerificationSend hook is triggered after a
+ // verification email was successfully sent to an auth record.
+ OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerBeforeUserChangeEmailSend hook is triggered right before
- // sending a confirmation new address email to a a user.
+ // OnMailerBeforeRecordChangeEmailSend hook is triggered right before
+ // sending a confirmation new address email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
- OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
+ OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerAfterUserChangeEmailSend hook is triggered after a user
- // change address email was successfully sent.
- OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
+ // OnMailerAfterRecordChangeEmailSend hook is triggered after a
+ // verification email was successfully sent to an auth record.
+ OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
// ---------------------------------------------------------------
// Realtime API event hooks
@@ -264,74 +264,31 @@ type App interface {
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
// ---------------------------------------------------------------
- // User API event hooks
+ // Auth Record API event hooks
// ---------------------------------------------------------------
- // OnUsersListRequest hook is triggered on each API Users list request.
+ // OnRecordAuthRequest hook is triggered on each successful API
+ // record authentication request (sign-in, token refresh, etc.).
+ //
+ // Could be used to additionally validate or modify the authenticated
+ // record data and token.
+ OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
+
+ // OnRecordListExternalAuths hook is triggered on each API record external auths list request.
//
// Could be used to validate or modify the response before returning it to the client.
- OnUsersListRequest() *hook.Hook[*UsersListEvent]
+ OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent]
- // OnUserViewRequest hook is triggered on each API User view request.
- //
- // Could be used to validate or modify the response before returning it to the client.
- OnUserViewRequest() *hook.Hook[*UserViewEvent]
-
- // OnUserBeforeCreateRequest hook is triggered before each API User
- // create request (after request data load and before model persistence).
- //
- // Could be used to additionally validate the request data or implement
- // completely different persistence behavior (returning [hook.StopPropagation]).
- OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]
-
- // OnUserAfterCreateRequest hook is triggered after each
- // successful API User create request.
- OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent]
-
- // OnUserBeforeUpdateRequest hook is triggered before each API User
- // update request (after request data load and before model persistence).
- //
- // Could be used to additionally validate the request data or implement
- // completely different persistence behavior (returning [hook.StopPropagation]).
- OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]
-
- // OnUserAfterUpdateRequest hook is triggered after each
- // successful API User update request.
- OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent]
-
- // OnUserBeforeDeleteRequest hook is triggered before each API User
- // delete request (after model load and before actual deletion).
- //
- // Could be used to additionally validate the request data or implement
- // completely different delete behavior (returning [hook.StopPropagation]).
- OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]
-
- // OnUserAfterDeleteRequest hook is triggered after each
- // successful API User delete request.
- OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent]
-
- // OnUserAuthRequest hook is triggered on each successful API User
- // authentication request (sign-in, token refresh, etc.).
- //
- // Could be used to additionally validate or modify the
- // authenticated user data and token.
- OnUserAuthRequest() *hook.Hook[*UserAuthEvent]
-
- // OnUserListExternalAuths hook is triggered on each API user's external auths list request.
- //
- // Could be used to validate or modify the response before returning it to the client.
- OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent]
-
- // OnUserBeforeUnlinkExternalAuthRequest hook is triggered before each API user's
+ // OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
- OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
+ OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
- // OnUserAfterUnlinkExternalAuthRequest hook is triggered after each
- // successful API user's external auth unlink request.
- OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
+ // OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
+ // successful API record external auth unlink request.
+ OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
// ---------------------------------------------------------------
// Record API event hooks
diff --git a/core/base.go b/core/base.go
index 708fe965..5369ef92 100644
--- a/core/base.go
+++ b/core/base.go
@@ -52,14 +52,14 @@ type BaseApp struct {
onModelAfterDelete *hook.Hook[*ModelEvent]
// mailer event hooks
- onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
- onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
- onMailerBeforeUserResetPasswordSend *hook.Hook[*MailerUserEvent]
- onMailerAfterUserResetPasswordSend *hook.Hook[*MailerUserEvent]
- onMailerBeforeUserVerificationSend *hook.Hook[*MailerUserEvent]
- onMailerAfterUserVerificationSend *hook.Hook[*MailerUserEvent]
- onMailerBeforeUserChangeEmailSend *hook.Hook[*MailerUserEvent]
- onMailerAfterUserChangeEmailSend *hook.Hook[*MailerUserEvent]
+ onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
+ onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
+ onMailerBeforeRecordResetPasswordSend *hook.Hook[*MailerRecordEvent]
+ onMailerAfterRecordResetPasswordSend *hook.Hook[*MailerRecordEvent]
+ onMailerBeforeRecordVerificationSend *hook.Hook[*MailerRecordEvent]
+ onMailerAfterRecordVerificationSend *hook.Hook[*MailerRecordEvent]
+ onMailerBeforeRecordChangeEmailSend *hook.Hook[*MailerRecordEvent]
+ onMailerAfterRecordChangeEmailSend *hook.Hook[*MailerRecordEvent]
// realtime api event hooks
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectEvent]
@@ -85,19 +85,11 @@ type BaseApp struct {
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
- // user api event hooks
- onUsersListRequest *hook.Hook[*UsersListEvent]
- onUserViewRequest *hook.Hook[*UserViewEvent]
- onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent]
- onUserAfterCreateRequest *hook.Hook[*UserCreateEvent]
- onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent]
- onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent]
- onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent]
- onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent]
- onUserAuthRequest *hook.Hook[*UserAuthEvent]
- onUserListExternalAuths *hook.Hook[*UserListExternalAuthsEvent]
- onUserBeforeUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent]
- onUserAfterUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent]
+ // user api event hooks
+ onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
+ onRecordListExternalAuths *hook.Hook[*RecordListExternalAuthsEvent]
+ onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
+ onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
// record api event hooks
onRecordsListRequest *hook.Hook[*RecordsListEvent]
@@ -147,14 +139,14 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onModelAfterDelete: &hook.Hook[*ModelEvent]{},
// mailer event hooks
- onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
- onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
- onMailerBeforeUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{},
- onMailerAfterUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{},
- onMailerBeforeUserVerificationSend: &hook.Hook[*MailerUserEvent]{},
- onMailerAfterUserVerificationSend: &hook.Hook[*MailerUserEvent]{},
- onMailerBeforeUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{},
- onMailerAfterUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{},
+ onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
+ onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
+ onMailerBeforeRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{},
+ onMailerAfterRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{},
+ onMailerBeforeRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{},
+ onMailerAfterRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{},
+ onMailerBeforeRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{},
+ onMailerAfterRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{},
// realtime API event hooks
onRealtimeConnectRequest: &hook.Hook[*RealtimeConnectEvent]{},
@@ -181,18 +173,10 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
// user API event hooks
- onUsersListRequest: &hook.Hook[*UsersListEvent]{},
- onUserViewRequest: &hook.Hook[*UserViewEvent]{},
- onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{},
- onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{},
- onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
- onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
- onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
- onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
- onUserAuthRequest: &hook.Hook[*UserAuthEvent]{},
- onUserListExternalAuths: &hook.Hook[*UserListExternalAuthsEvent]{},
- onUserBeforeUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{},
- onUserAfterUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{},
+ onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
+ onRecordListExternalAuths: &hook.Hook[*RecordListExternalAuthsEvent]{},
+ onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
+ onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
// record API event hooks
onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
@@ -469,28 +453,28 @@ func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdmi
return app.onMailerAfterAdminResetPasswordSend
}
-func (app *BaseApp) OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerBeforeUserResetPasswordSend
+func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerBeforeRecordResetPasswordSend
}
-func (app *BaseApp) OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerAfterUserResetPasswordSend
+func (app *BaseApp) OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerAfterRecordResetPasswordSend
}
-func (app *BaseApp) OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerBeforeUserVerificationSend
+func (app *BaseApp) OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerBeforeRecordVerificationSend
}
-func (app *BaseApp) OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerAfterUserVerificationSend
+func (app *BaseApp) OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerAfterRecordVerificationSend
}
-func (app *BaseApp) OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerBeforeUserChangeEmailSend
+func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerBeforeRecordChangeEmailSend
}
-func (app *BaseApp) OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerAfterUserChangeEmailSend
+func (app *BaseApp) OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerAfterRecordChangeEmailSend
}
// -------------------------------------------------------------------
@@ -574,55 +558,23 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
}
// -------------------------------------------------------------------
-// User API event hooks
+// Auth Record API event hooks
// -------------------------------------------------------------------
-func (app *BaseApp) OnUsersListRequest() *hook.Hook[*UsersListEvent] {
- return app.onUsersListRequest
+func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
+ return app.onRecordAuthRequest
}
-func (app *BaseApp) OnUserViewRequest() *hook.Hook[*UserViewEvent] {
- return app.onUserViewRequest
+func (app *BaseApp) OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent] {
+ return app.onRecordListExternalAuths
}
-func (app *BaseApp) OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent] {
- return app.onUserBeforeCreateRequest
+func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
+ return app.onRecordBeforeUnlinkExternalAuthRequest
}
-func (app *BaseApp) OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent] {
- return app.onUserAfterCreateRequest
-}
-
-func (app *BaseApp) OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent] {
- return app.onUserBeforeUpdateRequest
-}
-
-func (app *BaseApp) OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent] {
- return app.onUserAfterUpdateRequest
-}
-
-func (app *BaseApp) OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent] {
- return app.onUserBeforeDeleteRequest
-}
-
-func (app *BaseApp) OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent] {
- return app.onUserAfterDeleteRequest
-}
-
-func (app *BaseApp) OnUserAuthRequest() *hook.Hook[*UserAuthEvent] {
- return app.onUserAuthRequest
-}
-
-func (app *BaseApp) OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent] {
- return app.onUserListExternalAuths
-}
-
-func (app *BaseApp) OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] {
- return app.onUserBeforeUnlinkExternalAuthRequest
-}
-
-func (app *BaseApp) OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] {
- return app.onUserAfterUnlinkExternalAuthRequest
+func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
+ return app.onRecordAfterUnlinkExternalAuthRequest
}
// -------------------------------------------------------------------
diff --git a/core/base_test.go b/core/base_test.go
index c09f6638..dfa04261 100644
--- a/core/base_test.go
+++ b/core/base_test.go
@@ -195,28 +195,28 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend)
}
- if app.onMailerBeforeUserResetPasswordSend != app.OnMailerBeforeUserResetPasswordSend() || app.OnMailerBeforeUserResetPasswordSend() == nil {
- t.Fatalf("Getter app.OnMailerBeforeUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserResetPasswordSend(), app.onMailerBeforeUserResetPasswordSend)
+ if app.onMailerBeforeRecordResetPasswordSend != app.OnMailerBeforeRecordResetPasswordSend() || app.OnMailerBeforeRecordResetPasswordSend() == nil {
+ t.Fatalf("Getter app.OnMailerBeforeRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordResetPasswordSend(), app.onMailerBeforeRecordResetPasswordSend)
}
- if app.onMailerAfterUserResetPasswordSend != app.OnMailerAfterUserResetPasswordSend() || app.OnMailerAfterUserResetPasswordSend() == nil {
- t.Fatalf("Getter app.OnMailerAfterUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterUserResetPasswordSend(), app.onMailerAfterUserResetPasswordSend)
+ if app.onMailerAfterRecordResetPasswordSend != app.OnMailerAfterRecordResetPasswordSend() || app.OnMailerAfterRecordResetPasswordSend() == nil {
+ t.Fatalf("Getter app.OnMailerAfterRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordResetPasswordSend(), app.onMailerAfterRecordResetPasswordSend)
}
- if app.onMailerBeforeUserVerificationSend != app.OnMailerBeforeUserVerificationSend() || app.OnMailerBeforeUserVerificationSend() == nil {
- t.Fatalf("Getter app.OnMailerBeforeUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserVerificationSend(), app.onMailerBeforeUserVerificationSend)
+ if app.onMailerBeforeRecordVerificationSend != app.OnMailerBeforeRecordVerificationSend() || app.OnMailerBeforeRecordVerificationSend() == nil {
+ t.Fatalf("Getter app.OnMailerBeforeRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordVerificationSend(), app.onMailerBeforeRecordVerificationSend)
}
- if app.onMailerAfterUserVerificationSend != app.OnMailerAfterUserVerificationSend() || app.OnMailerAfterUserVerificationSend() == nil {
- t.Fatalf("Getter app.OnMailerAfterUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterUserVerificationSend(), app.onMailerAfterUserVerificationSend)
+ if app.onMailerAfterRecordVerificationSend != app.OnMailerAfterRecordVerificationSend() || app.OnMailerAfterRecordVerificationSend() == nil {
+ t.Fatalf("Getter app.OnMailerAfterRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordVerificationSend(), app.onMailerAfterRecordVerificationSend)
}
- if app.onMailerBeforeUserChangeEmailSend != app.OnMailerBeforeUserChangeEmailSend() || app.OnMailerBeforeUserChangeEmailSend() == nil {
- t.Fatalf("Getter app.OnMailerBeforeUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserChangeEmailSend(), app.onMailerBeforeUserChangeEmailSend)
+ if app.onMailerBeforeRecordChangeEmailSend != app.OnMailerBeforeRecordChangeEmailSend() || app.OnMailerBeforeRecordChangeEmailSend() == nil {
+ t.Fatalf("Getter app.OnMailerBeforeRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordChangeEmailSend(), app.onMailerBeforeRecordChangeEmailSend)
}
- if app.onMailerAfterUserChangeEmailSend != app.OnMailerAfterUserChangeEmailSend() || app.OnMailerAfterUserChangeEmailSend() == nil {
- t.Fatalf("Getter app.OnMailerAfterUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterUserChangeEmailSend(), app.onMailerAfterUserChangeEmailSend)
+ if app.onMailerAfterRecordChangeEmailSend != app.OnMailerAfterRecordChangeEmailSend() || app.OnMailerAfterRecordChangeEmailSend() == nil {
+ t.Fatalf("Getter app.OnMailerAfterRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordChangeEmailSend(), app.onMailerAfterRecordChangeEmailSend)
}
if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil {
@@ -283,52 +283,52 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest)
}
- if app.onUsersListRequest != app.OnUsersListRequest() || app.OnUsersListRequest() == nil {
- t.Fatalf("Getter app.OnUsersListRequest does not match or nil (%v vs %v)", app.OnUsersListRequest(), app.onUsersListRequest)
+ if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
+ t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest)
}
- if app.onUserViewRequest != app.OnUserViewRequest() || app.OnUserViewRequest() == nil {
- t.Fatalf("Getter app.OnUserViewRequest does not match or nil (%v vs %v)", app.OnUserViewRequest(), app.onUserViewRequest)
+ if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil {
+ t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest)
}
- if app.onUserBeforeCreateRequest != app.OnUserBeforeCreateRequest() || app.OnUserBeforeCreateRequest() == nil {
- t.Fatalf("Getter app.OnUserBeforeCreateRequest does not match or nil (%v vs %v)", app.OnUserBeforeCreateRequest(), app.onUserBeforeCreateRequest)
+ if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil {
+ t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest)
}
- if app.onUserAfterCreateRequest != app.OnUserAfterCreateRequest() || app.OnUserAfterCreateRequest() == nil {
- t.Fatalf("Getter app.OnUserAfterCreateRequest does not match or nil (%v vs %v)", app.OnUserAfterCreateRequest(), app.onUserAfterCreateRequest)
+ if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil {
+ t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest)
}
- if app.onUserBeforeUpdateRequest != app.OnUserBeforeUpdateRequest() || app.OnUserBeforeUpdateRequest() == nil {
- t.Fatalf("Getter app.OnUserBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnUserBeforeUpdateRequest(), app.onUserBeforeUpdateRequest)
+ if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil {
+ t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest)
}
- if app.onUserAfterUpdateRequest != app.OnUserAfterUpdateRequest() || app.OnUserAfterUpdateRequest() == nil {
- t.Fatalf("Getter app.OnUserAfterUpdateRequest does not match or nil (%v vs %v)", app.OnUserAfterUpdateRequest(), app.onUserAfterUpdateRequest)
+ if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil {
+ t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest)
}
- if app.onUserBeforeDeleteRequest != app.OnUserBeforeDeleteRequest() || app.OnUserBeforeDeleteRequest() == nil {
- t.Fatalf("Getter app.OnUserBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnUserBeforeDeleteRequest(), app.onUserBeforeDeleteRequest)
+ if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil {
+ t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest)
}
- if app.onUserAfterDeleteRequest != app.OnUserAfterDeleteRequest() || app.OnUserAfterDeleteRequest() == nil {
- t.Fatalf("Getter app.OnUserAfterDeleteRequest does not match or nil (%v vs %v)", app.OnUserAfterDeleteRequest(), app.onUserAfterDeleteRequest)
+ if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil {
+ t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest)
}
- if app.onUserAuthRequest != app.OnUserAuthRequest() || app.OnUserAuthRequest() == nil {
- t.Fatalf("Getter app.OnUserAuthRequest does not match or nil (%v vs %v)", app.OnUserAuthRequest(), app.onUserAuthRequest)
+ if app.onRecordAuthRequest != app.OnRecordAuthRequest() || app.OnRecordAuthRequest() == nil {
+ t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest)
}
- if app.onUserListExternalAuths != app.OnUserListExternalAuths() || app.OnUserListExternalAuths() == nil {
- t.Fatalf("Getter app.OnUserListExternalAuths does not match or nil (%v vs %v)", app.OnUserListExternalAuths(), app.onUserListExternalAuths)
+ if app.onRecordListExternalAuths != app.OnRecordListExternalAuths() || app.OnRecordListExternalAuths() == nil {
+ t.Fatalf("Getter app.OnRecordListExternalAuths does not match or nil (%v vs %v)", app.OnRecordListExternalAuths(), app.onRecordListExternalAuths)
}
- if app.onUserBeforeUnlinkExternalAuthRequest != app.OnUserBeforeUnlinkExternalAuthRequest() || app.OnUserBeforeUnlinkExternalAuthRequest() == nil {
- t.Fatalf("Getter app.OnUserBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserBeforeUnlinkExternalAuthRequest(), app.onUserBeforeUnlinkExternalAuthRequest)
+ if app.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.onUserAfterUnlinkExternalAuthRequest != app.OnUserAfterUnlinkExternalAuthRequest() || app.OnUserAfterUnlinkExternalAuthRequest() == nil {
- t.Fatalf("Getter app.OnUserAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserAfterUnlinkExternalAuthRequest(), app.onUserAfterUnlinkExternalAuthRequest)
+ if app.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 {
diff --git a/core/events.go b/core/events.go
index 11f88534..e2c55bec 100644
--- a/core/events.go
+++ b/core/events.go
@@ -33,9 +33,9 @@ type ModelEvent struct {
// Mailer events data
// -------------------------------------------------------------------
-type MailerUserEvent struct {
+type MailerRecordEvent struct {
MailClient mailer.Mailer
- User *models.User
+ Record *models.Record
Meta map[string]any
}
@@ -143,51 +143,25 @@ type AdminAuthEvent struct {
}
// -------------------------------------------------------------------
-// User API events data
+// Auth Record API events data
// -------------------------------------------------------------------
-type UsersListEvent struct {
+type RecordAuthEvent struct {
HttpContext echo.Context
- Users []*models.User
- Result *search.Result
-}
-
-type UserViewEvent struct {
- HttpContext echo.Context
- User *models.User
-}
-
-type UserCreateEvent struct {
- HttpContext echo.Context
- User *models.User
-}
-
-type UserUpdateEvent struct {
- HttpContext echo.Context
- User *models.User
-}
-
-type UserDeleteEvent struct {
- HttpContext echo.Context
- User *models.User
-}
-
-type UserAuthEvent struct {
- HttpContext echo.Context
- User *models.User
+ Record *models.Record
Token string
Meta any
}
-type UserListExternalAuthsEvent struct {
+type RecordListExternalAuthsEvent struct {
HttpContext echo.Context
- User *models.User
+ Record *models.Record
ExternalAuths []*models.ExternalAuth
}
-type UserUnlinkExternalAuthEvent struct {
+type RecordUnlinkExternalAuthEvent struct {
HttpContext echo.Context
- User *models.User
+ Record *models.Record
ExternalAuth *models.ExternalAuth
}
diff --git a/core/settings.go b/core/settings.go
index 4a592855..d87518c0 100644
--- a/core/settings.go
+++ b/core/settings.go
@@ -23,14 +23,16 @@ type Settings struct {
Smtp SmtpConfig `form:"smtp" json:"smtp"`
S3 S3Config `form:"s3" json:"s3"`
- AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
- AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
- UserAuthToken TokenConfig `form:"userAuthToken" json:"userAuthToken"`
- UserPasswordResetToken TokenConfig `form:"userPasswordResetToken" json:"userPasswordResetToken"`
- UserEmailChangeToken TokenConfig `form:"userEmailChangeToken" json:"userEmailChangeToken"`
- UserVerificationToken TokenConfig `form:"userVerificationToken" json:"userVerificationToken"`
+ AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
+ AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
+ RecordAuthToken TokenConfig `form:"recordAuthToken" json:"recordAuthToken"`
+ RecordPasswordResetToken TokenConfig `form:"recordPasswordResetToken" json:"recordPasswordResetToken"`
+ RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
+ RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
+
+ // Deprecated: Will be removed in v0.9!
+ EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
- EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"`
GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"`
@@ -52,9 +54,8 @@ func NewSettings() *Settings {
ResetPasswordTemplate: defaultResetPasswordTemplate,
ConfirmEmailChangeTemplate: defaultConfirmEmailChangeTemplate,
},
-
Logs: LogsConfig{
- MaxDays: 7,
+ MaxDays: 5,
},
Smtp: SmtpConfig{
Enabled: false,
@@ -72,49 +73,39 @@ func NewSettings() *Settings {
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
- UserAuthToken: TokenConfig{
+ RecordAuthToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1209600, // 14 days,
},
- UserPasswordResetToken: TokenConfig{
+ RecordPasswordResetToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
- UserVerificationToken: TokenConfig{
+ RecordVerificationToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 604800, // 7 days,
},
- UserEmailChangeToken: TokenConfig{
+ RecordEmailChangeToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
- EmailAuth: EmailAuthConfig{
- Enabled: true,
- MinPasswordLength: 8,
- },
GoogleAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
FacebookAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
GithubAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
GitlabAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
DiscordAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
TwitterAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
}
}
@@ -129,13 +120,12 @@ func (s *Settings) Validate() error {
validation.Field(&s.Logs),
validation.Field(&s.AdminAuthToken),
validation.Field(&s.AdminPasswordResetToken),
- validation.Field(&s.UserAuthToken),
- validation.Field(&s.UserPasswordResetToken),
- validation.Field(&s.UserEmailChangeToken),
- validation.Field(&s.UserVerificationToken),
+ validation.Field(&s.RecordAuthToken),
+ validation.Field(&s.RecordPasswordResetToken),
+ validation.Field(&s.RecordEmailChangeToken),
+ validation.Field(&s.RecordVerificationToken),
validation.Field(&s.Smtp),
validation.Field(&s.S3),
- validation.Field(&s.EmailAuth),
validation.Field(&s.GoogleAuth),
validation.Field(&s.FacebookAuth),
validation.Field(&s.GithubAuth),
@@ -182,10 +172,10 @@ func (s *Settings) RedactClone() (*Settings, error) {
&clone.S3.Secret,
&clone.AdminAuthToken.Secret,
&clone.AdminPasswordResetToken.Secret,
- &clone.UserAuthToken.Secret,
- &clone.UserPasswordResetToken.Secret,
- &clone.UserEmailChangeToken.Secret,
- &clone.UserVerificationToken.Secret,
+ &clone.RecordAuthToken.Secret,
+ &clone.RecordPasswordResetToken.Secret,
+ &clone.RecordEmailChangeToken.Secret,
+ &clone.RecordVerificationToken.Secret,
&clone.GoogleAuth.ClientSecret,
&clone.FacebookAuth.ClientSecret,
&clone.GithubAuth.ClientSecret,
@@ -407,43 +397,13 @@ func (c LogsConfig) Validate() error {
// -------------------------------------------------------------------
-type EmailAuthConfig struct {
- Enabled bool `form:"enabled" json:"enabled"`
- ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
- OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
- MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
-}
-
-// Validate makes `EmailAuthConfig` validatable by implementing [validation.Validatable] interface.
-func (c EmailAuthConfig) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(
- &c.ExceptDomains,
- validation.When(len(c.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- validation.Field(
- &c.OnlyDomains,
- validation.When(len(c.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- validation.Field(
- &c.MinPasswordLength,
- validation.When(c.Enabled, validation.Required),
- validation.Min(5),
- validation.Max(100),
- ),
- )
-}
-
-// -------------------------------------------------------------------
-
type AuthProviderConfig struct {
- Enabled bool `form:"enabled" json:"enabled"`
- AllowRegistrations bool `form:"allowRegistrations" json:"allowRegistrations"`
- ClientId string `form:"clientId" json:"clientId,omitempty"`
- ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
- AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
- TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
- UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
+ Enabled bool `form:"enabled" json:"enabled"`
+ ClientId string `form:"clientId" json:"clientId,omitempty"`
+ ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
+ AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
+ TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
+ UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
}
// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.
@@ -485,3 +445,18 @@ func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
return nil
}
+
+// -------------------------------------------------------------------
+
+// Deprecated: Will be removed in v0.9!
+type EmailAuthConfig struct {
+ Enabled bool `form:"enabled" json:"enabled"`
+ ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
+ OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
+ MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
+}
+
+// Deprecated: Will be removed in v0.9!
+func (c EmailAuthConfig) Validate() error {
+ return nil
+}
diff --git a/core/settings_templates.go b/core/settings_templates.go
index 23c0083c..006e56e0 100644
--- a/core/settings_templates.go
+++ b/core/settings_templates.go
@@ -20,7 +20,7 @@ var defaultVerificationTemplate = EmailTemplate{
Thanks,
` + EmailPlaceholderAppName + ` team
`,
- ActionUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-verification/" + EmailPlaceholderToken,
+ ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-verification/" + EmailPlaceholderToken,
}
var defaultResetPasswordTemplate = EmailTemplate{
@@ -35,7 +35,7 @@ var defaultResetPasswordTemplate = EmailTemplate{
Thanks,
` + EmailPlaceholderAppName + ` team
`,
- ActionUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-password-reset/" + EmailPlaceholderToken,
+ ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-password-reset/" + EmailPlaceholderToken,
}
var defaultConfirmEmailChangeTemplate = EmailTemplate{
@@ -50,5 +50,5 @@ var defaultConfirmEmailChangeTemplate = EmailTemplate{
Thanks,
` + EmailPlaceholderAppName + ` team
`,
- ActionUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-email-change/" + EmailPlaceholderToken,
+ ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-email-change/" + EmailPlaceholderToken,
}
diff --git a/core/settings_test.go b/core/settings_test.go
index 01ccbae0..cd19b45d 100644
--- a/core/settings_test.go
+++ b/core/settings_test.go
@@ -23,12 +23,10 @@ func TestSettingsValidate(t *testing.T) {
s.S3.Endpoint = "invalid"
s.AdminAuthToken.Duration = -10
s.AdminPasswordResetToken.Duration = -10
- s.UserAuthToken.Duration = -10
- s.UserPasswordResetToken.Duration = -10
- s.UserEmailChangeToken.Duration = -10
- s.UserVerificationToken.Duration = -10
- s.EmailAuth.Enabled = true
- s.EmailAuth.MinPasswordLength = -10
+ s.RecordAuthToken.Duration = -10
+ s.RecordPasswordResetToken.Duration = -10
+ s.RecordEmailChangeToken.Duration = -10
+ s.RecordVerificationToken.Duration = -10
s.GoogleAuth.Enabled = true
s.GoogleAuth.ClientId = ""
s.FacebookAuth.Enabled = true
@@ -55,16 +53,16 @@ func TestSettingsValidate(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
- `"userAuthToken":{`,
- `"userPasswordResetToken":{`,
- `"userEmailChangeToken":{`,
- `"userVerificationToken":{`,
- `"emailAuth":{`,
+ `"recordAuthToken":{`,
+ `"recordPasswordResetToken":{`,
+ `"recordEmailChangeToken":{`,
+ `"recordVerificationToken":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
`"discordAuth":{`,
+ `"twitterAuth":{`,
}
errBytes, _ := json.Marshal(err)
@@ -89,12 +87,10 @@ func TestSettingsMerge(t *testing.T) {
s2.S3.Endpoint = "test"
s2.AdminAuthToken.Duration = 1
s2.AdminPasswordResetToken.Duration = 2
- s2.UserAuthToken.Duration = 3
- s2.UserPasswordResetToken.Duration = 4
- s2.UserEmailChangeToken.Duration = 5
- s2.UserVerificationToken.Duration = 6
- s2.EmailAuth.Enabled = false
- s2.EmailAuth.MinPasswordLength = 30
+ s2.RecordAuthToken.Duration = 3
+ s2.RecordPasswordResetToken.Duration = 4
+ s2.RecordEmailChangeToken.Duration = 5
+ s2.RecordVerificationToken.Duration = 6
s2.GoogleAuth.Enabled = true
s2.GoogleAuth.ClientId = "google_test"
s2.FacebookAuth.Enabled = true
@@ -164,10 +160,10 @@ func TestSettingsRedactClone(t *testing.T) {
s1.S3.Secret = "test123"
s1.AdminAuthToken.Secret = "test123"
s1.AdminPasswordResetToken.Secret = "test123"
- s1.UserAuthToken.Secret = "test123"
- s1.UserPasswordResetToken.Secret = "test123"
- s1.UserEmailChangeToken.Secret = "test123"
- s1.UserVerificationToken.Secret = "test123"
+ s1.RecordAuthToken.Secret = "test123"
+ s1.RecordPasswordResetToken.Secret = "test123"
+ s1.RecordEmailChangeToken.Secret = "test123"
+ s1.RecordVerificationToken.Secret = "test123"
s1.GoogleAuth.ClientSecret = "test123"
s1.FacebookAuth.ClientSecret = "test123"
s1.GithubAuth.ClientSecret = "test123"
@@ -185,10 +181,10 @@ func TestSettingsRedactClone(t *testing.T) {
t.Fatal(err)
}
- expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/users/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/users/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/users/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"discordAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"twitterAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}`
+ expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"}}`
if encodedStr := string(encoded); encodedStr != expected {
- t.Fatalf("Expected %v, got \n%v", expected, encodedStr)
+ t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr)
}
}
@@ -210,10 +206,10 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
t.Fatal(err)
}
- expected := `{"discord":{"enabled":false,"allowRegistrations":true,"clientId":"discord_test"},"facebook":{"enabled":false,"allowRegistrations":true,"clientId":"facebook_test"},"github":{"enabled":false,"allowRegistrations":true,"clientId":"github_test"},"gitlab":{"enabled":true,"allowRegistrations":true,"clientId":"gitlab_test"},"google":{"enabled":false,"allowRegistrations":true,"clientId":"google_test"},"twitter":{"enabled":false,"allowRegistrations":true,"clientId":"twitter_test"}}`
+ expected := `{"discord":{"enabled":false,"clientId":"discord_test"},"facebook":{"enabled":false,"clientId":"facebook_test"},"github":{"enabled":false,"clientId":"github_test"},"gitlab":{"enabled":true,"clientId":"gitlab_test"},"google":{"enabled":false,"clientId":"google_test"},"twitter":{"enabled":false,"clientId":"twitter_test"}}`
if encodedStr := string(encoded); encodedStr != expected {
- t.Fatalf("Expected the same serialization, got %v", encodedStr)
+ t.Fatalf("Expected the same serialization, got \n%v", encodedStr)
}
}
@@ -701,83 +697,24 @@ func TestAuthProviderConfigSetupProvider(t *testing.T) {
if err := c2.SetupProvider(provider); err != nil {
t.Error(err)
}
- encoded, _ := json.Marshal(c2)
- expected := `{"enabled":true,"allowRegistrations":false,"clientId":"test_ClientId","clientSecret":"test_ClientSecret","authUrl":"test_AuthUrl","tokenUrl":"test_TokenUrl","userApiUrl":"test_UserApiUrl"}`
- if string(encoded) != expected {
- t.Errorf("Expected %s, got %s", expected, string(encoded))
- }
-}
-
-func TestEmailAuthConfigValidate(t *testing.T) {
- scenarios := []struct {
- config core.EmailAuthConfig
- expectError bool
- }{
- // zero values (disabled)
- {
- core.EmailAuthConfig{},
- false,
- },
- // zero values (enabled)
- {
- core.EmailAuthConfig{Enabled: true},
- true,
- },
- // invalid data (only the required)
- {
- core.EmailAuthConfig{
- Enabled: true,
- MinPasswordLength: 4,
- },
- true,
- },
- // valid data (only the required)
- {
- core.EmailAuthConfig{
- Enabled: true,
- MinPasswordLength: 5,
- },
- false,
- },
- // invalid data (both OnlyDomains and ExceptDomains set)
- {
- core.EmailAuthConfig{
- Enabled: true,
- MinPasswordLength: 5,
- OnlyDomains: []string{"example.com", "test.com"},
- ExceptDomains: []string{"example.com", "test.com"},
- },
- true,
- },
- // valid data (only onlyDomains set)
- {
- core.EmailAuthConfig{
- Enabled: true,
- MinPasswordLength: 5,
- OnlyDomains: []string{"example.com", "test.com"},
- },
- false,
- },
- // valid data (only exceptDomains set)
- {
- core.EmailAuthConfig{
- Enabled: true,
- MinPasswordLength: 5,
- ExceptDomains: []string{"example.com", "test.com"},
- },
- false,
- },
- }
-
- for i, scenario := range scenarios {
- result := scenario.config.Validate()
-
- if result != nil && !scenario.expectError {
- t.Errorf("(%d) Didn't expect error, got %v", i, result)
- }
-
- if result == nil && scenario.expectError {
- t.Errorf("(%d) Expected error, got nil", i)
- }
+
+ if provider.ClientId() != c2.ClientId {
+ t.Fatalf("Expected ClientId %s, got %s", c2.ClientId, provider.ClientId())
+ }
+
+ if provider.ClientSecret() != c2.ClientSecret {
+ t.Fatalf("Expected ClientSecret %s, got %s", c2.ClientSecret, provider.ClientSecret())
+ }
+
+ if provider.AuthUrl() != c2.AuthUrl {
+ t.Fatalf("Expected AuthUrl %s, got %s", c2.AuthUrl, provider.AuthUrl())
+ }
+
+ if provider.UserApiUrl() != c2.UserApiUrl {
+ t.Fatalf("Expected UserApiUrl %s, got %s", c2.UserApiUrl, provider.UserApiUrl())
+ }
+
+ if provider.TokenUrl() != c2.TokenUrl {
+ t.Fatalf("Expected TokenUrl %s, got %s", c2.TokenUrl, provider.TokenUrl())
}
}
diff --git a/daos/admin.go b/daos/admin.go
index 86ad77ac..3a000023 100644
--- a/daos/admin.go
+++ b/daos/admin.go
@@ -5,6 +5,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -49,6 +50,7 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
//
// Returns an error if the JWT token is invalid or expired.
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
+ // @todo consider caching the unverified claims
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
return nil, err
@@ -86,20 +88,22 @@ func (dao *Dao) TotalAdmins() (int, error) {
// IsAdminEmailUnique checks if the provided email address is not
// already in use by other admins.
-func (dao *Dao) IsAdminEmailUnique(email string, excludeId string) bool {
+func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
if email == "" {
return false
}
- var exists bool
- err := dao.AdminQuery().
- Select("count(*)").
- AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
+ query := dao.AdminQuery().Select("count(*)").
AndWhere(dbx.HashExp{"email": email}).
- Limit(1).
- Row(&exists)
+ Limit(1)
- return err == nil && !exists
+ if len(excludeIds) > 0 {
+ query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...))
+ }
+
+ var exists bool
+
+ return query.Row(&exists) == nil && !exists
}
// DeleteAdmin deletes the provided Admin model.
diff --git a/daos/admin_test.go b/daos/admin_test.go
index 4f56b7e5..d49f5972 100644
--- a/daos/admin_test.go
+++ b/daos/admin_test.go
@@ -27,8 +27,9 @@ func TestFindAdminById(t *testing.T) {
id string
expectError bool
}{
- {"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
- {"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", false},
+ {" ", true},
+ {"missing", true},
+ {"9q2trqumvlyr3bd", false},
}
for i, scenario := range scenarios {
@@ -53,6 +54,7 @@ func TestFindAdminByEmail(t *testing.T) {
email string
expectError bool
}{
+ {"", true},
{"invalid", true},
{"missing@example.com", true},
{"test@example.com", false},
@@ -83,23 +85,30 @@ func TestFindAdminByToken(t *testing.T) {
expectedEmail string
expectError bool
}{
- // invalid base key (password reset key for auth token)
+ // invalid auth token
{
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- app.Settings().AdminPasswordResetToken.Secret,
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY",
+ app.Settings().AdminAuthToken.Secret,
"",
true,
},
// expired token
{
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.uXZ_ywsZeRFSvDNQ9zBoYUXKXw7VEr48Fzx-E06OkS8",
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
app.Settings().AdminAuthToken.Secret,
"",
true,
},
+ // wrong base token (password reset token secret instead of auth secret)
+ {
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ app.Settings().AdminPasswordResetToken.Secret,
+ "",
+ true,
+ },
// valid token
{
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
app.Settings().AdminAuthToken.Secret,
"test@example.com",
false,
@@ -129,8 +138,8 @@ func TestTotalAdmins(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if result1 != 2 {
- t.Fatalf("Expected 2 admins, got %d", result1)
+ if result1 != 3 {
+ t.Fatalf("Expected 3 admins, got %d", result1)
}
// delete all
@@ -156,8 +165,10 @@ func TestIsAdminEmailUnique(t *testing.T) {
}{
{"", "", false},
{"test@example.com", "", false},
+ {"test2@example.com", "", false},
+ {"test3@example.com", "", false},
{"new@example.com", "", true},
- {"test@example.com", "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", true},
+ {"test@example.com", "sywbhecnh46rhm0", true},
}
for i, scenario := range scenarios {
@@ -186,15 +197,24 @@ func TestDeleteAdmin(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ admin3, err := app.Dao().FindAdminByEmail("test3@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
deleteErr1 := app.Dao().DeleteAdmin(admin1)
if deleteErr1 != nil {
t.Fatal(deleteErr1)
}
- // cannot delete the only remaining admin
deleteErr2 := app.Dao().DeleteAdmin(admin2)
- if deleteErr2 == nil {
+ if deleteErr2 != nil {
+ t.Fatal(deleteErr2)
+ }
+
+ // cannot delete the only remaining admin
+ deleteErr3 := app.Dao().DeleteAdmin(admin3)
+ if deleteErr3 == nil {
t.Fatal("Expected delete error, got nil")
}
diff --git a/daos/base_test.go b/daos/base_test.go
index 3b5ed45f..37f45e3e 100644
--- a/daos/base_test.go
+++ b/daos/base_test.go
@@ -35,8 +35,8 @@ func TestDaoModelQuery(t *testing.T) {
"SELECT {{_collections}}.* FROM `_collections`",
},
{
- &models.User{},
- "SELECT {{_users}}.* FROM `_users`",
+ &models.Admin{},
+ "SELECT {{_admins}}.* FROM `_admins`",
},
{
&models.Request{},
@@ -64,19 +64,19 @@ func TestDaoFindById(t *testing.T) {
// missing id
{
&models.Collection{},
- "00000000-075d-49fe-9d09-ea7e951000dc",
+ "missing",
true,
},
// existing collection id
{
&models.Collection{},
- "3f2888f8-075d-49fe-9d09-ea7e951000dc",
+ "wsmn24bux7wo113",
false,
},
- // existing user id
+ // existing admin id
{
- &models.User{},
- "97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
+ &models.Admin{},
+ "sbmbsdb40jyxf7h",
false,
},
}
diff --git a/daos/collection.go b/daos/collection.go
index 104cf887..c7c59155 100644
--- a/daos/collection.go
+++ b/daos/collection.go
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/list"
)
// CollectionQuery returns a new Collection select query.
@@ -15,6 +16,22 @@ func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.Collection{})
}
+// FindCollectionsByType finds all collections by the given type
+func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) {
+ models := []*models.Collection{}
+
+ err := dao.CollectionQuery().
+ AndWhere(dbx.HashExp{"type": collectionType}).
+ OrderBy("created ASC").
+ All(&models)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return models, nil
+}
+
// FindCollectionByNameOrId finds the first collection by its name or id.
func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) {
model := &models.Collection{}
@@ -38,38 +55,24 @@ func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, e
// with the provided name (case insensitive!).
//
// Note: case sensitive check because the name is used also as a table name for the records.
-func (dao *Dao) IsCollectionNameUnique(name string, excludeId string) bool {
+func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" {
return false
}
- var exists bool
- err := dao.CollectionQuery().
+ query := dao.CollectionQuery().
Select("count(*)").
- AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
- Limit(1).
- Row(&exists)
+ Limit(1)
- return err == nil && !exists
-}
+ if len(excludeIds) > 0 {
+ uniqueExcludeIds := list.NonzeroUniques(excludeIds)
+ query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
+ }
-// FindCollectionsWithUserFields finds all collections that has
-// at least one user schema field.
-func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
- result := []*models.Collection{}
+ var exists bool
- err := dao.CollectionQuery().
- InnerJoin(
- "json_each(schema) as jsonField",
- dbx.NewExp(
- "json_extract(jsonField.value, '$.type') = {:type}",
- dbx.Params{"type": schema.FieldTypeUser},
- ),
- ).
- All(&result)
-
- return result, err
+ return query.Row(&exists) == nil && !exists
}
// FindCollectionReferences returns information for all
@@ -78,13 +81,15 @@ func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
// If the provided collection has reference to itself then it will be
// also included in the result. To exclude it, pass the collection id
// as the excludeId argument.
-func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeId string) (map[*models.Collection][]*schema.SchemaField, error) {
+func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeIds ...string) (map[*models.Collection][]*schema.SchemaField, error) {
collections := []*models.Collection{}
- err := dao.CollectionQuery().
- AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
- All(&collections)
- if err != nil {
+ query := dao.CollectionQuery()
+ if len(excludeIds) > 0 {
+ uniqueExcludeIds := list.NonzeroUniques(excludeIds)
+ query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
+ }
+ if err := query.All(&collections); err != nil {
return nil, err
}
@@ -152,6 +157,11 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
}
return dao.RunInTransaction(func(txDao *Dao) error {
+ // set default collection type
+ if collection.Type == "" {
+ collection.Type = models.CollectionTypeBase
+ }
+
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
@@ -196,6 +206,11 @@ func (dao *Dao) ImportCollections(
imported.RefreshId()
}
+ // set default type if missing
+ if imported.Type == "" {
+ imported.Type = models.CollectionTypeBase
+ }
+
if existing, ok := mappedExisting[imported.GetId()]; ok {
// preserve original created date
if !existing.Created.IsZero() {
diff --git a/daos/collection_test.go b/daos/collection_test.go
index 0a4ac10e..a58980f8 100644
--- a/daos/collection_test.go
+++ b/daos/collection_test.go
@@ -24,6 +24,41 @@ func TestCollectionQuery(t *testing.T) {
}
}
+func TestFindCollectionsByType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionType string
+ expectError bool
+ expectTotal int
+ }{
+ {"", false, 0},
+ {"unknown", false, 0},
+ {models.CollectionTypeAuth, false, 3},
+ {models.CollectionTypeBase, false, 4},
+ }
+
+ for i, scenario := range scenarios {
+ collections, err := app.Dao().FindCollectionsByType(scenario.collectionType)
+
+ hasErr := err != nil
+ if hasErr != scenario.expectError {
+ t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
+ }
+
+ if len(collections) != scenario.expectTotal {
+ t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
+ }
+
+ for _, c := range collections {
+ if c.Type != scenario.collectionType {
+ t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
+ }
+ }
+ }
+}
+
func TestFindCollectionByNameOrId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -34,9 +69,8 @@ func TestFindCollectionByNameOrId(t *testing.T) {
}{
{"", true},
{"missing", true},
- {"00000000-075d-49fe-9d09-ea7e951000dc", true},
- {"3f2888f8-075d-49fe-9d09-ea7e951000dc", false},
- {"demo", false},
+ {"wsmn24bux7wo113", false},
+ {"demo1", false},
}
for i, scenario := range scenarios {
@@ -63,9 +97,10 @@ func TestIsCollectionNameUnique(t *testing.T) {
expected bool
}{
{"", "", false},
- {"demo", "", false},
+ {"demo1", "", false},
+ {"Demo1", "", false},
{"new", "", true},
- {"demo", "3f2888f8-075d-49fe-9d09-ea7e951000dc", true},
+ {"demo1", "wsmn24bux7wo113", true},
}
for i, scenario := range scenarios {
@@ -76,33 +111,11 @@ func TestIsCollectionNameUnique(t *testing.T) {
}
}
-func TestFindCollectionsWithUserFields(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- result, err := app.Dao().FindCollectionsWithUserFields()
- if err != nil {
- t.Fatal(err)
- }
-
- expectedNames := []string{"demo2", models.ProfileCollectionName}
-
- if len(result) != len(expectedNames) {
- t.Fatalf("Expected collections %v, got %v", expectedNames, result)
- }
-
- for i, col := range result {
- if !list.ExistInSlice(col.Name, expectedNames) {
- t.Errorf("(%d) Couldn't find %s in %v", i, col.Name, expectedNames)
- }
- }
-}
-
func TestFindCollectionReferences(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, err := app.Dao().FindCollectionByNameOrId("demo")
+ collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
@@ -116,11 +129,18 @@ func TestFindCollectionReferences(t *testing.T) {
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
}
- expectedFields := []string{"onerel", "manyrels", "cascaderel"}
+ expectedFields := []string{
+ "rel_one_no_cascade",
+ "rel_one_no_cascade_required",
+ "rel_one_cascade",
+ "rel_many_no_cascade",
+ "rel_many_no_cascade_required",
+ "rel_many_cascade",
+ }
for col, fields := range result {
- if col.Name != "demo2" {
- t.Fatalf("Expected collection demo2, got %s", col.Name)
+ if col.Name != "demo4" {
+ t.Fatalf("Expected collection demo4, got %s", col.Name)
}
if len(fields) != len(expectedFields) {
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
@@ -138,7 +158,7 @@ func TestDeleteCollection(t *testing.T) {
defer app.Cleanup()
c0 := &models.Collection{}
- c1, err := app.Dao().FindCollectionByNameOrId("demo")
+ c1, err := app.Dao().FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
@@ -146,18 +166,22 @@ func TestDeleteCollection(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- c3, err := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
+ c3, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
+ c3.System = true
+ if err := app.Dao().Save(c3); err != nil {
+ t.Fatal(err)
+ }
scenarios := []struct {
model *models.Collection
expectError bool
}{
{c0, true},
- {c1, true}, // is part of a reference
- {c2, false},
+ {c1, false},
+ {c2, true}, // is part of a reference
{c3, true}, // system
}
@@ -177,6 +201,7 @@ func TestSaveCollectionCreate(t *testing.T) {
collection := &models.Collection{
Name: "new_test",
+ Type: models.CollectionTypeBase,
Schema: schema.NewSchema(
&schema.SchemaField{
Type: schema.FieldTypeText,
@@ -239,7 +264,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
// check if the records table has the schema fields
- expectedColumns := []string{"id", "created", "updated", "title_update", "test"}
+ expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
columns, err := app.Dao().GetTableColumns(collection.Name)
if err != nil {
t.Fatal(err)
@@ -262,13 +287,14 @@ func TestImportCollections(t *testing.T) {
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
expectError bool
expectCollectionsCount int
+ beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
}{
{
name: "empty collections",
jsonData: `[]`,
expectError: true,
- expectCollectionsCount: 5,
+ expectCollectionsCount: 7,
},
{
name: "check db constraints",
@@ -277,7 +303,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: true,
- expectCollectionsCount: 5,
+ expectCollectionsCount: 7,
},
{
name: "minimal collection import",
@@ -286,7 +312,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
- expectCollectionsCount: 6,
+ expectCollectionsCount: 8,
},
{
name: "minimal collection import + failed beforeRecordsSync",
@@ -298,7 +324,7 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: true,
- expectCollectionsCount: 5,
+ expectCollectionsCount: 7,
},
{
name: "minimal collection import + successful beforeRecordsSync",
@@ -310,13 +336,13 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: false,
- expectCollectionsCount: 6,
+ expectCollectionsCount: 8,
},
{
name: "new + update + delete system collection",
jsonData: `[
{
- "id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
+ "id":"wsmn24bux7wo113",
"name":"demo",
"schema":[
{
@@ -346,50 +372,49 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: true,
expectError: true,
- expectCollectionsCount: 5,
+ expectCollectionsCount: 7,
},
{
name: "new + update + delete non-system collection",
jsonData: `[
{
- "id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "name":"profiles",
- "system":true,
- "listRule":"userId = @request.user.id",
- "viewRule":"created > 'test_change'",
- "createRule":"userId = @request.user.id",
- "updateRule":"userId = @request.user.id",
- "deleteRule":"userId = @request.user.id",
- "schema":[
+ "id": "kpv709sk2lqbqk8",
+ "system": true,
+ "name": "nologin",
+ "type": "auth",
+ "options": {
+ "allowEmailAuth": false,
+ "allowOAuth2Auth": false,
+ "allowUsernameAuth": false,
+ "exceptEmailDomains": [],
+ "manageRule": "@request.auth.collectionName = 'users'",
+ "minPasswordLength": 8,
+ "onlyEmailDomains": [],
+ "requireEmail": true
+ },
+ "listRule": "",
+ "viewRule": "",
+ "createRule": "",
+ "updateRule": "",
+ "deleteRule": "",
+ "schema": [
{
- "id":"koih1lqx",
- "name":"userId",
- "type":"user",
- "system":true,
- "required":true,
- "unique":true,
- "options":{
- "maxSelect":1,
- "cascadeDelete":true
- }
- },
- {
- "id":"69ycbg3q",
- "name":"rel",
- "type":"relation",
- "system":false,
- "required":false,
- "unique":false,
- "options":{
- "maxSelect":2,
- "collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "cascadeDelete":false
+ "id": "x8zzktwe",
+ "name": "name",
+ "type": "text",
+ "system": false,
+ "required": false,
+ "unique": false,
+ "options": {
+ "min": null,
+ "max": null,
+ "pattern": ""
}
}
]
},
{
- "id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
+ "id":"wsmn24bux7wo113",
"name":"demo",
"schema":[
{
@@ -427,38 +452,8 @@ func TestImportCollections(t *testing.T) {
name: "test with deleteMissing: false",
jsonData: `[
{
- "id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "name":"profiles",
- "system":true,
- "listRule":"userId = @request.user.id",
- "viewRule":"created > 'test_change'",
- "createRule":"userId = @request.user.id",
- "updateRule":"userId = @request.user.id",
- "deleteRule":"userId = @request.user.id",
- "schema":[
- {
- "id":"69ycbg3q",
- "name":"rel",
- "type":"relation",
- "system":false,
- "required":false,
- "unique":false,
- "options":{
- "maxSelect":2,
- "collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "cascadeDelete":true
- }
- },
- {
- "id":"abcd_import",
- "name":"new_field",
- "type":"bool"
- }
- ]
- },
- {
- "id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
- "name":"demo",
+ "id":"wsmn24bux7wo113",
+ "name":"demo1",
"schema":[
{
"id":"_2hlxbmp",
@@ -506,14 +501,14 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
- expectCollectionsCount: 6,
+ expectCollectionsCount: 8,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{
- "profiles": 6,
- "demo": 3,
- "demo2": 14,
- "demo3": 1,
- "demo4": 6,
+ "nologin": 1,
+ "demo1": 15,
+ "demo2": 2,
+ "demo3": 2,
+ "demo4": 11,
"new_import": 1,
}
for name, expectedCount := range expectedCollectionFields {
diff --git a/daos/external_auth.go b/daos/external_auth.go
index 7eea5758..525c273c 100644
--- a/daos/external_auth.go
+++ b/daos/external_auth.go
@@ -12,13 +12,16 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.ExternalAuth{})
}
-/// FindAllExternalAuthsByUserId returns all ExternalAuth models
-/// linked to the provided userId.
-func (dao *Dao) FindAllExternalAuthsByUserId(userId string) ([]*models.ExternalAuth, error) {
+/// FindAllExternalAuthsByRecord returns all ExternalAuth models
+/// linked to the provided auth record.
+func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
auths := []*models.ExternalAuth{}
err := dao.ExternalAuthQuery().
- AndWhere(dbx.HashExp{"userId": userId}).
+ AndWhere(dbx.HashExp{
+ "collectionId": authRecord.Collection().Id,
+ "recordId": authRecord.Id,
+ }).
OrderBy("created ASC").
All(&auths)
@@ -50,15 +53,16 @@ func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models
return model, nil
}
-// FindExternalAuthByUserIdAndProvider returns the first available
-// ExternalAuth model for the specified userId and provider.
-func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*models.ExternalAuth, error) {
+// FindExternalAuthByRecordAndProvider returns the first available
+// ExternalAuth model for the specified record data and provider.
+func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
model := &models.ExternalAuth{}
err := dao.ExternalAuthQuery().
AndWhere(dbx.HashExp{
- "userId": userId,
- "provider": provider,
+ "collectionId": authRecord.Collection().Id,
+ "recordId": authRecord.Id,
+ "provider": provider,
}).
Limit(1).
One(model)
@@ -74,7 +78,7 @@ func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*m
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
// extra check the model data in case the provider's API response
// has changed and no longer returns the expected fields
- if model.UserId == "" || model.Provider == "" || model.ProviderId == "" {
+ if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" {
return errors.New("Missing required ExternalAuth fields.")
}
@@ -82,27 +86,6 @@ func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
}
// DeleteExternalAuth deletes the provided ExternalAuth model.
-//
-// The delete may fail if the linked user doesn't have an email and
-// there are no other linked ExternalAuth models available.
func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
- user, err := dao.FindUserById(model.UserId)
- if err != nil {
- return err
- }
-
- // if the user doesn't have an email, make sure that there
- // is at least one other external auth relation available
- if user.Email == "" {
- allExternalAuths, err := dao.FindAllExternalAuthsByUserId(user.Id)
- if err != nil {
- return err
- }
-
- if len(allExternalAuths) <= 1 {
- return errors.New("You cannot delete the only available external auth relation because the user doesn't have an email address.")
- }
- }
-
return dao.Delete(model)
}
diff --git a/daos/external_auth_test.go b/daos/external_auth_test.go
index 68a58e71..f4d05c08 100644
--- a/daos/external_auth_test.go
+++ b/daos/external_auth_test.go
@@ -19,7 +19,7 @@ func TestExternalAuthQuery(t *testing.T) {
}
}
-func TestFindAllExternalAuthsByUserId(t *testing.T) {
+func TestFindAllExternalAuthsByRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -27,16 +27,20 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
userId string
expectedCount int
}{
- {"", 0},
- {"missing", 0},
- {"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", 0},
- {"cx9u0dh2udo8xol", 2},
+ {"oap640cot4yru2s", 0},
+ {"4q1xlclmfloku33", 2},
}
for i, s := range scenarios {
- auths, err := app.Dao().FindAllExternalAuthsByUserId(s.userId)
+ record, err := app.Dao().FindRecordById("users", s.userId)
if err != nil {
- t.Errorf("(%d) Unexpected error %v", i, err)
+ t.Errorf("(%d) Unexpected record fetch error %v", i, err)
+ continue
+ }
+
+ auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
+ if err != nil {
+ t.Errorf("(%d) Unexpected auths fetch error %v", i, err)
continue
}
@@ -45,8 +49,8 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
}
for _, auth := range auths {
- if auth.UserId != s.userId {
- t.Errorf("(%d) Expected all auths to be linked to userId %s, got %v", i, s.userId, auth)
+ if auth.RecordId != record.Id {
+ t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth)
}
}
}
@@ -65,8 +69,8 @@ func TestFindExternalAuthByProvider(t *testing.T) {
{"github", "", ""},
{"github", "id1", ""},
{"github", "id2", ""},
- {"google", "id1", "abcdefghijklmn0"},
- {"gitlab", "id2", "abcdefghijklmn1"},
+ {"google", "test123", "clmflokuq1xl341"},
+ {"gitlab", "test123", "dlmflokuq1xl342"},
}
for i, s := range scenarios {
@@ -85,7 +89,7 @@ func TestFindExternalAuthByProvider(t *testing.T) {
}
}
-func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
+func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -94,17 +98,19 @@ func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
provider string
expectedId string
}{
- {"", "", ""},
- {"", "github", ""},
- {"123456", "github", ""}, // missing user and provider record
- {"123456", "google", ""}, // missing user but existing provider record
- {"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", "google", ""},
- {"cx9u0dh2udo8xol", "google", "abcdefghijklmn0"},
- {"cx9u0dh2udo8xol", "gitlab", "abcdefghijklmn1"},
+ {"bgs820n361vj1qd", "google", ""},
+ {"4q1xlclmfloku33", "google", "clmflokuq1xl341"},
+ {"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"},
}
for i, s := range scenarios {
- auth, err := app.Dao().FindExternalAuthByUserIdAndProvider(s.userId, s.provider)
+ record, err := app.Dao().FindRecordById("users", s.userId)
+ if err != nil {
+ t.Errorf("(%d) Unexpected record fetch error %v", i, err)
+ continue
+ }
+
+ auth, err := app.Dao().FindExternalAuthByRecordAndProvider(record, s.provider)
hasErr := err != nil
expectErr := s.expectedId == ""
@@ -130,9 +136,10 @@ func TestSaveExternalAuth(t *testing.T) {
}
auth := &models.ExternalAuth{
- UserId: "97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
- Provider: "test",
- ProviderId: "test_id",
+ RecordId: "o1y0dd0spd786md",
+ CollectionId: "v851q4r790rhknl",
+ Provider: "test",
+ ProviderId: "test_id",
}
if err := app.Dao().SaveExternalAuth(auth); err != nil {
@@ -154,42 +161,29 @@ func TestDeleteExternalAuth(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- user, err := app.Dao().FindUserById("cx9u0dh2udo8xol")
+ record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
}
- auths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
+ auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
t.Fatal(err)
}
- if err := app.Dao().DeleteExternalAuth(auths[0]); err != nil {
- t.Fatalf("Failed to delete the first ExternalAuth relation, got \n%v", err)
- }
-
- if err := app.Dao().DeleteExternalAuth(auths[1]); err == nil {
- t.Fatal("Expected delete to fail, got nil")
- }
-
- // update the user model and try again
- user.Email = "test_new@example.com"
- if err := app.Dao().SaveUser(user); err != nil {
- t.Fatal(err)
- }
-
- // try to delete auths[1] again
- if err := app.Dao().DeleteExternalAuth(auths[1]); err != nil {
- t.Fatalf("Failed to delete the last ExternalAuth relation, got \n%v", err)
+ for _, auth := range auths {
+ if err := app.Dao().DeleteExternalAuth(auth); err != nil {
+ t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err)
+ }
}
// check if the relations were really deleted
- newAuths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
+ newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
t.Fatal(err)
}
if len(newAuths) != 0 {
- t.Fatalf("Expected all user %s ExternalAuth relations to be deleted, got \n%v", user.Id, newAuths)
+ t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths)
}
}
diff --git a/daos/record.go b/daos/record.go
index 6980b075..caf9e322 100644
--- a/daos/record.go
+++ b/daos/record.go
@@ -8,9 +8,11 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
)
// RecordQuery returns a new Record select query.
@@ -23,16 +25,24 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
// FindRecordById finds the Record model by its id.
func (dao *Dao) FindRecordById(
- collection *models.Collection,
+ collectionNameOrId string,
recordId string,
- filter func(q *dbx.SelectQuery) error,
+ optFilters ...func(q *dbx.SelectQuery) error,
) (*models.Record, error) {
+ collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
+ if err != nil {
+ return nil, err
+ }
+
tableName := collection.Name
query := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{tableName + ".id": recordId})
- if filter != nil {
+ for _, filter := range optFilters {
+ if filter == nil {
+ continue
+ }
if err := filter(query); err != nil {
return nil, err
}
@@ -49,16 +59,25 @@ func (dao *Dao) FindRecordById(
// FindRecordsByIds finds all Record models by the provided ids.
// If no records are found, returns an empty slice.
func (dao *Dao) FindRecordsByIds(
- collection *models.Collection,
+ collectionNameOrId string,
recordIds []string,
- filter func(q *dbx.SelectQuery) error,
+ optFilters ...func(q *dbx.SelectQuery) error,
) ([]*models.Record, error) {
- tableName := collection.Name
+ collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
+ if err != nil {
+ return nil, err
+ }
query := dao.RecordQuery(collection).
- AndWhere(dbx.In(tableName+".id", list.ToInterfaceSlice(recordIds)...))
+ AndWhere(dbx.In(
+ collection.Name+".id",
+ list.ToInterfaceSlice(recordIds)...,
+ ))
- if filter != nil {
+ for _, filter := range optFilters {
+ if filter == nil {
+ continue
+ }
if err := filter(query); err != nil {
return nil, err
}
@@ -72,24 +91,34 @@ func (dao *Dao) FindRecordsByIds(
return models.NewRecordsFromNullStringMaps(collection, rows), nil
}
-// FindRecordsByExpr finds all records by the provided db expression.
-// If no records are found, returns an empty slice.
+// FindRecordsByExpr finds all records by the specified db expression.
+//
+// Returns all collection records if no expressions are provided.
+//
+// Returns an empty slice if no records are found.
//
// Example:
-// expr := dbx.HashExp{"email": "test@example.com"}
-// dao.FindRecordsByExpr(collection, expr)
-func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expression) ([]*models.Record, error) {
- if expr == nil {
- return nil, errors.New("Missing filter expression")
+// expr1 := dbx.HashExp{"email": "test@example.com"}
+// expr2 := dbx.HashExp{"status": "active"}
+// dao.FindRecordsByExpr("example", expr1, expr2)
+func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
+ collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
+ if err != nil {
+ return nil, err
+ }
+
+ query := dao.RecordQuery(collection)
+
+ // add only the non-nil expressions
+ for _, expr := range exprs {
+ if expr != nil {
+ query.AndWhere(expr)
+ }
}
rows := []dbx.NullStringMap{}
- err := dao.RecordQuery(collection).
- AndWhere(expr).
- All(&rows)
-
- if err != nil {
+ if err := query.All(&rows); err != nil {
return nil, err
}
@@ -98,11 +127,16 @@ func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expres
// FindFirstRecordByData returns the first found record matching
// the provided key-value pair.
-func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string, value any) (*models.Record, error) {
+func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
+ collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
+ if err != nil {
+ return nil, err
+ }
+
row := dbx.NullStringMap{}
- err := dao.RecordQuery(collection).
- AndWhere(dbx.HashExp{key: value}).
+ err = dao.RecordQuery(collection).
+ AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(row)
@@ -115,85 +149,193 @@ func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string,
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
//
+// For correctness, if the collection is "auth" and the key is "username",
+// the unique check will be case insensitive.
+//
// NB! Array values (eg. from multiple select fields) are matched
// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness
// depends on the elements order. Or in other words the following values
// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}`
func (dao *Dao) IsRecordValueUnique(
- collection *models.Collection,
+ collectionNameOrId string,
key string,
value any,
- excludeId string,
+ excludeIds ...string,
) bool {
+ collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
+ if err != nil {
+ return false
+ }
+
+ var expr dbx.Expression
+ if collection.IsAuth() && key == schema.FieldNameUsername {
+ expr = dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
+ "username": strings.ToLower(cast.ToString(value)),
+ })
+ } else {
+ var normalizedVal any
+ switch val := value.(type) {
+ case []string:
+ normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
+ case []any:
+ normalizedVal = append(types.JsonArray{}, val...)
+ default:
+ normalizedVal = val
+ }
+
+ expr = dbx.HashExp{inflector.Columnify(key): normalizedVal}
+ }
+
+ query := dao.RecordQuery(collection).
+ Select("count(*)").
+ AndWhere(expr).
+ Limit(1)
+
+ if len(excludeIds) > 0 {
+ uniqueExcludeIds := list.NonzeroUniques(excludeIds)
+ query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
+ }
+
var exists bool
- var normalizedVal any
- switch val := value.(type) {
- case []string:
- normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
- case []any:
- normalizedVal = append(types.JsonArray{}, val...)
- default:
- normalizedVal = val
- }
-
- err := dao.RecordQuery(collection).
- Select("count(*)").
- AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
- AndWhere(dbx.HashExp{key: normalizedVal}).
- Limit(1).
- Row(&exists)
-
- return err == nil && !exists
+ return query.Row(&exists) == nil && !exists
}
-// FindUserRelatedRecords returns all records that has a reference
-// to the provided User model (via the user shema field).
-func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) {
- if user.Id == "" {
- return []*models.Record{}, nil
- }
-
- collections, err := dao.FindCollectionsWithUserFields()
+// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
+//
+// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
+func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
+ unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
return nil, err
}
- result := []*models.Record{}
- for _, collection := range collections {
- userFields := []*schema.SchemaField{}
-
- // prepare fields options
- if err := collection.Schema.InitFieldsOptions(); err != nil {
- return nil, err
- }
-
- // extract user fields
- for _, field := range collection.Schema.Fields() {
- if field.Type == schema.FieldTypeUser {
- userFields = append(userFields, field)
- }
- }
-
- // fetch records associated to the user
- exprs := []dbx.Expression{}
- for _, field := range userFields {
- exprs = append(exprs, dbx.HashExp{field.Name: user.Id})
- }
- rows := []dbx.NullStringMap{}
- if err := dao.RecordQuery(collection).AndWhere(dbx.Or(exprs...)).All(&rows); err != nil {
- return nil, err
- }
- records := models.NewRecordsFromNullStringMaps(collection, rows)
-
- result = append(result, records...)
+ // check required claims
+ id, _ := unverifiedClaims["id"].(string)
+ collectionId, _ := unverifiedClaims["collectionId"].(string)
+ if id == "" || collectionId == "" {
+ return nil, errors.New("Missing or invalid token claims.")
}
- return result, nil
+ record, err := dao.FindRecordById(collectionId, id)
+ if err != nil {
+ return nil, err
+ }
+
+ if !record.Collection().IsAuth() {
+ return nil, errors.New("The token is not associated to an auth collection record.")
+ }
+
+ verificationKey := record.TokenKey() + baseTokenKey
+
+ // verify token signature
+ if _, err := security.ParseJWT(token, verificationKey); err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
+
+// FindAuthRecordByEmail finds the auth record associated with the provided email.
+//
+// Returns an error if it is not an auth collection or the record is not found.
+func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
+ collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
+ if err != nil || !collection.IsAuth() {
+ return nil, errors.New("Missing or not an auth collection.")
+ }
+
+ row := dbx.NullStringMap{}
+
+ err = dao.RecordQuery(collection).
+ AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
+ Limit(1).
+ One(row)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return models.NewRecordFromNullStringMap(collection, row), nil
+}
+
+// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
+//
+// Returns an error if it is not an auth collection or the record is not found.
+func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
+ collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
+ if err != nil || !collection.IsAuth() {
+ return nil, errors.New("Missing or not an auth collection.")
+ }
+
+ row := dbx.NullStringMap{}
+
+ err = dao.RecordQuery(collection).
+ AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
+ "username": strings.ToLower(username),
+ })).
+ Limit(1).
+ One(row)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return models.NewRecordFromNullStringMap(collection, row), nil
+}
+
+// SuggestUniqueAuthRecordUsername checks if the provided username is unique
+// and return a new "unique" username with appended random numeric part
+// (eg. "existingName" -> "existingName583").
+//
+// The same username will be returned if the provided string is already unique.
+func (dao *Dao) SuggestUniqueAuthRecordUsername(
+ collectionNameOrId string,
+ baseUsername string,
+ excludeIds ...string,
+) string {
+ username := baseUsername
+
+ for i := 0; i < 10; i++ { // max 10 attempts
+ isUnique := dao.IsRecordValueUnique(
+ collectionNameOrId,
+ schema.FieldNameUsername,
+ username,
+ excludeIds...,
+ )
+ if isUnique {
+ break // already unique
+ }
+ username = baseUsername + security.RandomStringWithAlphabet(3+i, "123456789")
+ }
+
+ return username
}
// SaveRecord upserts the provided Record model.
func (dao *Dao) SaveRecord(record *models.Record) error {
+ if record.Collection().IsAuth() {
+ if record.Username() == "" {
+ return errors.New("Unable to save auth record without username.")
+ }
+
+ // Cross-check that the auth record id is unique for all auth collections.
+ // This is to make sure that the filter `@request.auth.id` always returns a unique id.
+ authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
+ if err != nil {
+ return fmt.Errorf("Unable to fetch the auth collections for cross-id unique check: %v", err)
+ }
+ for _, collection := range authCollections {
+ if record.Collection().Id == collection.Id {
+ continue // skip current collection (sqlite will do the check for us)
+ }
+ isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
+ if !isUnique {
+ return errors.New("The auth record ID must be unique across all auth collections.")
+ }
+ }
+ }
+
return dao.Save(record)
}
@@ -206,8 +348,8 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
// reference in another record (aka. cannot be deleted or set to NULL).
func (dao *Dao) DeleteRecord(record *models.Record) error {
// check for references
- // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
- refs, err := dao.FindCollectionReferences(record.Collection(), "")
+ // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction.
+ refs, err := dao.FindCollectionReferences(record.Collection())
if err != nil {
return err
}
@@ -217,6 +359,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
// just unset the record id from any relation field values (if they are not required)
// -----------------------------------------------------------
return dao.RunInTransaction(func(txDao *Dao) error {
+ // delete/update references
for refCollection, fields := range refs {
for _, field := range fields {
options, _ := field.Options.(*schema.RelationOptions)
@@ -234,7 +377,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
for _, refRecord := range refRecords {
- ids := refRecord.GetStringSliceDataValue(field.Name)
+ ids := refRecord.GetStringSlice(field.Name)
// unset the record id
for i := len(ids) - 1; i >= 0; i-- {
@@ -259,7 +402,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
}
// save the reference changes
- refRecord.SetDataValue(field.Name, field.PrepareValue(ids))
+ refRecord.Set(field.Name, field.PrepareValue(ids))
if err := txDao.SaveRecord(refRecord); err != nil {
return err
}
@@ -267,6 +410,17 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
}
}
+ // 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 txDao.Delete(record)
})
}
@@ -279,9 +433,26 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// create
if oldCollection == nil {
cols := map[string]string{
- schema.ReservedFieldNameId: "TEXT PRIMARY KEY",
- schema.ReservedFieldNameCreated: `TEXT DEFAULT "" NOT NULL`,
- schema.ReservedFieldNameUpdated: `TEXT DEFAULT "" NOT NULL`,
+ schema.FieldNameId: "TEXT PRIMARY KEY",
+ schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
+ schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
+ }
+
+ if newCollection.IsAuth() {
+ cols[schema.FieldNameUsername] = "TEXT NOT NULL"
+ cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
+ cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
+ cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
+ cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
+ cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
+ cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
+ cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
+ }
+
+ // ensure that the new collection has an id
+ if !newCollection.HasId() {
+ newCollection.RefreshId()
+ newCollection.MarkAsNew()
}
tableName := newCollection.Name
@@ -292,15 +463,30 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
}
// create table
- _, tableErr := dao.DB().CreateTable(tableName, cols).Execute()
- if tableErr != nil {
- return tableErr
+ if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
+ return err
}
- // add index on the base `created` column
- _, indexErr := dao.DB().CreateIndex(tableName, tableName+"_created_idx", "created").Execute()
- if indexErr != nil {
- return indexErr
+ // add named index on the base `created` column
+ if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
+ return err
+ }
+
+ // add named unique index on the email and tokenKey columns
+ if newCollection.IsAuth() {
+ _, err := dao.DB().NewQuery(fmt.Sprintf(
+ `
+ CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
+ CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
+ CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
+ `,
+ newCollection.Id, tableName,
+ newCollection.Id, tableName,
+ newCollection.Id, tableName,
+ )).Execute()
+ if err != nil {
+ return err
+ }
}
return nil
@@ -315,7 +501,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
- _, err := dao.DB().RenameTable(oldTableName, newTableName).Execute()
+ _, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
if err != nil {
return err
}
diff --git a/daos/record_expand.go b/daos/record_expand.go
index 816bec5f..cbff9e2f 100644
--- a/daos/record_expand.go
+++ b/daos/record_expand.go
@@ -3,11 +3,16 @@ package daos
import (
"errors"
"fmt"
+ "regexp"
"strings"
+ "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
)
// MaxExpandDepth specifies the max allowed nested expand depth path.
@@ -40,10 +45,13 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
return failed
}
+var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
+
// notes:
// - fetchFunc must be non-nil func
// - all records are expected to be from the same collection
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
+// - indirect expands are supported only with single relation fields
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
if fetchFunc == nil {
return errors.New("Relation records fetchFunc is not set.")
@@ -53,29 +61,104 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
return nil
}
- parts := strings.SplitN(expandPath, ".", 2)
-
- // extract the relation field (if exist)
mainCollection := records[0].Collection()
- relField := mainCollection.Schema.GetFieldByName(parts[0])
- if relField == nil || relField.Type != schema.FieldTypeRelation {
- return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
- }
- relField.InitOptions()
- relFieldOptions, ok := relField.Options.(*schema.RelationOptions)
- if !ok {
- return fmt.Errorf("Cannot initialize the options of relation field %q.", parts[0])
+
+ var relField *schema.SchemaField
+ var relFieldOptions *schema.RelationOptions
+ var relCollection *models.Collection
+
+ parts := strings.SplitN(expandPath, ".", 2)
+ matches := indirectExpandRegex.FindStringSubmatch(parts[0])
+
+ if len(matches) == 3 {
+ indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
+ if indirectRel == nil {
+ return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
+ }
+
+ indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
+ if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
+ return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
+ }
+
+ indirectRelField.InitOptions()
+ indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
+ if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
+ return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
+ }
+ if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
+ // for now don't allow multi-relation indirect fields expand
+ // due to eventual poor query performance with large data sets.
+ return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
+ }
+
+ recordIds := make([]any, len(records))
+ for _, record := range records {
+ recordIds = append(recordIds, record.Id)
+ }
+
+ indirectRecords, err := dao.FindRecordsByExpr(
+ indirectRel.Id,
+ dbx.In(inflector.Columnify(matches[2]), recordIds...),
+ )
+ if err != nil {
+ return err
+ }
+ mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
+ for _, indirectRecord := range indirectRecords {
+ recId := indirectRecord.GetString(matches[2])
+ if recId != "" {
+ mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
+ }
+ }
+
+ // add the indirect relation ids as a new relation field value
+ for _, record := range records {
+ relIds, ok := mappedIndirectRecordIds[record.Id]
+ if ok && len(relIds) > 0 {
+ record.Set(parts[0], relIds)
+ }
+ }
+
+ relFieldOptions = &schema.RelationOptions{
+ MaxSelect: nil,
+ CollectionId: indirectRel.Id,
+ }
+ if indirectRelField.Unique {
+ relFieldOptions.MaxSelect = types.Pointer(1)
+ }
+ // indirect relation
+ relField = &schema.SchemaField{
+ Id: "indirect_" + security.RandomString(3),
+ Type: schema.FieldTypeRelation,
+ Name: parts[0],
+ Options: relFieldOptions,
+ }
+ relCollection = indirectRel
+ } else {
+ // direct relation
+ relField = mainCollection.Schema.GetFieldByName(parts[0])
+ if relField == nil || relField.Type != schema.FieldTypeRelation {
+ return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
+ }
+ relField.InitOptions()
+ relFieldOptions, _ = relField.Options.(*schema.RelationOptions)
+ if relFieldOptions == nil {
+ return fmt.Errorf("Couldn't initialize the options of relation field %q.", parts[0])
+ }
+
+ relCollection, _ = dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
+ if relCollection == nil {
+ return fmt.Errorf("Couldn't find related collection %q.", relFieldOptions.CollectionId)
+ }
}
- relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
- if err != nil {
- return fmt.Errorf("Couldn't find collection %q.", relFieldOptions.CollectionId)
- }
+ // ---------------------------------------------------------------
// extract the id of the relations to expand
relIds := make([]string, 0, len(records))
for _, record := range records {
- relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...)
+ relIds = append(relIds, record.GetStringSlice(relField.Name)...)
}
// fetch rels
@@ -99,7 +182,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
}
for _, model := range records {
- relIds := model.GetStringSliceDataValue(relField.Name)
+ relIds := model.GetStringSlice(relField.Name)
validRels := make([]*models.Record, 0, len(relIds))
for _, id := range relIds {
@@ -112,7 +195,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
continue // no valid relations
}
- expandData := model.GetExpand()
+ expandData := model.Expand()
// normalize access to the previously expanded rel records (if any)
var oldExpandedRels []*models.Record
@@ -133,8 +216,8 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
continue
}
- oldRelExpand := oldExpandedRel.GetExpand()
- newRelExpand := rel.GetExpand()
+ oldRelExpand := oldExpandedRel.Expand()
+ newRelExpand := rel.Expand()
for k, v := range oldRelExpand {
newRelExpand[k] = v
}
@@ -143,7 +226,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
}
// update the expanded data
- if relFieldOptions.MaxSelect == 1 {
+ if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 {
expandData[relField.Name] = validRels[0]
} else {
expandData[relField.Name] = validRels
diff --git a/daos/record_expand_test.go b/daos/record_expand_test.go
index 689a0d63..d5059bd2 100644
--- a/daos/record_expand_test.go
+++ b/daos/record_expand_test.go
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -16,152 +17,173 @@ func TestExpandRecords(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- col, _ := app.Dao().FindCollectionByNameOrId("demo4")
-
scenarios := []struct {
+ testName string
+ collectionIdOrName string
recordIds []string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectExpandFailures int
}{
- // empty records
{
+ "empty records",
+ "",
[]string{},
- []string{"onerel", "manyrels.onerel.manyrels"},
+ []string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
0,
},
- // empty expand
{
- []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
+ "empty expand",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
0,
},
- // empty fetchFunc
{
- []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
- []string{"onerel", "manyrels.onerel.manyrels"},
+ "empty fetchFunc",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
2,
},
- // fetchFunc with error
{
- []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
- []string{"onerel", "manyrels.onerel.manyrels"},
+ "fetchFunc with error",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return nil, errors.New("test error")
},
0,
2,
},
- // missing relation field
{
- []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
- []string{"invalid"},
+ "missing relation field",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{"missing"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
- // existing, but non-relation type field
{
- []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
+ "existing, but non-relation type field",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
- // invalid/missing second level expand
{
- []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
- []string{"manyrels.invalid"},
+ "invalid/missing second level expand",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{"rel_one_no_cascade.title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
- // expand normalizations
{
+ "expand normalizations",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- "df55c8ff-45ef-4c82-8aed-6e2183fe1125",
- "b84cd893-7119-43c9-8505-3c4e22da28a9",
- "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
+ "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
+ "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
+ "self_rel_many", "self_rel_many.",
+ " self_rel_many ", "",
},
- []string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel", "onerel", "onerel.", " onerel ", ""},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
9,
0,
},
- // expand multiple relations sharing a common root path
{
+ "single expand",
+ "users",
[]string{
- "i15r5aa28ad06c8",
+ "bgs820n361vj1qd",
+ "4q1xlclmfloku33",
+ "oap640cot4yru2s", // no rels
},
- []string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel.onerel"},
+ []string{"rel"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
- },
- 4,
- 0,
- },
- // single expand
- {
- []string{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- "df55c8ff-45ef-4c82-8aed-6e2183fe1125",
- "b84cd893-7119-43c9-8505-3c4e22da28a9", // no manyrels
- "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", // no manyrels
- },
- []string{"manyrels"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
2,
0,
},
- // maxExpandDepth reached
{
- []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"},
- []string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
+ "maxExpandDepth reached",
+ "demo4",
+ []string{"qzaqccwrmva4o1n"},
+ []string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
6,
0,
},
+ {
+ "simple indirect expand",
+ "demo3",
+ []string{"lcl9d87w22ml6jy"},
+ []string{"demo4(rel_one_no_cascade_required)"},
+ func(c *models.Collection, ids []string) ([]*models.Record, error) {
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
+ },
+ 1,
+ 0,
+ },
+ {
+ "nested indirect expand",
+ "demo3",
+ []string{"lcl9d87w22ml6jy"},
+ []string{
+ "demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
+ },
+ func(c *models.Collection, ids []string) ([]*models.Record, error) {
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
+ },
+ 5,
+ 0,
+ },
}
- for i, s := range scenarios {
+ for _, s := range scenarios {
ids := list.ToUniqueStringSlice(s.recordIds)
- records, _ := app.Dao().FindRecordsByIds(col, ids, nil)
+ records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids)
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
if len(failed) != s.expectExpandFailures {
- t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
+ t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
}
encoded, _ := json.Marshal(records)
encodedStr := string(encoded)
- totalExpandProps := strings.Count(encodedStr, "@expand")
+ totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
if s.expectExpandProps != totalExpandProps {
- t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
+ t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
}
}
}
@@ -170,109 +192,157 @@ func TestExpandRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- col, _ := app.Dao().FindCollectionByNameOrId("demo4")
-
scenarios := []struct {
+ testName string
+ collectionIdOrName string
recordId string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectExpandFailures int
}{
- // empty expand
{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
+ "empty expand",
+ "demo4",
+ "i9naidtvr6qsgb4",
[]string{},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
0,
},
- // empty fetchFunc
{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- []string{"onerel", "manyrels.onerel.manyrels"},
+ "empty fetchFunc",
+ "demo4",
+ "i9naidtvr6qsgb4",
+ []string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
2,
},
- // fetchFunc with error
{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- []string{"onerel", "manyrels.onerel.manyrels"},
+ "fetchFunc with error",
+ "demo4",
+ "i9naidtvr6qsgb4",
+ []string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return nil, errors.New("test error")
},
0,
2,
},
- // invalid missing first level expand
{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- []string{"invalid"},
+ "missing relation field",
+ "demo4",
+ "i9naidtvr6qsgb4",
+ []string{"missing"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
- // invalid missing second level expand
{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- []string{"manyrels.invalid"},
+ "existing, but non-relation type field",
+ "demo4",
+ "i9naidtvr6qsgb4",
+ []string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
- // expand normalizations
{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- []string{"manyrels.onerel.manyrels", "manyrels.onerel", "onerel", " onerel "},
+ "invalid/missing second level expand",
+ "demo4",
+ "qzaqccwrmva4o1n",
+ []string{"rel_one_no_cascade.title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
- 3,
0,
- },
- // single expand
- {
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- []string{"manyrels"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
- },
1,
+ },
+ {
+ "expand normalizations",
+ "demo4",
+ "qzaqccwrmva4o1n",
+ []string{
+ "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
+ "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
+ "self_rel_many", "self_rel_many.",
+ " self_rel_many ", "",
+ },
+ func(c *models.Collection, ids []string) ([]*models.Record, error) {
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
+ },
+ 8,
0,
},
- // maxExpandDepth reached
{
- "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
- []string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
+ "no rels to expand",
+ "users",
+ "oap640cot4yru2s",
+ []string{"rel"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c, ids, nil)
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 0,
+ },
+ {
+ "maxExpandDepth reached",
+ "demo4",
+ "qzaqccwrmva4o1n",
+ []string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
+ func(c *models.Collection, ids []string) ([]*models.Record, error) {
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
6,
0,
},
+ {
+ "simple indirect expand",
+ "demo3",
+ "lcl9d87w22ml6jy",
+ []string{"demo4(rel_one_no_cascade_required)"},
+ func(c *models.Collection, ids []string) ([]*models.Record, error) {
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
+ },
+ 1,
+ 0,
+ },
+ {
+ "nested indirect expand",
+ "demo3",
+ "lcl9d87w22ml6jy",
+ []string{
+ "demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
+ },
+ func(c *models.Collection, ids []string) ([]*models.Record, error) {
+ return app.Dao().FindRecordsByIds(c.Id, ids, nil)
+ },
+ 5,
+ 0,
+ },
}
- for i, s := range scenarios {
- record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId)
+ for _, s := range scenarios {
+ record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId)
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
if len(failed) != s.expectExpandFailures {
- t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
+ t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
}
encoded, _ := json.Marshal(record)
encodedStr := string(encoded)
- totalExpandProps := strings.Count(encodedStr, "@expand")
+ totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
if s.expectExpandProps != totalExpandProps {
- t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
+ t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
}
}
}
diff --git a/daos/record_test.go b/daos/record_test.go
index 024be661..f21004d2 100644
--- a/daos/record_test.go
+++ b/daos/record_test.go
@@ -3,6 +3,8 @@ package daos_test
import (
"errors"
"fmt"
+ "regexp"
+ "strings"
"testing"
"github.com/pocketbase/dbx"
@@ -16,7 +18,10 @@ func TestRecordQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo")
+ collection, err := app.Dao().FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
@@ -30,30 +35,50 @@ func TestFindRecordById(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo")
-
scenarios := []struct {
- id string
- filter func(q *dbx.SelectQuery) error
- expectError bool
+ collectionIdOrName string
+ id string
+ filter1 func(q *dbx.SelectQuery) error
+ filter2 func(q *dbx.SelectQuery) error
+ expectError bool
}{
- {"00000000-bafd-48f7-b8b7-090638afe209", nil, true},
- {"b5c2ffc2-bafd-48f7-b8b7-090638afe209", nil, false},
- {"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
+ {"demo2", "missing", nil, nil, true},
+ {"missing", "0yxhwia2amd8gec", nil, nil, true},
+ {"demo2", "0yxhwia2amd8gec", nil, nil, false},
+ {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "missing"})
return nil
- }, true},
- {"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
+ }, nil, true},
+ {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
return errors.New("test error")
+ }, nil, true},
+ {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"title": "test3"})
+ return nil
+ }, nil, false},
+ {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"title": "test3"})
+ return nil
+ }, func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"active": false})
+ return nil
}, true},
- {"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"title": "lorem"})
+ {"sz5l5z67tg7gku0", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"title": "test3"})
+ return nil
+ }, func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"active": true})
return nil
}, false},
}
for i, scenario := range scenarios {
- record, err := app.Dao().FindRecordById(collection, scenario.id, scenario.filter)
+ record, err := app.Dao().FindRecordById(
+ scenario.collectionIdOrName,
+ scenario.id,
+ scenario.filter1,
+ scenario.filter2,
+ )
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -70,25 +95,34 @@ func TestFindRecordsByIds(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo")
-
scenarios := []struct {
- ids []string
- filter func(q *dbx.SelectQuery) error
- expectTotal int
- expectError bool
+ collectionIdOrName string
+ ids []string
+ filter1 func(q *dbx.SelectQuery) error
+ filter2 func(q *dbx.SelectQuery) error
+ expectTotal int
+ expectError bool
}{
- {[]string{}, nil, 0, false},
- {[]string{"00000000-bafd-48f7-b8b7-090638afe209"}, nil, 0, false},
- {[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209"}, nil, 1, false},
+ {"demo2", []string{}, nil, nil, 0, false},
+ {"demo2", []string{""}, nil, nil, 0, false},
+ {"demo2", []string{"missing"}, nil, nil, 0, false},
+ {"missing", []string{"0yxhwia2amd8gec"}, nil, nil, 0, true},
+ {"demo2", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
+ {"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
{
- []string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ nil,
nil,
2,
false,
},
{
- []string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ func(q *dbx.SelectQuery) error {
+ return nil // empty filter
+ },
func(q *dbx.SelectQuery) error {
return errors.New("test error")
},
@@ -96,9 +130,25 @@ func TestFindRecordsByIds(t *testing.T) {
true,
},
{
- []string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.Like("title", "test").Match(true, true))
+ q.AndWhere(dbx.HashExp{"active": true})
+ return nil
+ },
+ nil,
+ 1,
+ false,
+ },
+ {
+ "sz5l5z67tg7gku0",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"active": true})
+ return nil
+ },
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.Not(dbx.HashExp{"title": ""}))
return nil
},
1,
@@ -107,7 +157,12 @@ func TestFindRecordsByIds(t *testing.T) {
}
for i, scenario := range scenarios {
- records, err := app.Dao().FindRecordsByIds(collection, scenario.ids, scenario.filter)
+ records, err := app.Dao().FindRecordsByIds(
+ scenario.collectionIdOrName,
+ scenario.ids,
+ scenario.filter1,
+ scenario.filter2,
+ )
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -131,35 +186,53 @@ func TestFindRecordsByExpr(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo")
-
scenarios := []struct {
- expression dbx.Expression
- expectIds []string
- expectError bool
+ collectionIdOrName string
+ expressions []dbx.Expression
+ expectIds []string
+ expectError bool
}{
{
+ "missing",
nil,
[]string{},
true,
},
{
- dbx.HashExp{"id": 123},
+ "demo2",
+ nil,
+ []string{
+ "achvryl401bhse3",
+ "llvuca81nly1qls",
+ "0yxhwia2amd8gec",
+ },
+ false,
+ },
+ {
+ "demo2",
+ []dbx.Expression{
+ nil,
+ dbx.HashExp{"id": "123"},
+ },
[]string{},
false,
},
{
- dbx.Like("title", "test").Match(true, true),
+ "sz5l5z67tg7gku0",
+ []dbx.Expression{
+ dbx.Like("title", "test").Match(true, true),
+ dbx.HashExp{"active": true},
+ },
[]string{
- "848a1dea-5ddd-42d6-a00d-030547bffcfe",
- "577bd676-aacb-4072-b7da-99d00ee210a4",
+ "achvryl401bhse3",
+ "0yxhwia2amd8gec",
},
false,
},
}
for i, scenario := range scenarios {
- records, err := app.Dao().FindRecordsByExpr(collection, scenario.expression)
+ records, err := app.Dao().FindRecordsByExpr(scenario.collectionIdOrName, scenario.expressions...)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -183,42 +256,52 @@ func TestFindFirstRecordByData(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo")
-
scenarios := []struct {
- key string
- value any
- expectId string
- expectError bool
+ collectionIdOrName string
+ key string
+ value any
+ expectId string
+ expectError bool
}{
{
+ "missing",
+ "id",
+ "llvuca81nly1qls",
+ "llvuca81nly1qls",
+ true,
+ },
+ {
+ "demo2",
"",
- "848a1dea-5ddd-42d6-a00d-030547bffcfe",
+ "llvuca81nly1qls",
"",
true,
},
{
+ "demo2",
"id",
"invalid",
"",
true,
},
{
+ "demo2",
"id",
- "848a1dea-5ddd-42d6-a00d-030547bffcfe",
- "848a1dea-5ddd-42d6-a00d-030547bffcfe",
+ "llvuca81nly1qls",
+ "llvuca81nly1qls",
false,
},
{
+ "sz5l5z67tg7gku0",
"title",
- "lorem",
- "b5c2ffc2-bafd-48f7-b8b7-090638afe209",
+ "test3",
+ "0yxhwia2amd8gec",
false,
},
}
for i, scenario := range scenarios {
- record, err := app.Dao().FindFirstRecordByData(collection, scenario.key, scenario.value)
+ record, err := app.Dao().FindFirstRecordByData(scenario.collectionIdOrName, scenario.key, scenario.value)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -236,32 +319,44 @@ func TestIsRecordValueUnique(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
-
- testManyRelsId1 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125"
- testManyRelsId2 := "b84cd893-7119-43c9-8505-3c4e22da28a9"
+ testManyRelsId1 := "bgs820n361vj1qd"
+ testManyRelsId2 := "4q1xlclmfloku33"
+ testManyRelsId3 := "oap640cot4yru2s"
scenarios := []struct {
- key string
- value any
- excludeId string
- expected bool
+ collectionIdOrName string
+ key string
+ value any
+ excludeIds []string
+ expected bool
}{
- {"", "", "", false},
- {"missing", "unique", "", false},
- {"title", "unique", "", true},
- {"title", "demo1", "", false},
- {"title", "demo1", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", true},
- {"manyrels", []string{testManyRelsId2}, "", false},
- {"manyrels", []any{testManyRelsId2}, "", false},
- // with exclude
- {"manyrels", []string{testManyRelsId1, testManyRelsId2}, "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", true},
- // reverse order
- {"manyrels", []string{testManyRelsId2, testManyRelsId1}, "", true},
+ {"demo2", "", "", nil, false},
+ {"demo2", "", "", []string{""}, false},
+ {"demo2", "missing", "unique", nil, false},
+ {"demo2", "title", "unique", nil, true},
+ {"demo2", "title", "unique", []string{}, true},
+ {"demo2", "title", "unique", []string{""}, true},
+ {"demo2", "title", "test1", []string{""}, false},
+ {"demo2", "title", "test1", []string{"llvuca81nly1qls"}, true},
+ {"demo1", "rel_many", []string{testManyRelsId3}, nil, false},
+ {"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{""}, false},
+ {"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{"84nmscqy84lsi1t"}, true},
+ // mixed json array order
+ {"demo1", "rel_many", []string{testManyRelsId1, testManyRelsId3, testManyRelsId2}, nil, true},
+ // username special case-insensitive match
+ {"users", "username", "test2_username", nil, false},
+ {"users", "username", "TEST2_USERNAME", nil, false},
+ {"users", "username", "new_username", nil, true},
+ {"users", "username", "TEST2_USERNAME", []string{"oap640cot4yru2s"}, true},
}
for i, scenario := range scenarios {
- result := app.Dao().IsRecordValueUnique(collection, scenario.key, scenario.value, scenario.excludeId)
+ result := app.Dao().IsRecordValueUnique(
+ scenario.collectionIdOrName,
+ scenario.key,
+ scenario.value,
+ scenario.excludeIds...,
+ )
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
@@ -269,43 +364,164 @@ func TestIsRecordValueUnique(t *testing.T) {
}
}
-func TestFindUserRelatedRecords(t *testing.T) {
+func TestFindAuthRecordByToken(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- u0 := &models.User{}
- u1, _ := app.Dao().FindUserByEmail("test3@example.com")
- u2, _ := app.Dao().FindUserByEmail("test2@example.com")
-
scenarios := []struct {
- user *models.User
- expectedIds []string
+ token string
+ baseKey string
+ expectedEmail string
+ expectError bool
}{
- {u0, []string{}},
- {u1, []string{
- "94568ca2-0bee-49d7-b749-06cb97956fd9", // demo2
- "fc69274d-ca5c-416a-b9ef-561b101cfbb1", // profile
- }},
- {u2, []string{
- "b2d5e39d-f569-4cc1-b593-3f074ad026bf", // profile
- }},
+ // invalid auth token
+ {
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.H2KKcIXiAfxvuXMFzizo1SgsinDP4hcWhD3pYoP4Nqw",
+ app.Settings().RecordAuthToken.Secret,
+ "",
+ true,
+ },
+ // expired token
+ {
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
+ app.Settings().RecordAuthToken.Secret,
+ "",
+ true,
+ },
+ // wrong base key (password reset token secret instead of auth secret)
+ {
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ app.Settings().RecordPasswordResetToken.Secret,
+ "",
+ true,
+ },
+ // valid token and base key but with deleted/missing collection
+ {
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoibWlzc2luZyIsImV4cCI6MjIwODk4NTI2MX0.0oEHQpdpHp0Nb3VN8La0ssg-SjwWKiRl_k1mUGxdKlU",
+ app.Settings().RecordAuthToken.Secret,
+ "test@example.com",
+ true,
+ },
+ // valid token
+ {
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ app.Settings().RecordAuthToken.Secret,
+ "test@example.com",
+ false,
+ },
}
for i, scenario := range scenarios {
- records, err := app.Dao().FindUserRelatedRecords(scenario.user)
- if err != nil {
- t.Fatal(err)
- }
+ record, err := app.Dao().FindAuthRecordByToken(scenario.token, scenario.baseKey)
- if len(records) != len(scenario.expectedIds) {
- t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records)
+ hasErr := err != nil
+ if hasErr != scenario.expectError {
+ t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
- for _, r := range records {
- if !list.ExistInSlice(r.Id, scenario.expectedIds) {
- t.Errorf("(%d) Couldn't find %s in %v", i, r.Id, scenario.expectedIds)
- }
+ if !scenario.expectError && record.Email() != scenario.expectedEmail {
+ t.Errorf("(%d) Expected record model %s, got %s", i, scenario.expectedEmail, record.Email())
+ }
+ }
+}
+
+func TestFindAuthRecordByEmail(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionIdOrName string
+ email string
+ expectError bool
+ }{
+ {"missing", "test@example.com", true},
+ {"demo2", "test@example.com", true},
+ {"users", "missing@example.com", true},
+ {"users", "test@example.com", false},
+ {"clients", "test2@example.com", false},
+ }
+
+ for i, scenario := range scenarios {
+ record, err := app.Dao().FindAuthRecordByEmail(scenario.collectionIdOrName, scenario.email)
+
+ hasErr := err != nil
+ if hasErr != scenario.expectError {
+ t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
+ continue
+ }
+
+ if !scenario.expectError && record.Email() != scenario.email {
+ t.Errorf("(%d) Expected record with email %s, got %s", i, scenario.email, record.Email())
+ }
+ }
+}
+
+func TestFindAuthRecordByUsername(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionIdOrName string
+ username string
+ expectError bool
+ }{
+ {"missing", "test_username", true},
+ {"demo2", "test_username", true},
+ {"users", "missing", true},
+ {"users", "test2_username", false},
+ {"users", "TEST2_USERNAME", false}, // case insensitive check
+ {"clients", "clients43362", false},
+ }
+
+ for i, scenario := range scenarios {
+ record, err := app.Dao().FindAuthRecordByUsername(scenario.collectionIdOrName, scenario.username)
+
+ hasErr := err != nil
+ if hasErr != scenario.expectError {
+ t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
+ continue
+ }
+
+ if !scenario.expectError && !strings.EqualFold(record.Username(), scenario.username) {
+ t.Errorf("(%d) Expected record with username %s, got %s", i, scenario.username, record.Username())
+ }
+ }
+}
+
+func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionIdOrName string
+ baseUsername string
+ expectedPattern string
+ }{
+ // missing collection
+ {"missing", "test2_username", `^test2_username\d{12}$`},
+ // not an auth collection
+ {"demo2", "test2_username", `^test2_username\d{12}$`},
+ // auth collection with unique base username
+ {"users", "new_username", `^new_username$`},
+ {"users", "NEW_USERNAME", `^NEW_USERNAME$`},
+ // auth collection with existing username
+ {"users", "test2_username", `^test2_username\d{3}$`},
+ {"users", "TEST2_USERNAME", `^TEST2_USERNAME\d{3}$`},
+ }
+
+ for i, scenario := range scenarios {
+ username := app.Dao().SuggestUniqueAuthRecordUsername(
+ scenario.collectionIdOrName,
+ scenario.baseUsername,
+ )
+
+ pattern, err := regexp.Compile(scenario.expectedPattern)
+ if err != nil {
+ t.Errorf("[%d] Invalid username pattern %q: %v", i, scenario.expectedPattern, err)
+ }
+ if !pattern.MatchString(username) {
+ t.Fatalf("Expected username to match %s, got username %s", scenario.expectedPattern, username)
}
}
}
@@ -314,32 +530,64 @@ func TestSaveRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo")
+ collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
// create
// ---
r1 := models.NewRecord(collection)
- r1.SetDataValue("title", "test_new")
+ r1.Set("title", "test_new")
err1 := app.Dao().SaveRecord(r1)
if err1 != nil {
t.Fatal(err1)
}
- newR1, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_new")
- if newR1 == nil || newR1.Id != r1.Id || newR1.GetStringDataValue("title") != r1.GetStringDataValue("title") {
- t.Errorf("Expected to find record %v, got %v", r1, newR1)
+ newR1, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_new")
+ if newR1 == nil || newR1.Id != r1.Id || newR1.GetString("title") != r1.GetString("title") {
+ t.Fatalf("Expected to find record %v, got %v", r1, newR1)
}
// update
// ---
- r2, _ := app.Dao().FindFirstRecordByData(collection, "id", "b5c2ffc2-bafd-48f7-b8b7-090638afe209")
- r2.SetDataValue("title", "test_update")
+ r2, _ := app.Dao().FindFirstRecordByData(collection.Id, "id", "0yxhwia2amd8gec")
+ r2.Set("title", "test_update")
err2 := app.Dao().SaveRecord(r2)
if err2 != nil {
t.Fatal(err2)
}
- newR2, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_update")
- if newR2 == nil || newR2.Id != r2.Id || newR2.GetStringDataValue("title") != r2.GetStringDataValue("title") {
- t.Errorf("Expected to find record %v, got %v", r2, newR2)
+ newR2, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_update")
+ if newR2 == nil || newR2.Id != r2.Id || newR2.GetString("title") != r2.GetString("title") {
+ t.Fatalf("Expected to find record %v, got %v", r2, newR2)
+ }
+}
+
+func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ baseCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
+ authCollection, _ := app.Dao().FindCollectionByNameOrId("nologin")
+
+ // base collection test
+ r1 := models.NewRecord(baseCollection)
+ r1.Set("title", "test_new")
+ r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record
+ r1.MarkAsNew()
+ if err := app.Dao().SaveRecord(r1); err != nil {
+ t.Fatalf("Expected nil, got error %v", err)
+ }
+
+ // auth collection test
+ r2 := models.NewRecord(authCollection)
+ r2.Set("username", "test_new")
+ r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record
+ r2.MarkAsNew()
+ if err := app.Dao().SaveRecord(r2); err == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ // try again with unique id
+ r2.Set("id", "unique_id")
+ if err := app.Dao().SaveRecord(r2); err != nil {
+ t.Fatalf("Expected nil, got error %v", err)
}
}
@@ -347,41 +595,50 @@ func TestDeleteRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- demo, _ := app.Dao().FindCollectionByNameOrId("demo")
- demo2, _ := app.Dao().FindCollectionByNameOrId("demo2")
+ demoCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
// delete unsaved record
// ---
- rec1 := models.NewRecord(demo)
- err1 := app.Dao().DeleteRecord(rec1)
- if err1 == nil {
- t.Fatal("(rec1) Didn't expect to succeed deleting new record")
+ rec0 := models.NewRecord(demoCollection)
+ if err := app.Dao().DeleteRecord(rec0); err == nil {
+ t.Fatal("(rec0) Didn't expect to succeed deleting unsaved record")
+ }
+
+ // delete existing record + external auths
+ // ---
+ rec1, _ := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
+ if err := app.Dao().DeleteRecord(rec1); err != nil {
+ t.Fatalf("(rec1) Expected nil, got error %v", err)
+ }
+ // check if it was really deleted
+ if refreshed, _ := app.Dao().FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil {
+ t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed)
+ }
+ // check if the external auths were deleted
+ if auths, _ := app.Dao().FindAllExternalAuthsByRecord(rec1); len(auths) > 0 {
+ t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths)
}
// delete existing record while being part of a non-cascade required relation
// ---
- rec2, _ := app.Dao().FindFirstRecordByData(demo, "id", "848a1dea-5ddd-42d6-a00d-030547bffcfe")
- err2 := app.Dao().DeleteRecord(rec2)
- if err2 == nil {
+ rec2, _ := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
+ if err := app.Dao().DeleteRecord(rec2); err == nil {
t.Fatalf("(rec2) Expected error, got nil")
}
- // delete existing record
+ // delete existing record + cascade
// ---
- rec3, _ := app.Dao().FindFirstRecordByData(demo, "id", "577bd676-aacb-4072-b7da-99d00ee210a4")
- err3 := app.Dao().DeleteRecord(rec3)
- if err3 != nil {
- t.Fatalf("(rec3) Expected nil, got error %v", err3)
+ rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
+ if err := app.Dao().DeleteRecord(rec3); err != nil {
+ t.Fatalf("(rec3) Expected nil, got error %v", err)
}
-
// check if it was really deleted
- rec3, _ = app.Dao().FindRecordById(demo, rec3.Id, nil)
+ rec3, _ = app.Dao().FindRecordById(rec3.Collection().Id, rec3.Id)
if rec3 != nil {
t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3)
}
-
// check if the operation cascaded
- rel, _ := app.Dao().FindFirstRecordByData(demo2, "id", "63c2ab80-84ab-4057-a592-4604a731f78f")
+ rel, _ := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
if rel != nil {
t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
}
@@ -391,16 +648,16 @@ func TestSyncRecordTableSchema(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- oldCollection, err := app.Dao().FindCollectionByNameOrId("demo")
+ oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
- updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo")
+ updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
updatedCollection.Name = "demo_renamed"
- updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("file").Id)
+ updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
updatedCollection.Schema.AddField(
&schema.SchemaField{
Name: "new_field",
@@ -421,6 +678,7 @@ func TestSyncRecordTableSchema(t *testing.T) {
expectedTableName string
expectedColumns []string
}{
+ // new base collection
{
&models.Collection{
Name: "new_table",
@@ -435,12 +693,32 @@ func TestSyncRecordTableSchema(t *testing.T) {
"new_table",
[]string{"id", "created", "updated", "test"},
},
+ // new auth collection
+ {
+ &models.Collection{
+ Name: "new_table_auth",
+ Type: models.CollectionTypeAuth,
+ Schema: schema.NewSchema(
+ &schema.SchemaField{
+ Name: "test",
+ Type: schema.FieldTypeText,
+ },
+ ),
+ },
+ nil,
+ "new_table_auth",
+ []string{
+ "id", "created", "updated", "test",
+ "username", "email", "verified", "emailVisibility",
+ "tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
+ },
+ },
// no changes
{
oldCollection,
oldCollection,
- "demo",
- []string{"id", "created", "updated", "title", "file"},
+ "demo3",
+ []string{"id", "created", "updated", "title", "active"},
},
// renamed table, deleted column, renamed columnd and new column
{
diff --git a/daos/request_test.go b/daos/request_test.go
index 97a1c28e..e41b8e39 100644
--- a/daos/request_test.go
+++ b/daos/request_test.go
@@ -59,7 +59,7 @@ func TestRequestsStats(t *testing.T) {
tests.MockRequestLogsData(app)
- expected := `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`
+ expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
now := time.Now().UTC().Format(types.DefaultDateLayout)
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
@@ -84,10 +84,10 @@ func TestDeleteOldRequests(t *testing.T) {
date string
expectedTotal int
}{
- {"2022-01-01 10:00:00.000", 2}, // no requests to delete before that time
- {"2022-05-01 11:00:00.000", 1}, // only 1 request should have left
- {"2022-05-03 11:00:00.000", 0}, // no more requests should have left
- {"2022-05-04 11:00:00.000", 0}, // no more requests should have left
+ {"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
+ {"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
+ {"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
+ {"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
}
for i, scenario := range scenarios {
diff --git a/daos/user.go b/daos/user.go
deleted file mode 100644
index 33ddd285..00000000
--- a/daos/user.go
+++ /dev/null
@@ -1,282 +0,0 @@
-package daos
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "log"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-// UserQuery returns a new User model select query.
-func (dao *Dao) UserQuery() *dbx.SelectQuery {
- return dao.ModelQuery(&models.User{})
-}
-
-// LoadProfile loads the profile record associated to the provided user.
-func (dao *Dao) LoadProfile(user *models.User) error {
- collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
- if err != nil {
- return err
- }
-
- profile, err := dao.FindFirstRecordByData(collection, models.ProfileCollectionUserFieldName, user.Id)
- if err != nil && err != sql.ErrNoRows {
- return err
- }
-
- user.Profile = profile
-
- return nil
-}
-
-// LoadProfiles loads the profile records associated to the provided users list.
-func (dao *Dao) LoadProfiles(users []*models.User) error {
- collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
- if err != nil {
- return err
- }
-
- // extract user ids
- ids := make([]string, len(users))
- usersMap := map[string]*models.User{}
- for i, user := range users {
- ids[i] = user.Id
- usersMap[user.Id] = user
- }
-
- profiles, err := dao.FindRecordsByExpr(collection, dbx.HashExp{
- models.ProfileCollectionUserFieldName: list.ToInterfaceSlice(ids),
- })
- if err != nil {
- return err
- }
-
- // populate each user.Profile member
- for _, profile := range profiles {
- userId := profile.GetStringDataValue(models.ProfileCollectionUserFieldName)
- user, ok := usersMap[userId]
- if !ok {
- continue
- }
- user.Profile = profile
- }
-
- return nil
-}
-
-// FindUserById finds a single User model by its id.
-//
-// This method also auto loads the related user profile record
-// into the found model.
-func (dao *Dao) FindUserById(id string) (*models.User, error) {
- model := &models.User{}
-
- err := dao.UserQuery().
- AndWhere(dbx.HashExp{"id": id}).
- Limit(1).
- One(model)
-
- if err != nil {
- return nil, err
- }
-
- // try to load the user profile (if exist)
- if err := dao.LoadProfile(model); err != nil {
- log.Println(err)
- }
-
- return model, nil
-}
-
-// FindUserByEmail finds a single User model by its non-empty email address.
-//
-// This method also auto loads the related user profile record
-// into the found model.
-func (dao *Dao) FindUserByEmail(email string) (*models.User, error) {
- model := &models.User{}
-
- err := dao.UserQuery().
- AndWhere(dbx.Not(dbx.HashExp{"email": ""})).
- AndWhere(dbx.HashExp{"email": email}).
- Limit(1).
- One(model)
-
- if err != nil {
- return nil, err
- }
-
- // try to load the user profile (if exist)
- if err := dao.LoadProfile(model); err != nil {
- log.Println(err)
- }
-
- return model, nil
-}
-
-// FindUserByToken finds the user associated with the provided JWT token.
-// Returns an error if the JWT token is invalid or expired.
-//
-// This method also auto loads the related user profile record
-// into the found model.
-func (dao *Dao) FindUserByToken(token string, baseTokenKey string) (*models.User, error) {
- unverifiedClaims, err := security.ParseUnverifiedJWT(token)
- if err != nil {
- return nil, err
- }
-
- // check required claims
- id, _ := unverifiedClaims["id"].(string)
- if id == "" {
- return nil, errors.New("Missing or invalid token claims.")
- }
-
- user, err := dao.FindUserById(id)
- if err != nil || user == nil {
- return nil, err
- }
-
- verificationKey := user.TokenKey + baseTokenKey
-
- // verify token signature
- if _, err := security.ParseJWT(token, verificationKey); err != nil {
- return nil, err
- }
-
- return user, nil
-}
-
-// IsUserEmailUnique checks if the provided email address is not
-// already in use by other users.
-func (dao *Dao) IsUserEmailUnique(email string, excludeId string) bool {
- if email == "" {
- return false
- }
-
- var exists bool
- err := dao.UserQuery().
- Select("count(*)").
- AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
- AndWhere(dbx.HashExp{"email": email}).
- Limit(1).
- Row(&exists)
-
- return err == nil && !exists
-}
-
-// DeleteUser deletes the provided User model.
-//
-// This method will also cascade the delete operation to all
-// Record models that references the provided User model
-// (delete or set to NULL, depending on the related user shema field settings).
-//
-// The delete operation may fail if the user is part of a required
-// reference in another Record model (aka. cannot be deleted or set to NULL).
-func (dao *Dao) DeleteUser(user *models.User) error {
- // fetch related records
- // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
- relatedRecords, err := dao.FindUserRelatedRecords(user)
- if err != nil {
- return err
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- // check if related records has to be deleted (if `CascadeDelete` is set)
- // OR
- // just unset the user related fields (if they are not required)
- // -----------------------------------------------------------
- recordsLoop:
- for _, record := range relatedRecords {
- var needSave bool
-
- for _, field := range record.Collection().Schema.Fields() {
- if field.Type != schema.FieldTypeUser {
- continue // not a user field
- }
-
- ids := record.GetStringSliceDataValue(field.Name)
-
- // unset the user id
- for i := len(ids) - 1; i >= 0; i-- {
- if ids[i] == user.Id {
- ids = append(ids[:i], ids[i+1:]...)
- break
- }
- }
-
- options, _ := field.Options.(*schema.UserOptions)
-
- // cascade delete
- // (only if there are no other user references in case of multiple select)
- if options.CascadeDelete && len(ids) == 0 {
- if err := txDao.DeleteRecord(record); err != nil {
- return err
- }
- // no need to further iterate the user fields (the record is deleted)
- continue recordsLoop
- }
-
- if field.Required && len(ids) == 0 {
- return fmt.Errorf("Failed delete the user because a record exist with required user reference to the current model (%q, %q).", record.Id, record.Collection().Name)
- }
-
- // apply the reference changes
- record.SetDataValue(field.Name, field.PrepareValue(ids))
- needSave = true
- }
-
- if needSave {
- if err := txDao.SaveRecord(record); err != nil {
- return err
- }
- }
- }
- // -----------------------------------------------------------
-
- return txDao.Delete(user)
- })
-}
-
-// SaveUser upserts the provided User model.
-//
-// An empty profile record will be created if the user
-// doesn't have a profile record set yet.
-func (dao *Dao) SaveUser(user *models.User) error {
- profileCollection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
- if err != nil {
- return err
- }
-
- // fetch the related user profile record (if exist)
- var userProfile *models.Record
- if user.HasId() {
- userProfile, _ = dao.FindFirstRecordByData(
- profileCollection,
- models.ProfileCollectionUserFieldName,
- user.Id,
- )
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- if err := txDao.Save(user); err != nil {
- return err
- }
-
- // create default/empty profile record if doesn't exist
- if userProfile == nil {
- userProfile = models.NewRecord(profileCollection)
- userProfile.SetDataValue(models.ProfileCollectionUserFieldName, user.Id)
- if err := txDao.Save(userProfile); err != nil {
- return err
- }
- user.Profile = userProfile
- }
-
- return nil
- })
-}
diff --git a/daos/user_test.go b/daos/user_test.go
deleted file mode 100644
index 895328c9..00000000
--- a/daos/user_test.go
+++ /dev/null
@@ -1,275 +0,0 @@
-package daos_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestUserQuery(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- expected := "SELECT {{_users}}.* FROM `_users`"
-
- sql := app.Dao().UserQuery().Build().SQL()
- if sql != expected {
- t.Errorf("Expected sql %s, got %s", expected, sql)
- }
-}
-
-func TestLoadProfile(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // try to load missing profile (shouldn't return an error)
- // ---
- newUser := &models.User{}
- err1 := app.Dao().LoadProfile(newUser)
- if err1 != nil {
- t.Fatalf("Expected nil, got error %v", err1)
- }
-
- // try to load existing profile
- // ---
- existingUser, _ := app.Dao().FindUserByEmail("test@example.com")
- existingUser.Profile = nil // reset
-
- err2 := app.Dao().LoadProfile(existingUser)
- if err2 != nil {
- t.Fatal(err2)
- }
-
- if existingUser.Profile == nil {
- t.Fatal("Expected user profile to be loaded, got nil")
- }
-
- if existingUser.Profile.GetStringDataValue("name") != "test" {
- t.Fatalf("Expected profile.name to be 'test', got %s", existingUser.Profile.GetStringDataValue("name"))
- }
-}
-
-func TestLoadProfiles(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- u0 := &models.User{}
- u1, _ := app.Dao().FindUserByEmail("test@example.com")
- u2, _ := app.Dao().FindUserByEmail("test2@example.com")
-
- users := []*models.User{u0, u1, u2}
-
- err := app.Dao().LoadProfiles(users)
- if err != nil {
- t.Fatal(err)
- }
-
- if u0.Profile != nil {
- t.Errorf("Expected profile to be nil for u0, got %v", u0.Profile)
- }
- if u1.Profile == nil {
- t.Errorf("Expected profile to be set for u1, got nil")
- }
- if u2.Profile == nil {
- t.Errorf("Expected profile to be set for u2, got nil")
- }
-}
-
-func TestFindUserById(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- id string
- expectError bool
- }{
- {"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
- {"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", false},
- }
-
- for i, scenario := range scenarios {
- user, err := app.Dao().FindUserById(scenario.id)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if user != nil && user.Id != scenario.id {
- t.Errorf("(%d) Expected user with id %s, got %s", i, scenario.id, user.Id)
- }
- }
-}
-
-func TestFindUserByEmail(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- email string
- expectError bool
- }{
- {"", true},
- {"invalid", true},
- {"missing@example.com", true},
- {"test@example.com", false},
- }
-
- for i, scenario := range scenarios {
- user, err := app.Dao().FindUserByEmail(scenario.email)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- continue
- }
-
- if !scenario.expectError && user.Email != scenario.email {
- t.Errorf("(%d) Expected user with email %s, got %s", i, scenario.email, user.Email)
- }
- }
-}
-
-func TestFindUserByToken(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- token string
- baseKey string
- expectedEmail string
- expectError bool
- }{
- // invalid base key (password reset key for auth token)
- {
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- app.Settings().UserPasswordResetToken.Secret,
- "",
- true,
- },
- // expired token
- {
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.RrSG5NwysI38DEZrIQiz3lUgI6sEuYGTll_jLRbBSiw",
- app.Settings().UserAuthToken.Secret,
- "",
- true,
- },
- // valid token
- {
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- app.Settings().UserAuthToken.Secret,
- "test@example.com",
- false,
- },
- }
-
- for i, scenario := range scenarios {
- user, err := app.Dao().FindUserByToken(scenario.token, scenario.baseKey)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- continue
- }
-
- if !scenario.expectError && user.Email != scenario.expectedEmail {
- t.Errorf("(%d) Expected user model %s, got %s", i, scenario.expectedEmail, user.Email)
- }
- }
-}
-
-func TestIsUserEmailUnique(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- email string
- excludeId string
- expected bool
- }{
- {"", "", false},
- {"test@example.com", "", false},
- {"new@example.com", "", true},
- {"test@example.com", "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", true},
- }
-
- for i, scenario := range scenarios {
- result := app.Dao().IsUserEmailUnique(scenario.email, scenario.excludeId)
- if result != scenario.expected {
- t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
- }
- }
-}
-
-func TestDeleteUser(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // try to delete unsaved user
- // ---
- err1 := app.Dao().DeleteUser(&models.User{})
- if err1 == nil {
- t.Fatal("Expected error, got nil")
- }
-
- // try to delete existing user
- // ---
- user, _ := app.Dao().FindUserByEmail("test3@example.com")
- err2 := app.Dao().DeleteUser(user)
- if err2 != nil {
- t.Fatalf("Expected nil, got error %v", err2)
- }
-
- // check if the delete operation was cascaded to the profiles collection (record delete)
- profilesCol, _ := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
- profile, _ := app.Dao().FindRecordById(profilesCol, user.Profile.Id, nil)
- if profile != nil {
- t.Fatalf("Expected user profile to be deleted, got %v", profile)
- }
-
- // check if delete operation was cascaded to the related demo2 collection (null set)
- demo2Col, _ := app.Dao().FindCollectionByNameOrId("demo2")
- record, _ := app.Dao().FindRecordById(demo2Col, "94568ca2-0bee-49d7-b749-06cb97956fd9", nil)
- if record == nil {
- t.Fatal("Expected to found related record, got nil")
- }
- if record.GetStringDataValue("user") != "" {
- t.Fatalf("Expected user field to be set to empty string, got %v", record.GetStringDataValue("user"))
- }
-}
-
-func TestSaveUser(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create
- // ---
- u1 := &models.User{}
- u1.Email = "new@example.com"
- u1.SetPassword("123456")
- err1 := app.Dao().SaveUser(u1)
- if err1 != nil {
- t.Fatal(err1)
- }
- u1, refreshErr1 := app.Dao().FindUserByEmail("new@example.com")
- if refreshErr1 != nil {
- t.Fatalf("Expected user with email new@example.com to have been created, got error %v", refreshErr1)
- }
- if u1.Profile == nil {
- t.Fatalf("Expected creating a user to create also an empty profile record")
- }
-
- // update
- // ---
- u2, _ := app.Dao().FindUserByEmail("test@example.com")
- u2.Email = "test_update@example.com"
- err2 := app.Dao().SaveUser(u2)
- if err2 != nil {
- t.Fatal(err2)
- }
- u2, refreshErr2 := app.Dao().FindUserByEmail("test_update@example.com")
- if u2 == nil {
- t.Fatalf("Couldn't find user with email test_update@example.com (%v)", refreshErr2)
- }
-}
diff --git a/examples/base/main.go b/examples/base/main.go
index abd1cc71..41655c04 100644
--- a/examples/base/main.go
+++ b/examples/base/main.go
@@ -35,7 +35,7 @@ func main() {
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(publicDirFlag), false))
+ e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDirFlag), true))
return nil
})
diff --git a/forms/admin_login.go b/forms/admin_login.go
index d2e7b833..a88d1ad5 100644
--- a/forms/admin_login.go
+++ b/forms/admin_login.go
@@ -10,53 +10,36 @@ import (
"github.com/pocketbase/pocketbase/models"
)
-// AdminLogin specifies an admin email/pass login form.
+// AdminLogin is an admin email/pass login form.
type AdminLogin struct {
- config AdminLoginConfig
+ app core.App
+ dao *daos.Dao
- Email string `form:"email" json:"email"`
+ Identity string `form:"identity" json:"identity"`
Password string `form:"password" json:"password"`
}
-// AdminLoginConfig is the [AdminLogin] factory initializer config.
+// NewAdminLogin creates a new [AdminLogin] form initialized with
+// the provided [core.App] instance.
//
-// NB! App is a required struct member.
-type AdminLoginConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
-// NewAdminLogin creates a new [AdminLogin] form with initializer
-// config created from the provided [core.App] instance.
-//
-// If you want to submit the form as part of another transaction, use
-// [NewAdminLoginWithConfig] with explicitly set Dao.
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
func NewAdminLogin(app core.App) *AdminLogin {
- return NewAdminLoginWithConfig(AdminLoginConfig{
- App: app,
- })
+ return &AdminLogin{
+ app: app,
+ dao: app.Dao(),
+ }
}
-// NewAdminLoginWithConfig creates a new [AdminLogin] form
-// with the provided config or panics on invalid configuration.
-func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin {
- form := &AdminLogin{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *AdminLogin) SetDao(dao *daos.Dao) {
+ form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminLogin) Validate() error {
return validation.ValidateStruct(form,
- validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.EmailFormat),
+ validation.Field(&form.Identity, validation.Required, validation.Length(1, 255), is.EmailFormat),
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
)
}
@@ -68,7 +51,7 @@ func (form *AdminLogin) Submit() (*models.Admin, error) {
return nil, err
}
- admin, err := form.config.Dao.FindAdminByEmail(form.Email)
+ admin, err := form.dao.FindAdminByEmail(form.Identity)
if err != nil {
return nil, err
}
diff --git a/forms/admin_login_test.go b/forms/admin_login_test.go
index 5124bdc7..bd63e7c2 100644
--- a/forms/admin_login_test.go
+++ b/forms/admin_login_test.go
@@ -7,48 +7,7 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
-func TestAdminLoginPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewAdminLogin(nil)
-}
-
-func TestAdminLoginValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- form := forms.NewAdminLogin(app)
-
- scenarios := []struct {
- email string
- password string
- expectError bool
- }{
- {"", "", true},
- {"", "123", true},
- {"test@example.com", "", true},
- {"test", "123", true},
- {"test@example.com", "123", false},
- }
-
- for i, s := range scenarios {
- form.Email = s.email
- form.Password = s.password
-
- err := form.Validate()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
-
-func TestAdminLoginSubmit(t *testing.T) {
+func TestAdminLoginValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -62,14 +21,14 @@ func TestAdminLoginSubmit(t *testing.T) {
{"", "", true},
{"", "1234567890", true},
{"test@example.com", "", true},
- {"test", "1234567890", true},
+ {"test", "test", true},
{"missing@example.com", "1234567890", true},
{"test@example.com", "123456789", true},
{"test@example.com", "1234567890", false},
}
for i, s := range scenarios {
- form.Email = s.email
+ form.Identity = s.email
form.Password = s.password
admin, err := form.Submit()
diff --git a/forms/admin_password_reset_confirm.go b/forms/admin_password_reset_confirm.go
index 9898c078..134abc3f 100644
--- a/forms/admin_password_reset_confirm.go
+++ b/forms/admin_password_reset_confirm.go
@@ -8,55 +8,41 @@ import (
"github.com/pocketbase/pocketbase/models"
)
-// AdminPasswordResetConfirm specifies an admin password reset confirmation form.
+// AdminPasswordResetConfirm is an admin password reset confirmation form.
type AdminPasswordResetConfirm struct {
- config AdminPasswordResetConfirmConfig
+ app core.App
+ dao *daos.Dao
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
-// AdminPasswordResetConfirmConfig is the [AdminPasswordResetConfirm] factory initializer config.
-//
-// NB! App is required struct member.
-type AdminPasswordResetConfirmConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
-// form with initializer config created from the provided [core.App] instance.
+// form initialized with from the provided [core.App] instance.
//
-// If you want to submit the form as part of another transaction, use
-// [NewAdminPasswordResetConfirmWithConfig] with explicitly set Dao.
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
- return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{
- App: app,
- })
+ return &AdminPasswordResetConfirm{
+ app: app,
+ dao: app.Dao(),
+ }
}
-// NewAdminPasswordResetConfirmWithConfig creates a new [AdminPasswordResetConfirm]
-// form with the provided config or panics on invalid configuration.
-func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConfig) *AdminPasswordResetConfirm {
- form := &AdminPasswordResetConfirm{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
+// SetDao replaces the form Dao instance with the provided one.
+//
+// This is useful if you want to use a specific transaction Dao instance
+// instead of the default app.Dao().
+func (form *AdminPasswordResetConfirm) SetDao(dao *daos.Dao) {
+ form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminPasswordResetConfirm) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
- validation.Field(&form.Password, validation.Required, validation.Length(10, 100)),
+ validation.Field(&form.Password, validation.Required, validation.Length(10, 72)),
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
)
}
@@ -67,10 +53,7 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
return nil // nothing to check
}
- admin, err := form.config.Dao.FindAdminByToken(
- v,
- form.config.App.Settings().AdminPasswordResetToken.Secret,
- )
+ admin, err := form.dao.FindAdminByToken(v, form.app.Settings().AdminPasswordResetToken.Secret)
if err != nil || admin == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
}
@@ -85,9 +68,9 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
- admin, err := form.config.Dao.FindAdminByToken(
+ admin, err := form.dao.FindAdminByToken(
form.Token,
- form.config.App.Settings().AdminPasswordResetToken.Secret,
+ form.app.Settings().AdminPasswordResetToken.Secret,
)
if err != nil {
return nil, err
@@ -97,7 +80,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
- if err := form.config.Dao.SaveAdmin(admin); err != nil {
+ if err := form.dao.SaveAdmin(admin); err != nil {
return nil, err
}
diff --git a/forms/admin_password_reset_confirm_test.go b/forms/admin_password_reset_confirm_test.go
index de894f57..fc825838 100644
--- a/forms/admin_password_reset_confirm_test.go
+++ b/forms/admin_password_reset_confirm_test.go
@@ -8,17 +8,7 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
-func TestAdminPasswordResetPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewAdminPasswordResetConfirm(nil)
-}
-
-func TestAdminPasswordResetConfirmValidate(t *testing.T) {
+func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -38,64 +28,23 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
{"test", "123", "123", true},
{
// expired
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
"1234567890",
"1234567890",
true,
},
{
- // valid
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
- "1234567890",
- "1234567890",
- false,
- },
- }
-
- for i, s := range scenarios {
- form.Token = s.token
- form.Password = s.password
- form.PasswordConfirm = s.passwordConfirm
-
- err := form.Validate()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
-
-func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- form := forms.NewAdminPasswordResetConfirm(app)
-
- scenarios := []struct {
- token string
- password string
- passwordConfirm string
- expectError bool
- }{
- {"", "", "", true},
- {"", "123", "", true},
- {"", "", "123", true},
- {"test", "", "", true},
- {"test", "123", "", true},
- {"test", "123", "123", true},
- {
- // expired
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
- "1234567890",
+ // valid with mismatched passwords
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"1234567890",
+ "1234567891",
true,
},
{
- // valid
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
- "1234567890",
- "1234567890",
+ // valid with matching passwords
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
+ "1234567891",
+ "1234567891",
false,
},
}
@@ -110,6 +59,7 @@ func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
+ continue
}
if s.expectError {
diff --git a/forms/admin_password_reset_request.go b/forms/admin_password_reset_request.go
index 546113fd..1abfd9d8 100644
--- a/forms/admin_password_reset_request.go
+++ b/forms/admin_password_reset_request.go
@@ -12,48 +12,31 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
-// AdminPasswordResetRequest specifies an admin password reset request form.
+// AdminPasswordResetRequest is an admin password reset request form.
type AdminPasswordResetRequest struct {
- config AdminPasswordResetRequestConfig
+ app core.App
+ dao *daos.Dao
+ resendThreshold float64 // in seconds
Email string `form:"email" json:"email"`
}
-// AdminPasswordResetRequestConfig is the [AdminPasswordResetRequest] factory initializer config.
-//
-// NB! App is required struct member.
-type AdminPasswordResetRequestConfig struct {
- App core.App
- Dao *daos.Dao
- ResendThreshold float64 // in seconds
-}
-
// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
-// form with initializer config created from the provided [core.App] instance.
+// form initialized with from the provided [core.App] instance.
//
-// If you want to submit the form as part of another transaction, use
-// [NewAdminPasswordResetRequestWithConfig] with explicitly set Dao.
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
- return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{
- App: app,
- ResendThreshold: 120, // 2min
- })
+ return &AdminPasswordResetRequest{
+ app: app,
+ dao: app.Dao(),
+ resendThreshold: 120, // 2min
+ }
}
-// NewAdminPasswordResetRequestWithConfig creates a new [AdminPasswordResetRequest]
-// form with the provided config or panics on invalid configuration.
-func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConfig) *AdminPasswordResetRequest {
- form := &AdminPasswordResetRequest{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *AdminPasswordResetRequest) SetDao(dao *daos.Dao) {
+ form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -77,23 +60,23 @@ func (form *AdminPasswordResetRequest) Submit() error {
return err
}
- admin, err := form.config.Dao.FindAdminByEmail(form.Email)
+ admin, err := form.dao.FindAdminByEmail(form.Email)
if err != nil {
return err
}
now := time.Now().UTC()
lastResetSentAt := admin.LastResetSentAt.Time()
- if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
+ if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
return errors.New("You have already requested a password reset.")
}
- if err := mails.SendAdminPasswordReset(form.config.App, admin); err != nil {
+ if err := mails.SendAdminPasswordReset(form.app, admin); err != nil {
return err
}
// update last sent timestamp
admin.LastResetSentAt = types.NowDateTime()
- return form.config.Dao.SaveAdmin(admin)
+ return form.dao.SaveAdmin(admin)
}
diff --git a/forms/admin_password_reset_request_test.go b/forms/admin_password_reset_request_test.go
index 123804a2..0261c935 100644
--- a/forms/admin_password_reset_request_test.go
+++ b/forms/admin_password_reset_request_test.go
@@ -7,46 +7,7 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
-func TestAdminPasswordResetRequestPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewAdminPasswordResetRequest(nil)
-}
-
-func TestAdminPasswordResetRequestValidate(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- form := forms.NewAdminPasswordResetRequest(testApp)
-
- scenarios := []struct {
- email string
- expectError bool
- }{
- {"", true},
- {"", true},
- {"invalid", true},
- {"missing@example.com", false}, // doesn't check for existing admin
- {"test@example.com", false},
- }
-
- for i, s := range scenarios {
- form.Email = s.email
-
- err := form.Validate()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
-
-func TestAdminPasswordResetRequestSubmit(t *testing.T) {
+func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
diff --git a/forms/admin_upsert.go b/forms/admin_upsert.go
index f2916d43..b1212c09 100644
--- a/forms/admin_upsert.go
+++ b/forms/admin_upsert.go
@@ -9,10 +9,11 @@ import (
"github.com/pocketbase/pocketbase/models"
)
-// AdminUpsert specifies a [models.Admin] upsert (create/update) form.
+// AdminUpsert is a [models.Admin] upsert (create/update) form.
type AdminUpsert struct {
- config AdminUpsertConfig
- admin *models.Admin
+ app core.App
+ dao *daos.Dao
+ admin *models.Admin
Id string `form:"id" json:"id"`
Avatar int `form:"avatar" json:"avatar"`
@@ -21,41 +22,17 @@ type AdminUpsert struct {
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
-// AdminUpsertConfig is the [AdminUpsert] factory initializer config.
-//
-// NB! App is a required struct member.
-type AdminUpsertConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
// config created from the provided [core.App] and [models.Admin] instances
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
//
-// If you want to submit the form as part of another transaction, use
-// [NewAdminUpsertWithConfig] with explicitly set Dao.
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
- return NewAdminUpsertWithConfig(AdminUpsertConfig{
- App: app,
- }, admin)
-}
-
-// NewAdminUpsertWithConfig creates a new [AdminUpsert] form
-// with the provided config and [models.Admin] instance or panics on invalid configuration
-// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
-func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *AdminUpsert {
form := &AdminUpsert{
- config: config,
- admin: admin,
- }
-
- if form.config.App == nil || form.admin == nil {
- panic("Invalid initializer config or nil upsert model.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
+ app: app,
+ dao: app.Dao(),
+ admin: admin,
}
// load defaults
@@ -66,6 +43,11 @@ func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *Ad
return form
}
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *AdminUpsert) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminUpsert) Validate() error {
return validation.ValidateStruct(form,
@@ -92,7 +74,7 @@ func (form *AdminUpsert) Validate() error {
validation.Field(
&form.Password,
validation.When(form.admin.IsNew(), validation.Required),
- validation.Length(10, 100),
+ validation.Length(10, 72),
),
validation.Field(
&form.PasswordConfirm,
@@ -105,7 +87,7 @@ func (form *AdminUpsert) Validate() error {
func (form *AdminUpsert) checkUniqueEmail(value any) error {
v, _ := value.(string)
- if form.config.Dao.IsAdminEmailUnique(v, form.admin.Id) {
+ if form.dao.IsAdminEmailUnique(v, form.admin.Id) {
return nil
}
@@ -135,6 +117,6 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
}
return runInterceptors(func() error {
- return form.config.Dao.SaveAdmin(form.admin)
+ return form.dao.SaveAdmin(form.admin)
}, interceptors...)
}
diff --git a/forms/admin_upsert_test.go b/forms/admin_upsert_test.go
index 2ec6c42e..e92f029e 100644
--- a/forms/admin_upsert_test.go
+++ b/forms/admin_upsert_test.go
@@ -6,35 +6,11 @@ import (
"fmt"
"testing"
- validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
-func TestAdminUpsertPanic1(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewAdminUpsert(nil, nil)
-}
-
-func TestAdminUpsertPanic2(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewAdminUpsert(app, nil)
-}
-
func TestNewAdminUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -54,125 +30,7 @@ func TestNewAdminUpsert(t *testing.T) {
}
}
-func TestAdminUpsertValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- id string
- avatar int
- email string
- password string
- passwordConfirm string
- expectedErrors int
- }{
- {
- "",
- -1,
- "",
- "",
- "",
- 3,
- },
- {
- "",
- 10,
- "invalid",
- "12345678",
- "87654321",
- 4,
- },
- {
- // existing email
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
- 3,
- "test2@example.com",
- "1234567890",
- "1234567890",
- 1,
- },
- {
- // mismatching passwords
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
- 3,
- "test@example.com",
- "1234567890",
- "1234567891",
- 1,
- },
- {
- // create without setting password
- "",
- 9,
- "test_create@example.com",
- "",
- "",
- 1,
- },
- {
- // create with existing email
- "",
- 9,
- "test@example.com",
- "1234567890!",
- "1234567890!",
- 1,
- },
- {
- // update without setting password
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
- 3,
- "test_update@example.com",
- "",
- "",
- 0,
- },
- {
- // create with password
- "",
- 9,
- "test_create@example.com",
- "1234567890!",
- "1234567890!",
- 0,
- },
- {
- // update with password
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
- 4,
- "test_update@example.com",
- "1234567890",
- "1234567890",
- 0,
- },
- }
-
- for i, s := range scenarios {
- admin := &models.Admin{}
- if s.id != "" {
- admin, _ = app.Dao().FindAdminById(s.id)
- }
-
- form := forms.NewAdminUpsert(app, admin)
- form.Avatar = s.avatar
- form.Email = s.email
- form.Password = s.password
- form.PasswordConfirm = s.passwordConfirm
-
- result := form.Validate()
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- if len(errs) != s.expectedErrors {
- t.Errorf("(%d) Expected %d errors, got %d (%v)", i, s.expectedErrors, len(errs), errs)
- }
- }
-}
-
-func TestAdminUpsertSubmit(t *testing.T) {
+func TestAdminUpsertValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -189,7 +47,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
},
{
// update empty
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ "sywbhecnh46rhm0",
`{}`,
false,
},
@@ -225,7 +83,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
},
{
// update failure - existing email
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ "sywbhecnh46rhm0",
`{
"email": "test2@example.com"
}`,
@@ -233,7 +91,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
},
{
// update failure - mismatching passwords
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ "sywbhecnh46rhm0",
`{
"password": "1234567890",
"passwordConfirm": "1234567891"
@@ -242,7 +100,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
},
{
// update success - new email
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ "sywbhecnh46rhm0",
`{
"email": "test_update@example.com"
}`,
@@ -250,7 +108,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
},
{
// update success - new password
- "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ "sywbhecnh46rhm0",
`{
"password": "1234567890",
"passwordConfirm": "1234567890"
diff --git a/forms/base.go b/forms/base.go
index ee7bad20..46c2251f 100644
--- a/forms/base.go
+++ b/forms/base.go
@@ -2,7 +2,9 @@
// validation and applying changes to existing DB models through the app Dao.
package forms
-import "regexp"
+import (
+ "regexp"
+)
// base ID value regex pattern
var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
diff --git a/forms/collection_upsert.go b/forms/collection_upsert.go
index 387bca28..7ab76d15 100644
--- a/forms/collection_upsert.go
+++ b/forms/collection_upsert.go
@@ -1,6 +1,7 @@
package forms
import (
+ "encoding/json"
"fmt"
"regexp"
"strings"
@@ -11,17 +12,21 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
+ "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/types"
)
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
-// CollectionUpsert specifies a [models.Collection] upsert (create/update) form.
+// CollectionUpsert is a [models.Collection] upsert (create/update) form.
type CollectionUpsert struct {
- config CollectionUpsertConfig
+ app core.App
+ 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"`
@@ -30,47 +35,25 @@ type CollectionUpsert struct {
CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
-}
-
-// CollectionUpsertConfig is the [CollectionUpsert] factory initializer config.
-//
-// NB! App is a required struct member.
-type CollectionUpsertConfig struct {
- App core.App
- Dao *daos.Dao
+ Options types.JsonMap `form:"options" json:"options"`
}
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
// config created from the provided [core.App] and [models.Collection] instances
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
//
-// If you want to submit the form as part of another transaction, use
-// [NewCollectionUpsertWithConfig] with explicitly set Dao.
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
- return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
- App: app,
- }, collection)
-}
-
-// NewCollectionUpsertWithConfig creates a new [CollectionUpsert] form
-// with the provided config and [models.Collection] instance or panics on invalid configuration
-// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
-func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *models.Collection) *CollectionUpsert {
form := &CollectionUpsert{
- config: config,
+ app: app,
+ dao: app.Dao(),
collection: collection,
}
- if form.config.App == nil || form.collection == nil {
- panic("Invalid initializer config or nil upsert model.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
// load defaults
form.Id = form.collection.Id
+ form.Type = form.collection.Type
form.Name = form.collection.Name
form.System = form.collection.System
form.ListRule = form.collection.ListRule
@@ -78,6 +61,11 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
form.CreateRule = form.collection.CreateRule
form.UpdateRule = form.collection.UpdateRule
form.DeleteRule = form.collection.DeleteRule
+ form.Options = form.collection.Options
+
+ if form.Type == "" {
+ form.Type = models.CollectionTypeBase
+ }
clone, _ := form.collection.Schema.Clone()
if clone != nil {
@@ -89,8 +77,15 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
return form
}
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionUpsert) Validate() error {
+ isAuth := form.Type == models.CollectionTypeAuth
+
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
@@ -104,6 +99,12 @@ func (form *CollectionUpsert) Validate() error {
&form.System,
validation.By(form.ensureNoSystemFlagChange),
),
+ validation.Field(
+ &form.Type,
+ validation.Required,
+ validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
+ validation.By(form.ensureNoTypeChange),
+ ),
validation.Field(
&form.Name,
validation.Required,
@@ -118,23 +119,35 @@ func (form *CollectionUpsert) Validate() error {
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureExistingRelationCollectionId),
+ validation.When(
+ isAuth,
+ validation.By(form.ensureNoAuthFieldName),
+ ),
),
validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
+ validation.Field(&form.Options, validation.By(form.checkOptions)),
)
}
func (form *CollectionUpsert) checkUniqueName(value any) error {
v, _ := value.(string)
- if !form.config.Dao.IsCollectionNameUnique(v, form.collection.Id) {
+ // ensure unique collection name
+ if !form.dao.IsCollectionNameUnique(v, form.collection.Id) {
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
}
- if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.Dao.HasTable(v) {
+ // ensure that the collection name doesn't collide with the id of any collection
+ if form.dao.FindById(&models.Collection{}, v) == nil {
+ return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
+ }
+
+ // ensure that there is no existing table name with the same name
+ if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
}
@@ -144,21 +157,31 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
v, _ := value.(string)
- if form.collection.IsNew() || !form.collection.System || v == form.collection.Name {
- return nil
+ if !form.collection.IsNew() && form.collection.System && v != form.collection.Name {
+ return validation.NewError("validation_collection_system_name_change", "System collections cannot be renamed.")
}
- return validation.NewError("validation_system_collection_name_change", "System collections cannot be renamed.")
+ return nil
}
func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
v, _ := value.(bool)
- if form.collection.IsNew() || v == form.collection.System {
- return nil
+ if !form.collection.IsNew() && v != form.collection.System {
+ return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.")
}
- return validation.NewError("validation_system_collection_flag_change", "System collection state cannot be changed.")
+ return nil
+}
+
+func (form *CollectionUpsert) ensureNoTypeChange(value any) error {
+ v, _ := value.(string)
+
+ if !form.collection.IsNew() && v != form.collection.Type {
+ return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.")
+ }
+
+ return nil
}
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
@@ -191,7 +214,7 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
continue
}
- if _, err := form.config.Dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
+ 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.",
@@ -202,6 +225,36 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
return nil
}
+func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
+ v, _ := value.(schema.Schema)
+
+ if form.Type != models.CollectionTypeAuth {
+ return nil // not an auth collection
+ }
+
+ authFieldNames := schema.AuthFieldNames()
+ // exclude the meta RecordUpsert form fields
+ authFieldNames = append(authFieldNames, "password", "passwordConfirm", "oldPassword")
+
+ errs := validation.Errors{}
+ for i, field := range v.Fields() {
+ if list.ExistInSlice(field.Name, authFieldNames) {
+ errs[fmt.Sprint(i)] = validation.Errors{
+ "name": validation.NewError(
+ "validation_reserved_auth_field_name",
+ "The field name is reserved and cannot be used.",
+ ),
+ }
+ }
+ }
+
+ if len(errs) > 0 {
+ return errs
+ }
+
+ return nil
+}
+
func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
v, _ := value.(schema.Schema)
@@ -222,17 +275,44 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
func (form *CollectionUpsert) checkRule(value any) error {
v, _ := value.(*string)
-
if v == nil || *v == "" {
return nil // nothing to check
}
dummy := &models.Collection{Schema: form.Schema}
- r := resolvers.NewRecordFieldResolver(form.config.Dao, dummy, nil)
+ r := resolvers.NewRecordFieldResolver(form.dao, dummy, nil, true)
_, err := search.FilterData(*v).BuildExpr(r)
if err != nil {
- return validation.NewError("validation_collection_rule", "Invalid filter rule.")
+ return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
+ }
+
+ return nil
+}
+
+func (form *CollectionUpsert) checkOptions(value any) error {
+ v, _ := value.(types.JsonMap)
+
+ if form.Type == models.CollectionTypeAuth {
+ raw, err := v.MarshalJSON()
+ if err != nil {
+ return validation.NewError("validation_invalid_options", "Invalid options.")
+ }
+
+ options := models.CollectionAuthOptions{}
+ if err := json.Unmarshal(raw, &options); err != nil {
+ return validation.NewError("validation_invalid_options", "Invalid options.")
+ }
+
+ // check the generic validations
+ if err := options.Validate(); err != nil {
+ return err
+ }
+
+ // additional form specific validations
+ if err := form.checkRule(options.ManageRule); err != nil {
+ return validation.Errors{"manageRule": err}
+ }
}
return nil
@@ -250,6 +330,9 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
}
if form.collection.IsNew() {
+ // type can be set only on create
+ form.collection.Type = form.Type
+
// system flag can be set only on create
form.collection.System = form.System
@@ -271,8 +354,9 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
form.collection.CreateRule = form.CreateRule
form.collection.UpdateRule = form.UpdateRule
form.collection.DeleteRule = form.DeleteRule
+ form.collection.SetOptions(form.Options)
return runInterceptors(func() error {
- return form.config.Dao.SaveCollection(form.collection)
+ return form.dao.SaveCollection(form.collection)
}, interceptors...)
}
diff --git a/forms/collection_upsert_test.go b/forms/collection_upsert_test.go
index 0595f493..db2bd6bb 100644
--- a/forms/collection_upsert_test.go
+++ b/forms/collection_upsert_test.go
@@ -14,35 +14,13 @@ import (
"github.com/spf13/cast"
)
-func TestCollectionUpsertPanic1(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewCollectionUpsert(nil, nil)
-}
-
-func TestCollectionUpsertPanic2(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewCollectionUpsert(app, nil)
-}
-
func TestNewCollectionUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := &models.Collection{}
- collection.Name = "test"
+ collection.Name = "test_name"
+ collection.Type = "test_type"
collection.System = true
listRule := "testview"
collection.ListRule = &listRule
@@ -65,6 +43,10 @@ func TestNewCollectionUpsert(t *testing.T) {
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
}
+ if form.Type != collection.Type {
+ t.Errorf("Expected Type %q, got %q", collection.Type, form.Type)
+ }
+
if form.System != collection.System {
t.Errorf("Expected System %v, got %v", collection.System, form.System)
}
@@ -104,95 +86,24 @@ func TestNewCollectionUpsert(t *testing.T) {
}
}
-func TestCollectionUpsertValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- {"{}", []string{"name", "schema"}},
- {
- `{
- "name": "test ?!@#$",
- "system": true,
- "schema": [
- {"name":"","type":"text"}
- ],
- "listRule": "missing = '123'",
- "viewRule": "missing = '123'",
- "createRule": "missing = '123'",
- "updateRule": "missing = '123'",
- "deleteRule": "missing = '123'"
- }`,
- []string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
- },
- {
- `{
- "name": "test",
- "system": true,
- "schema": [
- {"name":"test","type":"text"}
- ],
- "listRule": "test='123'",
- "viewRule": "test='123'",
- "createRule": "test='123'",
- "updateRule": "test='123'",
- "deleteRule": "test='123'"
- }`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewCollectionUpsert(app, &models.Collection{})
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- // parse errors
- result := form.Validate()
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
- }
-}
-
-func TestCollectionUpsertSubmit(t *testing.T) {
+func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
+ testName string
existingName string
jsonData string
expectedErrors []string
}{
- // empty create
- {"", "{}", []string{"name", "schema"}},
- // empty update
- {"demo", "{}", []string{}},
- // create failure
+ {"empty create", "", "{}", []string{"name", "schema"}},
+ {"empty update", "demo2", "{}", []string{}},
{
+ "create failure",
"",
`{
"name": "test ?!@#$",
+ "type": "invalid",
"system": true,
"schema": [
{"name":"","type":"text"}
@@ -203,13 +114,13 @@ func TestCollectionUpsertSubmit(t *testing.T) {
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'"
}`,
- []string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
+ []string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
},
- // create failure - existing name
{
+ "create failure - existing name",
"",
`{
- "name": "demo",
+ "name": "demo1",
"system": true,
"schema": [
{"name":"test","type":"text"}
@@ -222,19 +133,19 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"name"},
},
- // create failure - existing internal table
{
+ "create failure - existing internal table",
"",
`{
- "name": "_users",
+ "name": "_admins",
"schema": [
{"name":"test","type":"text"}
]
}`,
[]string{"name"},
},
- // create failure - name starting with underscore
{
+ "create failure - name starting with underscore",
"",
`{
"name": "_test_new",
@@ -244,8 +155,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"name"},
},
- // create failure - duplicated field names (case insensitive)
{
+ "create failure - duplicated field names (case insensitive)",
"",
`{
"name": "test_new",
@@ -256,8 +167,21 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"schema"},
},
- // create success
{
+ "create failure - check type options validators",
+ "",
+ `{
+ "name": "test_new",
+ "type": "auth",
+ "schema": [
+ {"name":"test","type":"text"}
+ ],
+ "options": { "minPasswordLength": 3 }
+ }`,
+ []string{"options"},
+ },
+ {
+ "create success",
"",
`{
"name": "test_new",
@@ -274,8 +198,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{},
},
- // update failure - changing field type
{
+ "update failure - changing field type",
"test_new",
`{
"schema": [
@@ -285,8 +209,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"schema"},
},
- // update success - rename fields to existing field names (aka. reusing field names)
{
+ "update success - rename fields to existing field names (aka. reusing field names)",
"test_new",
`{
"schema": [
@@ -296,34 +220,43 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{},
},
- // update failure - existing name
{
- "demo",
- `{"name": "demo2"}`,
+ "update failure - existing name",
+ "demo2",
+ `{"name": "demo3"}`,
[]string{"name"},
},
- // update failure - changing system collection
{
- models.ProfileCollectionName,
+ "update failure - changing system collection",
+ "nologin",
`{
"name": "update",
"system": false,
"schema": [
- {"id":"koih1lqx","name":"userId","type":"text"}
+ {"id":"koih1lqx","name":"abc","type":"text"}
],
- "listRule": "userId = '123'",
- "viewRule": "userId = '123'",
- "createRule": "userId = '123'",
- "updateRule": "userId = '123'",
- "deleteRule": "userId = '123'"
+ "listRule": "abc = '123'",
+ "viewRule": "abc = '123'",
+ "createRule": "abc = '123'",
+ "updateRule": "abc = '123'",
+ "deleteRule": "abc = '123'"
}`,
- []string{"name", "system", "schema"},
+ []string{"name", "system"},
},
- // update failure - all fields
{
- "demo",
+ "update failure - changing collection type",
+ "demo3",
+ `{
+ "type": "auth"
+ }`,
+ []string{"type"},
+ },
+ {
+ "update failure - all fields",
+ "demo2",
`{
"name": "test ?!@#$",
+ "type": "invalid",
"system": true,
"schema": [
{"name":"","type":"text"}
@@ -332,15 +265,17 @@ func TestCollectionUpsertSubmit(t *testing.T) {
"viewRule": "missing = '123'",
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
- "deleteRule": "missing = '123'"
+ "deleteRule": "missing = '123'",
+ "options": {"test": 123}
}`,
- []string{"name", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
+ []string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
},
- // update success - update all fields
{
- "demo",
+ "update success - update all fields",
+ "clients",
`{
"name": "demo_update",
+ "type": "auth",
"schema": [
{"id":"_2hlxbmp","name":"test","type":"text"}
],
@@ -348,13 +283,14 @@ func TestCollectionUpsertSubmit(t *testing.T) {
"viewRule": "test='123'",
"createRule": "test='123'",
"updateRule": "test='123'",
- "deleteRule": "test='123'"
+ "deleteRule": "test='123'",
+ "options": {"minPasswordLength": 10}
}`,
[]string{},
},
- // update failure - rename the schema field of the last updated collection
// (fail due to filters old field references)
{
+ "update failure - rename the schema field of the last updated collection",
"demo_update",
`{
"schema": [
@@ -363,9 +299,9 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
},
- // update success - rename the schema field of the last updated collection
// (cleared filter references)
{
+ "update success - rename the schema field of the last updated collection",
"demo_update",
`{
"schema": [
@@ -379,21 +315,21 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{},
},
- // update success - system collection
{
- models.ProfileCollectionName,
+ "update success - system collection",
+ "nologin",
`{
- "listRule": "userId='123'",
- "viewRule": "userId='123'",
- "createRule": "userId='123'",
- "updateRule": "userId='123'",
- "deleteRule": "userId='123'"
+ "listRule": "name='123'",
+ "viewRule": "name='123'",
+ "createRule": "name='123'",
+ "updateRule": "name='123'",
+ "deleteRule": "name='123'"
}`,
[]string{},
},
}
- for i, s := range scenarios {
+ for _, s := range scenarios {
collection := &models.Collection{}
if s.existingName != "" {
var err error
@@ -408,7 +344,7 @@ func TestCollectionUpsertSubmit(t *testing.T) {
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
+ t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
continue
}
@@ -424,7 +360,7 @@ func TestCollectionUpsertSubmit(t *testing.T) {
result := form.Submit(interceptor)
errs, ok := result.(validation.Errors)
if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
+ t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
continue
}
@@ -434,16 +370,16 @@ func TestCollectionUpsertSubmit(t *testing.T) {
expectInterceptorCall = 0
}
if interceptorCalls != expectInterceptorCall {
- t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
+ t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCall, interceptorCalls)
}
// check errors
if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
+ t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
+ t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
}
}
@@ -453,42 +389,46 @@ func TestCollectionUpsertSubmit(t *testing.T) {
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
if collection == nil {
- t.Errorf("(%d) Expected to find collection %q, got nil", i, form.Name)
+ t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
continue
}
if form.Name != collection.Name {
- t.Errorf("(%d) Expected Name %q, got %q", i, collection.Name, form.Name)
+ t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
+ }
+
+ if form.Type != collection.Type {
+ t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
}
if form.System != collection.System {
- t.Errorf("(%d) Expected System %v, got %v", i, collection.System, form.System)
+ t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System)
}
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
- t.Errorf("(%d) Expected ListRule %v, got %v", i, collection.ListRule, form.ListRule)
+ t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule)
}
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
- t.Errorf("(%d) Expected ViewRule %v, got %v", i, collection.ViewRule, form.ViewRule)
+ t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule)
}
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
- t.Errorf("(%d) Expected CreateRule %v, got %v", i, collection.CreateRule, form.CreateRule)
+ t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule)
}
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
- t.Errorf("(%d) Expected UpdateRule %v, got %v", i, collection.UpdateRule, form.UpdateRule)
+ t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule)
}
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
- t.Errorf("(%d) Expected DeleteRule %v, got %v", i, collection.DeleteRule, form.DeleteRule)
+ t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
}
formSchema, _ := form.Schema.MarshalJSON()
collectionSchema, _ := collection.Schema.MarshalJSON()
if string(formSchema) != string(collectionSchema) {
- t.Errorf("(%d) Expected Schema %v, got %v", i, string(collectionSchema), string(formSchema))
+ t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
}
}
}
@@ -497,7 +437,7 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, err := app.Dao().FindCollectionByNameOrId("demo")
+ collection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
@@ -547,7 +487,7 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- existingCollection, err := app.Dao().FindCollectionByNameOrId("demo3")
+ existingCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
@@ -621,27 +561,27 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
},
}
- for _, scenario := range scenarios {
- form := forms.NewCollectionUpsert(app, scenario.collection)
+ for _, s := range scenarios {
+ form := forms.NewCollectionUpsert(app, s.collection)
// load data
- loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
+ loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
- t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
+ t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
- if hasErr != scenario.expectError {
- t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
+ if hasErr != s.expectError {
+ t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindCollectionByNameOrId(form.Id)
if err != nil {
- t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
+ t.Errorf("[%s] Expected to find record with id %s, got %v", s.name, form.Id, err)
}
}
}
diff --git a/forms/collections_import.go b/forms/collections_import.go
index 8a1e8c50..2b5a2d38 100644
--- a/forms/collections_import.go
+++ b/forms/collections_import.go
@@ -11,48 +11,31 @@ import (
"github.com/pocketbase/pocketbase/models"
)
-// CollectionsImport specifies a form model to bulk import
+// CollectionsImport is a form model to bulk import
// (create, replace and delete) collections from a user provided list.
type CollectionsImport struct {
- config CollectionsImportConfig
+ app core.App
+ dao *daos.Dao
Collections []*models.Collection `form:"collections" json:"collections"`
DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
}
-// CollectionsImportConfig is the [CollectionsImport] factory initializer config.
-//
-// NB! App is a required struct member.
-type CollectionsImportConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
// NewCollectionsImport creates a new [CollectionsImport] form with
-// initializer config created from the provided [core.App] instance.
+// initialized with from the provided [core.App] instance.
//
-// If you want to submit the form as part of another transaction, use
-// [NewCollectionsImportWithConfig] with explicitly set Dao.
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
func NewCollectionsImport(app core.App) *CollectionsImport {
- return NewCollectionsImportWithConfig(CollectionsImportConfig{
- App: app,
- })
+ return &CollectionsImport{
+ app: app,
+ dao: app.Dao(),
+ }
}
-// NewCollectionsImportWithConfig creates a new [CollectionsImport]
-// form with the provided config or panics on invalid configuration.
-func NewCollectionsImportWithConfig(config CollectionsImportConfig) *CollectionsImport {
- form := &CollectionsImport{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *CollectionsImport) SetDao(dao *daos.Dao) {
+ form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -79,7 +62,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
}
return runInterceptors(func() error {
- return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
+ return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
importErr := txDao.ImportCollections(
form.Collections,
form.DeleteMissing,
@@ -95,7 +78,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
}
// generic/db failure
- if form.config.App.IsDebug() {
+ if form.app.IsDebug() {
log.Println("Internal import failure:", importErr)
}
return validation.Errors{"collections": validation.NewError(
@@ -121,13 +104,12 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
upsertModel = collection
}
- upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{
- App: form.config.App,
- Dao: txDao,
- }, upsertModel)
+ upsertForm := NewCollectionUpsert(form.app, upsertModel)
+ upsertForm.SetDao(txDao)
// load form fields with the refreshed collection state
upsertForm.Id = collection.Id
+ upsertForm.Type = collection.Type
upsertForm.Name = collection.Name
upsertForm.System = collection.System
upsertForm.ListRule = collection.ListRule
@@ -136,6 +118,7 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
upsertForm.UpdateRule = collection.UpdateRule
upsertForm.DeleteRule = collection.DeleteRule
upsertForm.Schema = collection.Schema
+ upsertForm.Options = collection.Options
if err := upsertForm.Validate(); err != nil {
// serialize the validation error(s)
diff --git a/forms/collections_import_test.go b/forms/collections_import_test.go
index 671affbb..d811fe40 100644
--- a/forms/collections_import_test.go
+++ b/forms/collections_import_test.go
@@ -10,16 +10,6 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
-func TestCollectionsImportPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewCollectionsImport(nil)
-}
-
func TestCollectionsImportValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -62,7 +52,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
"collections": []
}`,
expectError: true,
- expectCollectionsCount: 5,
+ expectCollectionsCount: 7,
expectEvents: nil,
},
{
@@ -92,7 +82,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
- expectCollectionsCount: 5,
+ expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
@@ -124,7 +114,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: false,
- expectCollectionsCount: 7,
+ expectCollectionsCount: 9,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
@@ -147,7 +137,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
- expectCollectionsCount: 5,
+ expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
@@ -158,8 +148,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
"deleteMissing": true,
"collections": [
{
- "id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
- "name":"demo",
+ "id":"sz5l5z67tg7gku0",
+ "name":"demo2",
"schema":[
{
"id":"_2hlxbmp",
@@ -189,19 +179,22 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
- expectCollectionsCount: 5,
+ expectCollectionsCount: 7,
+ expectEvents: map[string]int{
+ "OnModelBeforeDelete": 5,
+ },
},
{
name: "modified + new collection",
jsonData: `{
"collections": [
{
- "id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
- "name":"demo",
+ "id":"sz5l5z67tg7gku0",
+ "name":"demo2",
"schema":[
{
"id":"_2hlxbmp",
- "name":"title",
+ "name":"title_new",
"type":"text",
"system":false,
"required":true,
@@ -237,7 +230,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: false,
- expectCollectionsCount: 7,
+ expectCollectionsCount: 9,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
@@ -251,45 +244,44 @@ func TestCollectionsImportSubmit(t *testing.T) {
"deleteMissing": true,
"collections": [
{
- "id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "name":"profiles",
- "system":true,
- "listRule":"userId = @request.user.id",
- "viewRule":"created > 'test_change'",
- "createRule":"userId = @request.user.id",
- "updateRule":"userId = @request.user.id",
- "deleteRule":"userId = @request.user.id",
- "schema":[
+ "id": "kpv709sk2lqbqk8",
+ "system": true,
+ "name": "nologin",
+ "type": "auth",
+ "options": {
+ "allowEmailAuth": false,
+ "allowOAuth2Auth": false,
+ "allowUsernameAuth": false,
+ "exceptEmailDomains": [],
+ "manageRule": "@request.auth.collectionName = 'users'",
+ "minPasswordLength": 8,
+ "onlyEmailDomains": [],
+ "requireEmail": true
+ },
+ "listRule": "",
+ "viewRule": "",
+ "createRule": "",
+ "updateRule": "",
+ "deleteRule": "",
+ "schema": [
{
- "id":"koih1lqx",
- "name":"userId",
- "type":"user",
- "system":true,
- "required":true,
- "unique":true,
- "options":{
- "maxSelect":1,
- "cascadeDelete":true
- }
- },
- {
- "id":"69ycbg3q",
- "name":"rel",
- "type":"relation",
- "system":false,
- "required":false,
- "unique":false,
- "options":{
- "maxSelect":2,
- "collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "cascadeDelete":false
+ "id": "x8zzktwe",
+ "name": "name",
+ "type": "text",
+ "system": false,
+ "required": false,
+ "unique": false,
+ "options": {
+ "min": null,
+ "max": null,
+ "pattern": ""
}
}
]
},
{
- "id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
- "name":"demo",
+ "id":"sz5l5z67tg7gku0",
+ "name":"demo2",
"schema":[
{
"id":"_2hlxbmp",
@@ -308,7 +300,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
},
{
"id": "test_deleted_collection_name_reuse",
- "name": "demo2",
+ "name": "demo1",
"schema": [
{
"id":"fz6iql2m",
@@ -326,8 +318,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
- "OnModelBeforeDelete": 3,
- "OnModelAfterDelete": 3,
+ "OnModelBeforeDelete": 5,
+ "OnModelAfterDelete": 5,
},
},
}
diff --git a/forms/record_email_change_confirm.go b/forms/record_email_change_confirm.go
new file mode 100644
index 00000000..f4712299
--- /dev/null
+++ b/forms/record_email_change_confirm.go
@@ -0,0 +1,135 @@
+package forms
+
+import (
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+// RecordEmailChangeConfirm is an auth record email change confirmation form.
+type RecordEmailChangeConfirm struct {
+ app core.App
+ dao *daos.Dao
+ collection *models.Collection
+
+ Token string `form:"token" json:"token"`
+ Password string `form:"password" json:"password"`
+}
+
+// NewRecordEmailChangeConfirm creates a new [RecordEmailChangeConfirm] form
+// initialized with from the provided [core.App] and [models.Collection] instances.
+//
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
+func NewRecordEmailChangeConfirm(app core.App, collection *models.Collection) *RecordEmailChangeConfirm {
+ return &RecordEmailChangeConfirm{
+ app: app,
+ dao: app.Dao(),
+ collection: collection,
+ }
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordEmailChangeConfirm) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+func (form *RecordEmailChangeConfirm) Validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(
+ &form.Token,
+ validation.Required,
+ validation.By(form.checkToken),
+ ),
+ validation.Field(
+ &form.Password,
+ validation.Required,
+ validation.Length(1, 100),
+ validation.By(form.checkPassword),
+ ),
+ )
+}
+
+func (form *RecordEmailChangeConfirm) checkToken(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ authRecord, _, err := form.parseToken(v)
+ if err != nil {
+ return err
+ }
+
+ if authRecord.Collection().Id != form.collection.Id {
+ return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
+ }
+
+ return nil
+}
+
+func (form *RecordEmailChangeConfirm) checkPassword(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ authRecord, _, _ := form.parseToken(form.Token)
+ if authRecord == nil || !authRecord.ValidatePassword(v) {
+ return validation.NewError("validation_invalid_password", "Missing or invalid auth record password.")
+ }
+
+ return nil
+}
+
+func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record, string, error) {
+ // check token payload
+ claims, _ := security.ParseUnverifiedJWT(token)
+ newEmail, _ := claims["newEmail"].(string)
+ if newEmail == "" {
+ return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
+ }
+
+ // ensure that there aren't other users with the new email
+ if !form.dao.IsRecordValueUnique(form.collection.Id, schema.FieldNameEmail, newEmail) {
+ return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
+ }
+
+ // verify that the token is not expired and its signature is valid
+ authRecord, err := form.dao.FindAuthRecordByToken(
+ token,
+ form.app.Settings().RecordEmailChangeToken.Secret,
+ )
+ if err != nil || authRecord == nil {
+ return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
+ }
+
+ return authRecord, newEmail, nil
+}
+
+// Submit validates and submits the auth record email change confirmation form.
+// On success returns the updated auth record associated to `form.Token`.
+func (form *RecordEmailChangeConfirm) Submit() (*models.Record, error) {
+ if err := form.Validate(); err != nil {
+ return nil, err
+ }
+
+ authRecord, newEmail, err := form.parseToken(form.Token)
+ if err != nil {
+ return nil, err
+ }
+
+ authRecord.SetEmail(newEmail)
+ authRecord.SetVerified(true)
+ authRecord.RefreshTokenKey() // invalidate old tokens
+
+ if err := form.dao.SaveRecord(authRecord); err != nil {
+ return nil, err
+ }
+
+ return authRecord, nil
+}
diff --git a/forms/record_email_change_confirm_test.go b/forms/record_email_change_confirm_test.go
new file mode 100644
index 00000000..5d6ee1d1
--- /dev/null
+++ b/forms/record_email_change_confirm_test.go
@@ -0,0 +1,126 @@
+package forms_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/forms"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ jsonData string
+ expectedErrors []string
+ }{
+ // empty payload
+ {"{}", []string{"token", "password"}},
+ // empty data
+ {
+ `{"token": "", "password": ""}`,
+ []string{"token", "password"},
+ },
+ // invalid token payload
+ {
+ `{
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.quDgaCi2rGTRx3qO06CrFvHdeCua_5J7CCVWSaFhkus",
+ "password": "123456"
+ }`,
+ []string{"token", "password"},
+ },
+ // expired token
+ {
+ `{
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTYwOTQ1NTY2MX0.n1OJXJEACMNPT9aMTO48cVJexIiZEtHsz4UNBIfMcf4",
+ "password": "123456"
+ }`,
+ []string{"token", "password"},
+ },
+ // existing new email
+ {
+ `{
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.Q_o6zpc2URggTU0mWv2CS0rIPbQhFdmrjZ-ASwHh1Ww",
+ "password": "1234567890"
+ }`,
+ []string{"token", "password"},
+ },
+ // wrong confirmation password
+ {
+ `{
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
+ "password": "123456"
+ }`,
+ []string{"password"},
+ },
+ // valid data
+ {
+ `{
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
+ "password": "1234567890"
+ }`,
+ []string{},
+ },
+ }
+
+ for i, s := range scenarios {
+ form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
+
+ // load data
+ loadErr := json.Unmarshal([]byte(s.jsonData), form)
+ if loadErr != nil {
+ t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
+ continue
+ }
+
+ record, err := form.Submit()
+
+ // parse errors
+ errs, ok := err.(validation.Errors)
+ if !ok && err != nil {
+ t.Errorf("(%d) Failed to parse errors %v", i, err)
+ continue
+ }
+
+ // check errors
+ if len(errs) > len(s.expectedErrors) {
+ t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
+ }
+ for _, k := range s.expectedErrors {
+ if _, ok := errs[k]; !ok {
+ t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
+ }
+ }
+
+ if len(errs) > 0 {
+ continue
+ }
+
+ claims, _ := security.ParseUnverifiedJWT(form.Token)
+ newEmail, _ := claims["newEmail"].(string)
+
+ // check whether the user was updated
+ // ---
+ if record.Email() != newEmail {
+ t.Errorf("(%d) Expected record email %q, got %q", i, newEmail, record.Email())
+ }
+
+ if !record.Verified() {
+ t.Errorf("(%d) Expected record to be verified, got false", i)
+ }
+
+ // shouldn't validate second time due to refreshed record token
+ if err := form.Validate(); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ }
+}
diff --git a/forms/record_email_change_request.go b/forms/record_email_change_request.go
new file mode 100644
index 00000000..8c655f7e
--- /dev/null
+++ b/forms/record_email_change_request.go
@@ -0,0 +1,70 @@
+package forms
+
+import (
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/go-ozzo/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/mails"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/models/schema"
+)
+
+// RecordEmailChangeRequest is an auth record email change request form.
+type RecordEmailChangeRequest struct {
+ app core.App
+ dao *daos.Dao
+ record *models.Record
+
+ NewEmail string `form:"newEmail" json:"newEmail"`
+}
+
+// NewRecordEmailChangeRequest creates a new [RecordEmailChangeRequest] form
+// initialized with from the provided [core.App] and [models.Record] instances.
+//
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
+func NewRecordEmailChangeRequest(app core.App, record *models.Record) *RecordEmailChangeRequest {
+ return &RecordEmailChangeRequest{
+ app: app,
+ dao: app.Dao(),
+ record: record,
+ }
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordEmailChangeRequest) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+func (form *RecordEmailChangeRequest) Validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(
+ &form.NewEmail,
+ validation.Required,
+ validation.Length(1, 255),
+ is.EmailFormat,
+ validation.By(form.checkUniqueEmail),
+ ),
+ )
+}
+
+func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
+ v, _ := value.(string)
+
+ if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
+ return validation.NewError("validation_record_email_exists", "User email already exists.")
+ }
+
+ return nil
+}
+
+// Submit validates and sends the change email request.
+func (form *RecordEmailChangeRequest) Submit() error {
+ if err := form.Validate(); err != nil {
+ return err
+ }
+
+ return mails.SendRecordChangeEmail(form.app, form.record, form.NewEmail)
+}
diff --git a/forms/user_email_change_request_test.go b/forms/record_email_change_request_test.go
similarity index 71%
rename from forms/user_email_change_request_test.go
rename to forms/record_email_change_request_test.go
index ed209be7..af364f07 100644
--- a/forms/user_email_change_request_test.go
+++ b/forms/record_email_change_request_test.go
@@ -9,34 +9,11 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
-func TestUserEmailChangeRequestPanic1(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserEmailChangeRequest(nil, nil)
-}
-
-func TestUserEmailChangeRequestPanic2(t *testing.T) {
+func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserEmailChangeRequest(testApp, nil)
-}
-
-func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- user, err := testApp.Dao().FindUserByEmail("test@example.com")
+ user, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
@@ -59,7 +36,7 @@ func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
},
// existing email token
{
- `{"newEmail": "test@example.com"}`,
+ `{"newEmail": "test2@example.com"}`,
[]string{"newEmail"},
},
// valid new email
@@ -71,7 +48,7 @@ func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
for i, s := range scenarios {
testApp.TestMailer.TotalSend = 0 // reset
- form := forms.NewUserEmailChangeRequest(testApp, user)
+ form := forms.NewRecordEmailChangeRequest(testApp, user)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
diff --git a/forms/record_oauth2_login.go b/forms/record_oauth2_login.go
new file mode 100644
index 00000000..cb559f60
--- /dev/null
+++ b/forms/record_oauth2_login.go
@@ -0,0 +1,234 @@
+package forms
+
+import (
+ "errors"
+ "fmt"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/go-ozzo/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "golang.org/x/oauth2"
+)
+
+// RecordOAuth2Login is an auth record OAuth2 login form.
+type RecordOAuth2Login struct {
+ app core.App
+ dao *daos.Dao
+ collection *models.Collection
+
+ // Optional auth record that will be used if no external
+ // auth relation is found (if it is from the same collection)
+ loggedAuthRecord *models.Record
+
+ // The name of the OAuth2 client provider (eg. "google")
+ Provider string `form:"provider" json:"provider"`
+
+ // The authorization code returned from the initial request.
+ Code string `form:"code" json:"code"`
+
+ // The code verifier sent with the initial request as part of the code_challenge.
+ CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
+
+ // The redirect url sent with the initial request.
+ RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
+
+ // Additional data that will be used for creating a new auth record
+ // if an existing OAuth2 account doesn't exist.
+ CreateData map[string]any `form:"createData" json:"createData"`
+}
+
+// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
+// initialized with from the provided [core.App] instance.
+//
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
+func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login {
+ form := &RecordOAuth2Login{
+ app: app,
+ dao: app.Dao(),
+ collection: collection,
+ loggedAuthRecord: optAuthRecord,
+ }
+
+ return form
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+func (form *RecordOAuth2Login) Validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
+ validation.Field(&form.Code, validation.Required),
+ validation.Field(&form.CodeVerifier, validation.Required),
+ validation.Field(&form.RedirectUrl, validation.Required, is.URL),
+ )
+}
+
+func (form *RecordOAuth2Login) checkProviderName(value any) error {
+ name, _ := value.(string)
+
+ config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
+ if !ok || !config.Enabled {
+ return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
+ }
+
+ return nil
+}
+
+// Submit validates and submits the form.
+//
+// If an auth record doesn't exist, it will make an attempt to create it
+// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
+// You can intercept/modify the create form by setting the optional beforeCreateFuncs argument.
+//
+// On success returns the authorized record model and the fetched provider's data.
+func (form *RecordOAuth2Login) Submit(
+ beforeCreateFuncs ...func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error,
+) (*models.Record, *auth.AuthUser, error) {
+ if err := form.Validate(); err != nil {
+ return nil, nil, err
+ }
+
+ if !form.collection.AuthOptions().AllowOAuth2Auth {
+ return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.")
+ }
+
+ provider, err := auth.NewProviderByName(form.Provider)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // load provider configuration
+ providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
+ if err := providerConfig.SetupProvider(provider); err != nil {
+ return nil, nil, err
+ }
+
+ provider.SetRedirectUrl(form.RedirectUrl)
+
+ // fetch token
+ token, err := provider.FetchToken(
+ form.Code,
+ oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
+ )
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // fetch external auth user
+ authUser, err := provider.FetchAuthUser(token)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ var authRecord *models.Record
+
+ // check for existing relation with the auth record
+ rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
+ switch {
+ case rel != nil:
+ authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
+ if err != nil {
+ return nil, authUser, err
+ }
+ case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id:
+ // fallback to the logged auth record (if any)
+ authRecord = form.loggedAuthRecord
+ case authUser.Email != "":
+ // look for an existing auth record by the external auth record's email
+ authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
+ }
+
+ saveErr := form.dao.RunInTransaction(func(txDao *daos.Dao) error {
+ if authRecord == nil {
+ authRecord = models.NewRecord(form.collection)
+ authRecord.RefreshId()
+ authRecord.MarkAsNew()
+ createForm := NewRecordUpsert(form.app, authRecord)
+ createForm.SetFullManageAccess(true)
+ createForm.SetDao(txDao)
+ if authUser.Username != "" {
+ createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(form.collection.Id, authUser.Username)
+ }
+
+ // load custom data
+ createForm.LoadData(form.CreateData)
+
+ // load the OAuth2 profile data as fallback
+ if createForm.Email == "" {
+ createForm.Email = authUser.Email
+ }
+ createForm.Verified = false
+ if createForm.Email == authUser.Email {
+ // mark as verified as long as it matches the OAuth2 data (even if the email is empty)
+ createForm.Verified = true
+ }
+ if createForm.Password == "" {
+ createForm.Password = security.RandomString(30)
+ createForm.PasswordConfirm = createForm.Password
+ }
+
+ for _, f := range beforeCreateFuncs {
+ if f == nil {
+ continue
+ }
+ if err := f(createForm, authRecord, authUser); err != nil {
+ return err
+ }
+ }
+
+ // create the new auth record
+ if err := createForm.Submit(); err != nil {
+ return err
+ }
+ } else {
+ // update the existing auth record empty email if the authUser has one
+ // (this is in case previously the auth record was created
+ // with an OAuth2 provider that didn't return an email address)
+ if authRecord.Email() == "" && authUser.Email != "" {
+ authRecord.SetEmail(authUser.Email)
+ if err := txDao.SaveRecord(authRecord); err != nil {
+ return err
+ }
+ }
+
+ // update the existing auth record verified state
+ // (only if the auth record doesn't have an email or the auth record email match with the one in authUser)
+ if !authRecord.Verified() && (authRecord.Email() == "" || authRecord.Email() == authUser.Email) {
+ authRecord.SetVerified(true)
+ if err := txDao.SaveRecord(authRecord); err != nil {
+ return err
+ }
+ }
+ }
+
+ // create ExternalAuth relation if missing
+ if rel == nil {
+ rel = &models.ExternalAuth{
+ CollectionId: authRecord.Collection().Id,
+ RecordId: authRecord.Id,
+ Provider: form.Provider,
+ ProviderId: authUser.Id,
+ }
+ if err := txDao.SaveExternalAuth(rel); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+
+ if saveErr != nil {
+ return nil, authUser, saveErr
+ }
+
+ return authRecord, authUser, nil
+}
diff --git a/forms/user_oauth2_login_test.go b/forms/record_oauth2_login_test.go
similarity index 61%
rename from forms/user_oauth2_login_test.go
rename to forms/record_oauth2_login_test.go
index e0a9674c..637ed083 100644
--- a/forms/user_oauth2_login_test.go
+++ b/forms/record_oauth2_login_test.go
@@ -9,55 +9,60 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
-func TestUserOauth2LoginPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserOauth2Login(nil)
-}
-
func TestUserOauth2LoginValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
+ testName string
+ collectionName string
jsonData string
expectedErrors []string
}{
- // empty payload
- {"{}", []string{"provider", "code", "codeVerifier", "redirectUrl"}},
- // empty data
{
+ "empty payload",
+ "users",
+ "{}",
+ []string{"provider", "code", "codeVerifier", "redirectUrl"},
+ },
+ {
+ "empty data",
+ "users",
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
},
- // missing provider
{
+ "missing provider",
+ "users",
`{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{"provider"},
},
- // disabled provider
{
+ "disabled provider",
+ "users",
`{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{"provider"},
},
- // enabled provider
{
+ "enabled provider",
+ "users",
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{},
},
}
- for i, s := range scenarios {
- form := forms.NewUserOauth2Login(app)
+ for _, s := range scenarios {
+ authCollection, _ := app.Dao().FindCollectionByNameOrId(s.collectionName)
+ if authCollection == nil {
+ t.Errorf("[%s] Failed to fetch auth collection", s.testName)
+ }
+
+ form := forms.NewRecordOAuth2Login(app, authCollection, nil)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
+ t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
continue
}
@@ -66,17 +71,17 @@ func TestUserOauth2LoginValidate(t *testing.T) {
// parse errors
errs, ok := err.(validation.Errors)
if !ok && err != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, err)
+ t.Errorf("[%s] Failed to parse errors %v", s.testName, err)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
+ t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
+ t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
}
}
}
diff --git a/forms/record_password_login.go b/forms/record_password_login.go
new file mode 100644
index 00000000..2c01e9a8
--- /dev/null
+++ b/forms/record_password_login.go
@@ -0,0 +1,77 @@
+package forms
+
+import (
+ "errors"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/go-ozzo/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/models"
+)
+
+// RecordPasswordLogin is record username/email + password login form.
+type RecordPasswordLogin struct {
+ app core.App
+ dao *daos.Dao
+ collection *models.Collection
+
+ Identity string `form:"identity" json:"identity"`
+ Password string `form:"password" json:"password"`
+}
+
+// NewRecordPasswordLogin creates a new [RecordPasswordLogin] form initialized
+// with from the provided [core.App] and [models.Collection] instance.
+//
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
+func NewRecordPasswordLogin(app core.App, collection *models.Collection) *RecordPasswordLogin {
+ return &RecordPasswordLogin{
+ app: app,
+ dao: app.Dao(),
+ collection: collection,
+ }
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordPasswordLogin) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+func (form *RecordPasswordLogin) Validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
+ validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
+ )
+}
+
+// Submit validates and submits the form.
+// On success returns the authorized record model.
+func (form *RecordPasswordLogin) Submit() (*models.Record, error) {
+ if err := form.Validate(); err != nil {
+ return nil, err
+ }
+
+ authOptions := form.collection.AuthOptions()
+
+ if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
+ return nil, errors.New("Password authentication is not allowed for the collection.")
+ }
+
+ var record *models.Record
+ var fetchErr error
+
+ if authOptions.AllowEmailAuth &&
+ (!authOptions.AllowUsernameAuth || is.EmailFormat.Validate(form.Identity) == nil) {
+ record, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
+ } else {
+ record, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
+ }
+
+ if fetchErr != nil || !record.ValidatePassword(form.Password) {
+ return nil, errors.New("Invalid login credentials.")
+ }
+
+ return record, nil
+}
diff --git a/forms/record_password_login_test.go b/forms/record_password_login_test.go
new file mode 100644
index 00000000..c36dc72d
--- /dev/null
+++ b/forms/record_password_login_test.go
@@ -0,0 +1,130 @@
+package forms_test
+
+import (
+ "testing"
+
+ "github.com/pocketbase/pocketbase/forms"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ scenarios := []struct {
+ testName string
+ collectionName string
+ identity string
+ password string
+ expectError bool
+ }{
+ {
+ "empty data",
+ "users",
+ "",
+ "",
+ true,
+ },
+
+ // username
+ {
+ "existing username + wrong password",
+ "users",
+ "users75657",
+ "invalid",
+ true,
+ },
+ {
+ "missing username + valid password",
+ "users",
+ "clients57772", // not in the "users" collection
+ "1234567890",
+ true,
+ },
+ {
+ "existing username + valid password but in restricted username auth collection",
+ "clients",
+ "clients57772",
+ "1234567890",
+ true,
+ },
+ {
+ "existing username + valid password but in restricted username and email auth collection",
+ "nologin",
+ "test_username",
+ "1234567890",
+ true,
+ },
+ {
+ "existing username + valid password",
+ "users",
+ "users75657",
+ "1234567890",
+ false,
+ },
+
+ // email
+ {
+ "existing email + wrong password",
+ "users",
+ "test@example.com",
+ "invalid",
+ true,
+ },
+ {
+ "missing email + valid password",
+ "users",
+ "test_missing@example.com",
+ "1234567890",
+ true,
+ },
+ {
+ "existing username + valid password but in restricted username auth collection",
+ "clients",
+ "test@example.com",
+ "1234567890",
+ false,
+ },
+ {
+ "existing username + valid password but in restricted username and email auth collection",
+ "nologin",
+ "test@example.com",
+ "1234567890",
+ true,
+ },
+ {
+ "existing email + valid password",
+ "users",
+ "test@example.com",
+ "1234567890",
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ authCollection, err := testApp.Dao().FindCollectionByNameOrId(s.collectionName)
+ if err != nil {
+ t.Errorf("[%s] Failed to fetch auth collection: %v", s.testName, err)
+ }
+
+ form := forms.NewRecordPasswordLogin(testApp, authCollection)
+ form.Identity = s.identity
+ form.Password = s.password
+
+ record, err := form.Submit()
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.testName, s.expectError, hasErr, err)
+ continue
+ }
+
+ if hasErr {
+ continue
+ }
+
+ if record.Email() != s.identity && record.Username() != s.identity {
+ t.Errorf("[%s] Expected record with identity %q, got \n%v", s.testName, s.identity, record)
+ }
+ }
+}
diff --git a/forms/record_password_reset_confirm.go b/forms/record_password_reset_confirm.go
new file mode 100644
index 00000000..a3ca1086
--- /dev/null
+++ b/forms/record_password_reset_confirm.go
@@ -0,0 +1,96 @@
+package forms
+
+import (
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/forms/validators"
+ "github.com/pocketbase/pocketbase/models"
+)
+
+// RecordPasswordResetConfirm is an auth record password reset confirmation form.
+type RecordPasswordResetConfirm struct {
+ app core.App
+ collection *models.Collection
+ dao *daos.Dao
+
+ Token string `form:"token" json:"token"`
+ Password string `form:"password" json:"password"`
+ PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
+}
+
+// NewRecordPasswordResetConfirm creates a new [RecordPasswordResetConfirm]
+// form initialized with from the provided [core.App] instance.
+//
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
+func NewRecordPasswordResetConfirm(app core.App, collection *models.Collection) *RecordPasswordResetConfirm {
+ return &RecordPasswordResetConfirm{
+ app: app,
+ dao: app.Dao(),
+ collection: collection,
+ }
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordPasswordResetConfirm) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+func (form *RecordPasswordResetConfirm) Validate() error {
+ minPasswordLength := form.collection.AuthOptions().MinPasswordLength
+
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
+ validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)),
+ validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
+ )
+}
+
+func (form *RecordPasswordResetConfirm) checkToken(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ record, err := form.dao.FindAuthRecordByToken(
+ v,
+ form.app.Settings().RecordPasswordResetToken.Secret,
+ )
+ if err != nil || record == nil {
+ return validation.NewError("validation_invalid_token", "Invalid or expired token.")
+ }
+
+ if record.Collection().Id != form.collection.Id {
+ return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
+ }
+
+ return nil
+}
+
+// Submit validates and submits the form.
+// On success returns the updated auth record associated to `form.Token`.
+func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) {
+ if err := form.Validate(); err != nil {
+ return nil, err
+ }
+
+ authRecord, err := form.dao.FindAuthRecordByToken(
+ form.Token,
+ form.app.Settings().RecordPasswordResetToken.Secret,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if err := authRecord.SetPassword(form.Password); err != nil {
+ return nil, err
+ }
+
+ if err := form.dao.SaveRecord(authRecord); err != nil {
+ return nil, err
+ }
+
+ return authRecord, nil
+}
diff --git a/forms/record_password_reset_confirm_test.go b/forms/record_password_reset_confirm_test.go
new file mode 100644
index 00000000..c3ef5466
--- /dev/null
+++ b/forms/record_password_reset_confirm_test.go
@@ -0,0 +1,117 @@
+package forms_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/forms"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ jsonData string
+ expectedErrors []string
+ }{
+ // empty data (Validate call check)
+ {
+ `{}`,
+ []string{"token", "password", "passwordConfirm"},
+ },
+ // expired token
+ {
+ `{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY",
+ "password":"12345678",
+ "passwordConfirm":"12345678"
+ }`,
+ []string{"token"},
+ },
+ // valid token but invalid passwords lengths
+ {
+ `{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
+ "password":"1234567",
+ "passwordConfirm":"1234567"
+ }`,
+ []string{"password"},
+ },
+ // valid token but mismatched passwordConfirm
+ {
+ `{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
+ "password":"12345678",
+ "passwordConfirm":"12345679"
+ }`,
+ []string{"passwordConfirm"},
+ },
+ // valid token and password
+ {
+ `{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
+ "password":"12345678",
+ "passwordConfirm":"12345678"
+ }`,
+ []string{},
+ },
+ }
+
+ for i, s := range scenarios {
+ form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
+
+ // load data
+ loadErr := json.Unmarshal([]byte(s.jsonData), form)
+ if loadErr != nil {
+ t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
+ continue
+ }
+
+ record, submitErr := form.Submit()
+
+ // parse errors
+ errs, ok := submitErr.(validation.Errors)
+ if !ok && submitErr != nil {
+ t.Errorf("(%d) Failed to parse errors %v", i, submitErr)
+ continue
+ }
+
+ // check errors
+ if len(errs) > len(s.expectedErrors) {
+ t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
+ }
+ for _, k := range s.expectedErrors {
+ if _, ok := errs[k]; !ok {
+ t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
+ }
+ }
+
+ if len(errs) > 0 || len(s.expectedErrors) > 0 {
+ continue
+ }
+
+ claims, _ := security.ParseUnverifiedJWT(form.Token)
+ tokenRecordId := claims["id"]
+
+ if record.Id != tokenRecordId {
+ t.Errorf("(%d) Expected record with id %s, got %v", i, tokenRecordId, record)
+ }
+
+ if !record.LastResetSentAt().IsZero() {
+ t.Errorf("(%d) Expected record.LastResetSentAt to be empty, got %v", i, record.LastResetSentAt())
+ }
+
+ if !record.ValidatePassword(form.Password) {
+ t.Errorf("(%d) Expected the record password to have been updated to %q", i, form.Password)
+ }
+ }
+}
diff --git a/forms/record_password_reset_request.go b/forms/record_password_reset_request.go
new file mode 100644
index 00000000..bbba8cff
--- /dev/null
+++ b/forms/record_password_reset_request.go
@@ -0,0 +1,86 @@
+package forms
+
+import (
+ "errors"
+ "time"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/go-ozzo/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/mails"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+// RecordPasswordResetRequest is an auth record reset password request form.
+type RecordPasswordResetRequest struct {
+ app core.App
+ dao *daos.Dao
+ collection *models.Collection
+ resendThreshold float64 // in seconds
+
+ Email string `form:"email" json:"email"`
+}
+
+// NewRecordPasswordResetRequest creates a new [RecordPasswordResetRequest]
+// form initialized with from the provided [core.App] instance.
+//
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
+func NewRecordPasswordResetRequest(app core.App, collection *models.Collection) *RecordPasswordResetRequest {
+ return &RecordPasswordResetRequest{
+ app: app,
+ dao: app.Dao(),
+ collection: collection,
+ resendThreshold: 120, // 2 min
+ }
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordPasswordResetRequest) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+//
+// This method doesn't checks whether auth record with `form.Email` exists (this is done on Submit).
+func (form *RecordPasswordResetRequest) Validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(
+ &form.Email,
+ validation.Required,
+ validation.Length(1, 255),
+ is.EmailFormat,
+ ),
+ )
+}
+
+// Submit validates and submits the form.
+// On success, sends a password reset email to the `form.Email` auth record.
+func (form *RecordPasswordResetRequest) Submit() error {
+ if err := form.Validate(); err != nil {
+ return err
+ }
+
+ authRecord, err := form.dao.FindFirstRecordByData(form.collection.Id, schema.FieldNameEmail, form.Email)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now().UTC()
+ lastResetSentAt := authRecord.LastResetSentAt().Time()
+ if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
+ return errors.New("You've already requested a password reset.")
+ }
+
+ if err := mails.SendRecordPasswordReset(form.app, authRecord); err != nil {
+ return err
+ }
+
+ // update last sent timestamp
+ authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
+
+ return form.dao.SaveRecord(authRecord)
+}
diff --git a/forms/user_password_reset_request_test.go b/forms/record_password_reset_request_test.go
similarity index 50%
rename from forms/user_password_reset_request_test.go
rename to forms/record_password_reset_request_test.go
index 19e8d704..b6413887 100644
--- a/forms/user_password_reset_request_test.go
+++ b/forms/record_password_reset_request_test.go
@@ -5,86 +5,20 @@ import (
"testing"
"time"
- validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
-func TestUserPasswordResetRequestPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserPasswordResetRequest(nil)
-}
-
-func TestUserPasswordResetRequestValidate(t *testing.T) {
+func TestRecordPasswordResetRequestSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty data
- {
- `{}`,
- []string{"email"},
- },
- // empty fields
- {
- `{"email":""}`,
- []string{"email"},
- },
- // invalid email format
- {
- `{"email":"invalid"}`,
- []string{"email"},
- },
- // valid email
- {
- `{"email":"new@example.com"}`,
- []string{},
- },
+ authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
}
- for i, s := range scenarios {
- form := forms.NewUserPasswordResetRequest(testApp)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- // parse errors
- result := form.Validate()
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
- }
-}
-
-func TestUserPasswordResetRequestSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
scenarios := []struct {
jsonData string
expectError bool
@@ -121,7 +55,7 @@ func TestUserPasswordResetRequestSubmit(t *testing.T) {
for i, s := range scenarios {
testApp.TestMailer.TotalSend = 0 // reset
- form := forms.NewUserPasswordResetRequest(testApp)
+ form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
@@ -150,14 +84,14 @@ func TestUserPasswordResetRequestSubmit(t *testing.T) {
}
// check whether LastResetSentAt was updated
- user, err := testApp.Dao().FindUserByEmail(form.Email)
+ user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
if err != nil {
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
continue
}
- if user.LastResetSentAt.Time().Sub(now.Time()) < 0 {
- t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt)
+ if user.LastResetSentAt().Time().Sub(now.Time()) < 0 {
+ t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt())
}
}
}
diff --git a/forms/record_upsert.go b/forms/record_upsert.go
index becfa8ad..690db513 100644
--- a/forms/record_upsert.go
+++ b/forms/record_upsert.go
@@ -8,8 +8,10 @@ import (
"net/http"
"regexp"
"strconv"
+ "strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
@@ -18,70 +20,88 @@ import (
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
-// RecordUpsert specifies a [models.Record] upsert (create/update) form.
+// username value regex pattern
+var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`)
+
+// RecordUpsert is a [models.Record] upsert (create/update) form.
type RecordUpsert struct {
- config RecordUpsertConfig
- record *models.Record
+ app core.App
+ dao *daos.Dao
+ manageAccess bool
+ record *models.Record
filesToDelete []string // names list
- filesToUpload []*rest.UploadedFile
+ filesToUpload map[string][]*rest.UploadedFile
+
+ // base model fields
+ Id string `json:"id"`
+
+ // auth collection fields
+ // ---
+ Username string `json:"username"`
+ Email string `json:"email"`
+ EmailVisibility bool `json:"emailVisibility"`
+ Verified bool `json:"verified"`
+ Password string `json:"password"`
+ PasswordConfirm string `json:"passwordConfirm"`
+ OldPassword string `json:"oldPassword"`
+ // ---
- Id string `form:"id" json:"id"`
Data map[string]any `json:"data"`
}
-// RecordUpsertConfig is the [RecordUpsert] factory initializer config.
-//
-// NB! App is required struct member.
-type RecordUpsertConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
// config created from the provided [core.App] and [models.Record] instances
-// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
+// (for create you could pass a pointer to an empty Record - models.NewRecord(collection)).
//
-// If you want to submit the form as part of another transaction, use
-// [NewRecordUpsertWithConfig] with explicitly set Dao.
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
- return NewRecordUpsertWithConfig(RecordUpsertConfig{
- App: app,
- }, record)
-}
-
-// NewRecordUpsertWithConfig creates a new [RecordUpsert] form
-// with the provided config and [models.Record] instance or panics on invalid configuration
-// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
-func NewRecordUpsertWithConfig(config RecordUpsertConfig, record *models.Record) *RecordUpsert {
form := &RecordUpsert{
- config: config,
+ app: app,
+ dao: app.Dao(),
record: record,
filesToDelete: []string{},
- filesToUpload: []*rest.UploadedFile{},
+ filesToUpload: map[string][]*rest.UploadedFile{},
}
- if form.config.App == nil || form.record == nil {
- panic("Invalid initializer config or nil upsert model.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- form.Id = record.Id
-
- form.Data = map[string]any{}
- for _, field := range record.Collection().Schema.Fields() {
- form.Data[field.Name] = record.GetDataValue(field.Name)
- }
+ form.loadFormDefaults()
return form
}
+// SetFullManageAccess sets the manageAccess bool flag of the current
+// form to enable/disable directly changing some system record fields
+// (often used with auth collection records).
+func (form *RecordUpsert) SetFullManageAccess(fullManageAccess bool) {
+ form.manageAccess = fullManageAccess
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordUpsert) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+func (form *RecordUpsert) loadFormDefaults() {
+ form.Id = form.record.Id
+
+ if form.record.Collection().IsAuth() {
+ form.Username = form.record.Username()
+ form.Email = form.record.Email()
+ form.EmailVisibility = form.record.EmailVisibility()
+ form.Verified = form.record.Verified()
+ }
+
+ form.Data = map[string]any{}
+ for _, field := range form.record.Collection().Schema.Fields() {
+ form.Data[field.Name] = form.record.Get(field.Name)
+ }
+}
+
func (form *RecordUpsert) getContentType(r *http.Request) string {
t := r.Header.Get("Content-Type")
for i, c := range t {
@@ -92,26 +112,38 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
return t
}
-func (form *RecordUpsert) extractRequestData(r *http.Request) (map[string]any, error) {
+func (form *RecordUpsert) extractRequestData(r *http.Request, keyPrefix string) (map[string]any, error) {
switch form.getContentType(r) {
case "application/json":
- return form.extractJsonData(r)
+ return form.extractJsonData(r, keyPrefix)
case "multipart/form-data":
- return form.extractMultipartFormData(r)
+ return form.extractMultipartFormData(r, keyPrefix)
default:
return nil, errors.New("Unsupported request Content-Type.")
}
}
-func (form *RecordUpsert) extractJsonData(r *http.Request) (map[string]any, error) {
+func (form *RecordUpsert) extractJsonData(r *http.Request, keyPrefix string) (map[string]any, error) {
result := map[string]any{}
- err := rest.ReadJsonBodyCopy(r, &result)
+ err := rest.CopyJsonBody(r, &result)
+
+ if keyPrefix != "" {
+ parts := strings.Split(keyPrefix, ".")
+ for _, part := range parts {
+ if result[part] == nil {
+ break
+ }
+ if v, ok := result[part].(map[string]any); ok {
+ result = v
+ }
+ }
+ }
return result, err
}
-func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]any, error) {
+func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix string) (map[string]any, error) {
result := map[string]any{}
// parse form data (if not already)
@@ -121,7 +153,14 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]
arrayValueSupportTypes := schema.ArraybleFieldTypes()
- for key, values := range r.PostForm {
+ form.filesToUpload = map[string][]*rest.UploadedFile{}
+
+ for fullKey, values := range r.PostForm {
+ key := fullKey
+ if keyPrefix != "" {
+ key = strings.TrimPrefix(key, keyPrefix+".")
+ }
+
if len(values) == 0 {
result[key] = nil
continue
@@ -135,6 +174,44 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]
}
}
+ // load uploaded files (if any)
+ for _, field := range form.record.Collection().Schema.Fields() {
+ if field.Type != schema.FieldTypeFile {
+ continue // not a file field
+ }
+
+ key := field.Name
+ fullKey := key
+ if keyPrefix != "" {
+ fullKey = keyPrefix + "." + key
+ }
+
+ files, err := rest.FindUploadedFiles(r, fullKey)
+ if err != nil || len(files) == 0 {
+ if err != nil && err != http.ErrMissingFile && form.app.IsDebug() {
+ log.Printf("%q uploaded file error: %v\n", fullKey, err)
+ }
+
+ // skip invalid or missing file(s)
+ continue
+ }
+
+ options, ok := field.Options.(*schema.FileOptions)
+ if !ok {
+ continue
+ }
+
+ if form.filesToUpload[key] == nil {
+ form.filesToUpload[key] = []*rest.UploadedFile{}
+ }
+
+ if options.MaxSelect == 1 {
+ form.filesToUpload[key] = append(form.filesToUpload[key], files[0])
+ } else if options.MaxSelect > 1 {
+ form.filesToUpload[key] = append(form.filesToUpload[key], files...)
+ }
+ }
+
return result, nil
}
@@ -144,35 +221,66 @@ func (form *RecordUpsert) normalizeData() error {
form.Data[field.Name] = field.PrepareValue(v)
}
}
-
return nil
}
-// LoadData loads and normalizes json OR multipart/form-data request data.
+// LoadRequest extracts the json or multipart/form-data request data
+// and lods it into the form.
//
// File upload is supported only via multipart/form-data.
//
-// To REPLACE previously uploaded file(s) you can suffix the field name
-// with the file index (eg. `myfile.0`) and set the new value.
-// For single file upload fields, you can skip the index and directly
-// assign the file value to the field name (eg. `myfile`).
-//
// To DELETE previously uploaded file(s) you can suffix the field name
-// with the file index (eg. `myfile.0`) and set it to null or empty string.
+// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
// For single file upload fields, you can skip the index and directly
-// reset the field using its field name (eg. `myfile`).
-func (form *RecordUpsert) LoadData(r *http.Request) error {
- requestData, err := form.extractRequestData(r)
+// reset the field using its field name (eg. `myfile = null`).
+func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
+ requestData, err := form.extractRequestData(r, keyPrefix)
if err != nil {
return err
}
- if id, ok := requestData["id"]; ok {
- form.Id = cast.ToString(id)
+ return form.LoadData(requestData)
+}
+
+// LoadData loads and normalizes the provided data into the form.
+//
+// To DELETE previously uploaded file(s) you can suffix the field name
+// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
+// For single file upload fields, you can skip the index and directly
+// reset the field using its field name (eg. `myfile = null`).
+func (form *RecordUpsert) LoadData(requestData map[string]any) error {
+ // load base system fields
+ if v, ok := requestData["id"]; ok {
+ form.Id = cast.ToString(v)
}
- // extend base data with the extracted one
- extendedData := form.record.Data()
+ // load auth system fields
+ if form.record.Collection().IsAuth() {
+ if v, ok := requestData["username"]; ok {
+ form.Username = cast.ToString(v)
+ }
+ if v, ok := requestData["email"]; ok {
+ form.Email = cast.ToString(v)
+ }
+ if v, ok := requestData["emailVisibility"]; ok {
+ form.EmailVisibility = cast.ToBool(v)
+ }
+ if v, ok := requestData["verified"]; ok {
+ form.Verified = cast.ToBool(v)
+ }
+ if v, ok := requestData["password"]; ok {
+ form.Password = cast.ToString(v)
+ }
+ if v, ok := requestData["passwordConfirm"]; ok {
+ form.PasswordConfirm = cast.ToString(v)
+ }
+ if v, ok := requestData["oldPassword"]; ok {
+ form.OldPassword = cast.ToString(v)
+ }
+ }
+
+ // extend the record schema data with the request data
+ extendedData := form.record.SchemaData()
rawData, err := json.Marshal(requestData)
if err != nil {
return err
@@ -243,17 +351,8 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
// Check for new uploaded file
// -----------------------------------------------------------
- if form.getContentType(r) != "multipart/form-data" {
- continue // file upload is supported only via multipart/form-data
- }
-
- files, err := rest.FindUploadedFiles(r, key)
- if err != nil {
- if form.config.App.IsDebug() {
- log.Printf("%q uploaded file error: %v\n", key, err)
- }
-
- continue // skip invalid or missing file(s)
+ if len(form.filesToUpload[key]) == 0 {
+ continue
}
// refresh oldNames list
@@ -264,12 +363,10 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
if len(oldNames) > 0 {
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
}
- form.filesToUpload = append(form.filesToUpload, files[0])
- form.Data[key] = files[0].Name()
+ form.Data[key] = form.filesToUpload[key][0].Name()
} else if options.MaxSelect > 1 {
// append the id of each uploaded file instance
- form.filesToUpload = append(form.filesToUpload, files...)
- for _, file := range files {
+ for _, file := range form.filesToUpload[key] {
oldNames = append(oldNames, file.Name())
}
form.Data[key] = oldNames
@@ -282,7 +379,7 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordUpsert) Validate() error {
// base form fields validator
- baseFieldsErrors := validation.ValidateStruct(form,
+ baseFieldsRules := []*validation.FieldRules{
validation.Field(
&form.Id,
validation.When(
@@ -291,26 +388,159 @@ func (form *RecordUpsert) Validate() error {
validation.Match(idRegex),
).Else(validation.In(form.record.Id)),
),
- )
- if baseFieldsErrors != nil {
- return baseFieldsErrors
+ }
+
+ // auth fields validators
+ if form.record.Collection().IsAuth() {
+ baseFieldsRules = append(baseFieldsRules,
+ validation.Field(
+ &form.Username,
+ // require only on update, because on create we fallback to auto generated username
+ validation.When(!form.record.IsNew(), validation.Required),
+ validation.Length(4, 100),
+ validation.Match(usernameRegex),
+ validation.By(form.checkUniqueUsername),
+ ),
+ validation.Field(
+ &form.Email,
+ validation.When(
+ form.record.Collection().AuthOptions().RequireEmail,
+ validation.Required,
+ ),
+ // don't allow direct email change (or unset) if the form doesn't have manage access permissions
+ // (aka. allow only admin or authorized auth models to directly update the field)
+ validation.When(
+ !form.record.IsNew() && !form.manageAccess,
+ validation.In(form.record.Email()),
+ ),
+ validation.Length(1, 255),
+ is.EmailFormat,
+ validation.By(form.checkEmailDomain),
+ validation.By(form.checkUniqueEmail),
+ ),
+ validation.Field(
+ &form.Verified,
+ // don't allow changing verified if the form doesn't have manage access permissions
+ // (aka. allow only admin or authorized auth models to directly change the field)
+ validation.When(
+ !form.manageAccess,
+ validation.In(form.record.Verified()),
+ ),
+ ),
+ validation.Field(
+ &form.Password,
+ validation.When(form.record.IsNew(), validation.Required),
+ validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72),
+ ),
+ validation.Field(
+ &form.PasswordConfirm,
+ validation.When(
+ (form.record.IsNew() || form.Password != ""),
+ validation.Required,
+ ),
+ validation.By(validators.Compare(form.Password)),
+ ),
+ validation.Field(
+ &form.OldPassword,
+ // require old password only on update when:
+ // - form.manageAccess is not set
+ // - changing the existing password
+ validation.When(
+ !form.record.IsNew() && !form.manageAccess && form.Password != "",
+ validation.Required,
+ validation.By(form.checkOldPassword),
+ ),
+ ),
+ )
+ }
+
+ if err := validation.ValidateStruct(form, baseFieldsRules...); err != nil {
+ return err
}
// record data validator
- dataValidator := validators.NewRecordDataValidator(
- form.config.Dao,
+ return validators.NewRecordDataValidator(
+ form.dao,
form.record,
form.filesToUpload,
- )
-
- return dataValidator.Validate(form.Data)
+ ).Validate(form.Data)
}
-// DrySubmit performs a form submit within a transaction and reverts it.
-// For actual record persistence, check the `form.Submit()` method.
-//
-// This method doesn't handle file uploads/deletes or trigger any app events!
-func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error {
+func (form *RecordUpsert) checkUniqueUsername(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil
+ }
+
+ isUnique := form.dao.IsRecordValueUnique(
+ form.record.Collection().Id,
+ schema.FieldNameUsername,
+ v,
+ form.record.Id,
+ )
+ if !isUnique {
+ return validation.NewError("validation_invalid_username", "The username is invalid or already in use.")
+ }
+
+ return nil
+}
+
+func (form *RecordUpsert) checkUniqueEmail(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil
+ }
+
+ isUnique := form.dao.IsRecordValueUnique(
+ form.record.Collection().Id,
+ schema.FieldNameEmail,
+ v,
+ form.record.Id,
+ )
+ if !isUnique {
+ return validation.NewError("validation_invalid_email", "The email is invalid or already in use.")
+ }
+
+ return nil
+}
+
+func (form *RecordUpsert) checkEmailDomain(value any) error {
+ val, _ := value.(string)
+ if val == "" {
+ return nil // nothing to check
+ }
+
+ domain := val[strings.LastIndex(val, "@")+1:]
+ only := form.record.Collection().AuthOptions().OnlyEmailDomains
+ except := form.record.Collection().AuthOptions().ExceptEmailDomains
+
+ // only domains check
+ if len(only) > 0 && !list.ExistInSlice(domain, only) {
+ return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
+ }
+
+ // except domains check
+ if len(except) > 0 && list.ExistInSlice(domain, except) {
+ return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
+ }
+
+ return nil
+}
+
+func (form *RecordUpsert) checkOldPassword(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ if !form.record.ValidatePassword(v) {
+ return validation.NewError("validation_invalid_old_password", "Missing or invalid old password.")
+ }
+
+ return nil
+}
+
+func (form *RecordUpsert) ValidateAndFill() error {
if err := form.Validate(); err != nil {
return err
}
@@ -319,16 +549,67 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
// custom insertion id can be set only on create
if isNew && form.Id != "" {
- form.record.MarkAsNew()
form.record.SetId(form.Id)
+ form.record.MarkAsNew()
}
- // bulk load form data
- if err := form.record.Load(form.Data); err != nil {
+ // set auth fields
+ if form.record.Collection().IsAuth() {
+ // generate a default username during create (if missing)
+ if form.record.IsNew() && form.Username == "" {
+ baseUsername := form.record.Collection().Name + security.RandomStringWithAlphabet(5, "123456789")
+ form.Username = form.dao.SuggestUniqueAuthRecordUsername(form.record.Collection().Id, baseUsername)
+ }
+
+ if form.Username != "" {
+ if err := form.record.SetUsername(form.Username); err != nil {
+ return err
+ }
+ }
+
+ if isNew || form.manageAccess {
+ if err := form.record.SetEmail(form.Email); err != nil {
+ return err
+ }
+ }
+
+ if err := form.record.SetEmailVisibility(form.EmailVisibility); err != nil {
+ return err
+ }
+
+ if form.manageAccess {
+ if err := form.record.SetVerified(form.Verified); err != nil {
+ return err
+ }
+ }
+
+ if form.Password != "" {
+ if err := form.record.SetPassword(form.Password); err != nil {
+ return err
+ }
+ }
+ }
+
+ // bulk load the remaining form data
+ form.record.Load(form.Data)
+
+ return nil
+}
+
+// DrySubmit performs a form submit within a transaction and reverts it.
+// For actual record persistence, check the `form.Submit()` method.
+//
+// This method doesn't handle file uploads/deletes or trigger any app events!
+func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error {
+ isNew := form.record.IsNew()
+
+ if err := form.ValidateAndFill(); err != nil {
return err
}
- return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
+ // use the default app.Dao to prevent changing the transaction form.Dao
+ // and causing "transaction has already been committed or rolled back" error
+ return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
tx, ok := txDao.DB().(*dbx.Tx)
if !ok {
return errors.New("failed to get transaction db")
@@ -362,31 +643,20 @@ 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 {
- if err := form.Validate(); err != nil {
- return err
- }
-
- // custom insertion id can be set only on create
- if form.record.IsNew() && form.Id != "" {
- form.record.MarkAsNew()
- form.record.SetId(form.Id)
- }
-
- // bulk load form data
- if err := form.record.Load(form.Data); err != nil {
+ if err := form.ValidateAndFill(); err != nil {
return err
}
return runInterceptors(func() error {
- return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
+ return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
// persist record model
if err := txDao.SaveRecord(form.record); err != nil {
- return err
+ return fmt.Errorf("Failed to save the record: %v", err)
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
- return err
+ return fmt.Errorf("Failed to process the upload files: %v", err)
}
// delete old files (if any)
@@ -402,30 +672,33 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
func (form *RecordUpsert) processFilesToUpload() error {
if len(form.filesToUpload) == 0 {
- return nil // nothing to upload
+ return nil // no parsed file fields
}
if !form.record.HasId() {
return errors.New("The record is not persisted yet.")
}
- fs, err := form.config.App.NewFilesystem()
+ fs, err := form.app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
var uploadErrors []error
- for i := len(form.filesToUpload) - 1; i >= 0; i-- {
- file := form.filesToUpload[i]
- path := form.record.BaseFilesPath() + "/" + file.Name()
- if err := fs.Upload(file.Bytes(), path); err == nil {
- // remove the uploaded file from the list
- form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...)
- } else {
- // store the upload error
- uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
+ for fieldKey := range form.filesToUpload {
+ for i := len(form.filesToUpload[fieldKey]) - 1; i >= 0; i-- {
+ file := form.filesToUpload[fieldKey][i]
+ path := form.record.BaseFilesPath() + "/" + file.Name()
+
+ if err := fs.UploadMultipart(file.Header(), path); err == nil {
+ // remove the uploaded file from the list
+ form.filesToUpload[fieldKey] = append(form.filesToUpload[fieldKey][:i], form.filesToUpload[fieldKey][i+1:]...)
+ } else {
+ // store the upload error
+ uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
+ }
}
}
@@ -445,7 +718,7 @@ func (form *RecordUpsert) processFilesToDelete() error {
return errors.New("The record is not persisted yet.")
}
- fs, err := form.config.App.NewFilesystem()
+ fs, err := form.app.NewFilesystem()
if err != nil {
return err
}
diff --git a/forms/record_upsert_test.go b/forms/record_upsert_test.go
index 4bba9fef..9809132d 100644
--- a/forms/record_upsert_test.go
+++ b/forms/record_upsert_test.go
@@ -10,7 +10,6 @@ import (
"strings"
"testing"
- validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
@@ -20,36 +19,28 @@ import (
"github.com/pocketbase/pocketbase/tools/list"
)
-func TestRecordUpsertPanic1(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
+func hasRecordFile(app core.App, record *models.Record, filename string) bool {
+ fs, _ := app.NewFilesystem()
+ defer fs.Close()
- forms.NewRecordUpsert(nil, nil)
-}
+ fileKey := filepath.Join(
+ record.Collection().Id,
+ record.Id,
+ filename,
+ )
-func TestRecordUpsertPanic2(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+ exists, _ := fs.Exists(fileKey)
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewRecordUpsert(app, nil)
+ return exists
}
func TestNewRecordUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo")
+ collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
record := models.NewRecord(collection)
- record.SetDataValue("title", "test_value")
+ record.Set("title", "test_value")
form := forms.NewRecordUpsert(app, record)
@@ -59,12 +50,11 @@ func TestNewRecordUpsert(t *testing.T) {
}
}
-func TestRecordUpsertLoadDataUnsupported(t *testing.T) {
+func TestRecordUpsertLoadRequestUnsupported(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
+ record, err := app.Dao().FindRecordById("demo2", "0yxhwia2amd8gec")
if err != nil {
t.Fatal(err)
}
@@ -75,37 +65,40 @@ func TestRecordUpsertLoadDataUnsupported(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(testData))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
- if err := form.LoadData(req); err == nil {
- t.Fatal("Expected LoadData to fail, got nil")
+ if err := form.LoadRequest(req, ""); err == nil {
+ t.Fatal("Expected LoadRequest to fail, got nil")
}
}
-func TestRecordUpsertLoadDataJson(t *testing.T) {
+func TestRecordUpsertLoadRequestJson(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
+ record, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
testData := map[string]any{
- "id": "test_id",
- "title": "test123",
- "unknown": "test456",
- // file fields unset/delete
- "onefile": nil,
- "manyfiles.0": "",
- "manyfiles.1": "test.png", // should be ignored
- "onlyimages": nil,
+ "a": map[string]any{
+ "b": map[string]any{
+ "id": "test_id",
+ "text": "test123",
+ "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
+ },
+ },
}
form := forms.NewRecordUpsert(app, record)
jsonBody, _ := json.Marshal(testData)
req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader(jsonBody))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
- loadErr := form.LoadData(req)
+ loadErr := form.LoadRequest(req, "a.b")
if loadErr != nil {
t.Fatal(loadErr)
}
@@ -114,7 +107,7 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
}
- if v, ok := form.Data["title"]; !ok || v != "test123" {
+ if v, ok := form.Data["text"]; !ok || v != "test123" {
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
}
@@ -122,50 +115,43 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
}
- onefile, ok := form.Data["onefile"]
+ fileOne, ok := form.Data["file_one"]
if !ok {
- t.Fatal("Expect onefile field to be set")
+ t.Fatal("Expect file_one field to be set")
}
- if onefile != "" {
- t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
+ if fileOne != "" {
+ t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
}
- manyfiles, ok := form.Data["manyfiles"]
- if !ok || manyfiles == nil {
- t.Fatal("Expect manyfiles field to be set")
+ fileMany, ok := form.Data["file_many"]
+ if !ok || fileMany == nil {
+ t.Fatal("Expect file_many field to be set")
}
- manyfilesRemains := len(list.ToUniqueStringSlice(manyfiles))
+ manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
if manyfilesRemains != 1 {
- t.Fatalf("Expect only 1 manyfiles to remain, got \n%v", manyfiles)
- }
-
- onlyimages := form.Data["onlyimages"]
- if len(list.ToUniqueStringSlice(onlyimages)) != 0 {
- t.Fatalf("Expect onlyimages field to be deleted, got \n%v", onlyimages)
+ t.Fatalf("Expect only 1 file_many to remain, got \n%v", fileMany)
}
}
-func TestRecordUpsertLoadDataMultipart(t *testing.T) {
+func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
+ record, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
formData, mp, err := tests.MockMultipartData(map[string]string{
- "id": "test_id",
- "title": "test123",
- "unknown": "test456",
+ "a.b.id": "test_id",
+ "a.b.text": "test123",
+ "a.b.unknown": "test456",
// file fields unset/delete
- "onefile": "",
- "manyfiles.0": "", // delete by index
- "manyfiles.b635c395-6837-49e5-8535-b0a6ebfbdbf3.png": "", // delete by name
- "manyfiles.1": "test.png", // should be ignored
- "onlyimages": "",
- }, "onlyimages")
+ "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")
if err != nil {
t.Fatal(err)
}
@@ -173,7 +159,7 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
form := forms.NewRecordUpsert(app, record)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- loadErr := form.LoadData(req)
+ loadErr := form.LoadRequest(req, "a.b")
if loadErr != nil {
t.Fatal(loadErr)
}
@@ -182,117 +168,58 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
}
- if v, ok := form.Data["title"]; !ok || v != "test123" {
- t.Fatalf("Expect title field to be %q, got %q", "test123", v)
+ 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 {
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
}
- onefile, ok := form.Data["onefile"]
+ fileOne, ok := form.Data["file_one"]
if !ok {
- t.Fatal("Expect onefile field to be set")
+ t.Fatal("Expect file_one field to be set")
}
- if onefile != "" {
- t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
+ if fileOne != "" {
+ t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
}
- manyfiles, ok := form.Data["manyfiles"]
- if !ok || manyfiles == nil {
- t.Fatal("Expect manyfiles field to be set")
+ fileMany, ok := form.Data["file_many"]
+ if !ok || fileMany == nil {
+ t.Fatal("Expect file_many field to be set")
}
- manyfilesRemains := len(list.ToUniqueStringSlice(manyfiles))
- if manyfilesRemains != 0 {
- t.Fatalf("Expect 0 manyfiles to remain, got %v", manyfiles)
- }
-
- onlyimages, ok := form.Data["onlyimages"]
- if !ok || onlyimages == nil {
- t.Fatal("Expect onlyimages field to be set")
- }
- onlyimagesRemains := len(list.ToUniqueStringSlice(onlyimages))
- expectedRemains := 1 // -2 removed + 1 new upload
- if onlyimagesRemains != expectedRemains {
- t.Fatalf("Expect onlyimages to be %d, got %d (%v)", expectedRemains, onlyimagesRemains, onlyimages)
+ manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
+ expectedRemains := 2 // -2 from 3 removed + 1 new upload
+ if manyfilesRemains != expectedRemains {
+ t.Fatalf("Expect file_many to be %d, got %d (%v)", expectedRemains, manyfilesRemains, fileMany)
}
}
-func TestRecordUpsertValidateFailure(t *testing.T) {
+func TestRecordUpsertLoadData(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
- if err != nil {
- t.Fatal(err)
- }
-
- // try with invalid test data to check whether the RecordDataValidator is triggered
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "id": "",
- "unknown": "test456", // should be ignored
- "title": "a",
- "onerel": "00000000-84ab-4057-a592-4604a731f78f",
- }, "manyfiles", "manyfiles")
- if err != nil {
- t.Fatal(err)
- }
-
- expectedErrors := []string{"title", "onerel", "manyfiles"}
-
- form := forms.NewRecordUpsert(app, record)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadData(req)
-
- result := form.Validate()
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Fatalf("Failed to parse errors %v", result)
- }
-
- // check errors
- if len(errs) > len(expectedErrors) {
- t.Fatalf("Expected error keys %v, got %v", expectedErrors, errs)
- }
- for _, k := range expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("Missing expected error key %q in %v", k, errs)
- }
- }
-}
-
-func TestRecordUpsertValidateSuccess(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
- if err != nil {
- t.Fatal(err)
- }
-
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "id": record.Id,
- "unknown": "test456", // should be ignored
- "title": "abc",
- "onerel": "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
- }, "manyfiles", "onefile")
+ record, err := app.Dao().FindRecordById("demo2", "llvuca81nly1qls")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordUpsert(app, record)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadData(req)
- result := form.Validate()
- if result != nil {
- t.Fatal(result)
+ loadErr := form.LoadData(map[string]any{
+ "title": "test_new",
+ "active": true,
+ })
+ if loadErr != nil {
+ t.Fatal(loadErr)
+ }
+
+ 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 {
+ t.Fatalf("Expect active field to be %v, got %v", true, v)
}
}
@@ -300,15 +227,15 @@ func TestRecordUpsertDrySubmitFailure(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
+ collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
+ recordBefore, err := app.Dao().FindRecordById(collection.Id, "al1h9ijdeojtsjy")
if err != nil {
t.Fatal(err)
}
formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "a",
- "onerel": "00000000-84ab-4057-a592-4604a731f78f",
+ "title": "abc",
+ "rel_one": "missing",
})
if err != nil {
t.Fatal(err)
@@ -317,7 +244,7 @@ func TestRecordUpsertDrySubmitFailure(t *testing.T) {
form := forms.NewRecordUpsert(app, recordBefore)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadData(req)
+ form.LoadRequest(req, "")
callbackCalls := 0
@@ -336,17 +263,17 @@ func TestRecordUpsertDrySubmitFailure(t *testing.T) {
// ensure that the record changes weren't persisted
// ---
- recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
+ recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
if err != nil {
t.Fatal(err)
}
- if recordAfter.GetStringDataValue("title") == "a" {
- t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "a")
+ if recordAfter.GetString("title") == "abc" {
+ t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "abc")
}
- if recordAfter.GetStringDataValue("onerel") == "00000000-84ab-4057-a592-4604a731f78f" {
- t.Fatalf("Expected record.onerel to be %s, got %s", recordBefore.GetStringDataValue("onerel"), recordAfter.GetStringDataValue("onerel"))
+ if recordAfter.GetString("rel_one") == "missing" {
+ t.Fatalf("Expected record.rel_one to be %s, got %s", recordBefore.GetString("rel_one"), "missing")
}
}
@@ -354,16 +281,16 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
+ 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",
- "onefile": "",
- }, "manyfiles")
+ "title": "dry_test",
+ "file_one": "",
+ }, "file_many")
if err != nil {
t.Fatal(err)
}
@@ -371,7 +298,7 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
form := forms.NewRecordUpsert(app, recordBefore)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadData(req)
+ form.LoadRequest(req, "")
callbackCalls := 0
@@ -390,21 +317,21 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
// ensure that the record changes weren't persisted
// ---
- recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
+ recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
if err != nil {
t.Fatal(err)
}
- if recordAfter.GetStringDataValue("title") == "dry_test" {
- t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "dry_test")
+ if recordAfter.GetString("title") == "dry_test" {
+ t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "dry_test")
}
- if recordAfter.GetStringDataValue("onefile") == "" {
- t.Fatal("Expected record.onefile to be set, got empty string")
+ if recordAfter.GetString("file_one") == "" {
+ t.Fatal("Expected record.file_one to not be changed, got empty string")
}
// file wasn't removed
- if !hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
- t.Fatal("onefile file should not have been deleted")
+ if !hasRecordFile(app, recordAfter, recordAfter.GetString("file_one")) {
+ t.Fatal("file_one file should not have been deleted")
}
}
@@ -412,16 +339,23 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
+ collection, err := app.Dao().FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "a",
- "onefile": "",
- })
+ "text": "abc",
+ "bool": "false",
+ "select_one": "invalid",
+ "file_many": "invalid",
+ "email": "invalid",
+ }, "file_one")
if err != nil {
t.Fatal(err)
}
@@ -429,7 +363,7 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
form := forms.NewRecordUpsert(app, recordBefore)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadData(req)
+ form.LoadRequest(req, "")
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
@@ -454,22 +388,32 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
// ensure that the record changes weren't persisted
// ---
- recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
+ recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
if err != nil {
t.Fatal(err)
}
- if recordAfter.GetStringDataValue("title") == "a" {
- t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "a")
+ if v := recordAfter.Get("text"); v == "abc" {
+ t.Fatalf("Expected record.text not to change, got %v", v)
+ }
+ if v := recordAfter.Get("bool"); v == false {
+ t.Fatalf("Expected record.bool not to change, got %v", v)
+ }
+ if v := recordAfter.Get("select_one"); v == "invalid" {
+ t.Fatalf("Expected record.select_one not to change, got %v", v)
+ }
+ 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 {
+ t.Fatalf("Expected record.file_many not to change, got %v", v)
}
- if recordAfter.GetStringDataValue("onefile") == "" {
- t.Fatal("Expected record.onefile to be set, got empty string")
- }
-
- // file wasn't removed
- if !hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
- t.Fatal("onefile file should not have been deleted")
+ // ensure the files weren't removed
+ for _, f := range recordAfter.GetStringSlice("file_many") {
+ if !hasRecordFile(app, recordAfter, f) {
+ t.Fatal("file_many file should not have been deleted")
+ }
}
}
@@ -477,17 +421,18 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
+ 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": "test_save",
- "onefile": "",
- "onlyimages": "",
- }, "manyfiles.1", "manyfiles") // replace + new file
+ "text": "test_save",
+ "bool": "true",
+ "select_one": "optionA",
+ "file_one": "",
+ }, "file_many.1", "file_many") // replace + new file
if err != nil {
t.Fatal(err)
}
@@ -495,7 +440,7 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
form := forms.NewRecordUpsert(app, recordBefore)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadData(req)
+ form.LoadRequest(req, "")
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
@@ -518,29 +463,24 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
// ensure that the record changes were persisted
// ---
- recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
+ recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
if err != nil {
t.Fatal(err)
}
- if recordAfter.GetStringDataValue("title") != "test_save" {
- t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "test_save")
+ if v := recordAfter.GetString("text"); v != "test_save" {
+ t.Fatalf("Expected record.text to be %v, got %v", v, "test_save")
}
- if hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
- t.Fatal("Expected record.onefile to be deleted")
+ if hasRecordFile(app, recordAfter, recordAfter.GetString("file_one")) {
+ t.Fatal("Expected record.file_one to be deleted")
}
- onlyimages := (recordAfter.GetStringSliceDataValue("onlyimages"))
- if len(onlyimages) != 0 {
- t.Fatalf("Expected all onlyimages files to be deleted, got %d (%v)", len(onlyimages), onlyimages)
+ 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)
}
-
- manyfiles := (recordAfter.GetStringSliceDataValue("manyfiles"))
- if len(manyfiles) != 3 {
- t.Fatalf("Expected 3 manyfiles, got %d (%v)", len(manyfiles), manyfiles)
- }
- for _, f := range manyfiles {
+ for _, f := range fileMany {
if !hasRecordFile(app, recordAfter, f) {
t.Fatalf("Expected file %q to exist", f)
}
@@ -551,8 +491,8 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
- record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
+ collection, _ := app.Dao().FindCollectionByNameOrId("demo3")
+ record, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk")
if err != nil {
t.Fatal(err)
}
@@ -574,7 +514,7 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
- interceptorRecordTitle = record.GetStringDataValue("title") // to check if the record was filled
+ interceptorRecordTitle = record.GetString("title") // to check if the record was filled
interceptor2Called = true
return testErr
}
@@ -598,27 +538,16 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
}
}
-func hasRecordFile(app core.App, record *models.Record, filename string) bool {
- fs, _ := app.NewFilesystem()
- defer fs.Close()
-
- fileKey := filepath.Join(
- record.Collection().Id,
- record.Id,
- filename,
- )
-
- exists, _ := fs.Exists(fileKey)
-
- return exists
-}
-
func TestRecordUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo3")
- existingRecord, err := app.Dao().FindFirstRecordByData(collection, "id", "2c542824-9de1-42fe-8924-e57c86267760")
+ collection, err := app.Dao().FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ existingRecord, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk")
if err != nil {
t.Fatal(err)
}
@@ -694,7 +623,7 @@ func TestRecordUpsertWithCustomId(t *testing.T) {
form := forms.NewRecordUpsert(app, scenario.record)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadData(req)
+ form.LoadRequest(req, "")
dryErr := form.DrySubmit(nil)
hasDryErr := dryErr != nil
@@ -711,10 +640,191 @@ func TestRecordUpsertWithCustomId(t *testing.T) {
}
if id, ok := scenario.data["id"]; ok && id != "" && !hasSubmitErr {
- _, err := app.Dao().FindRecordById(collection, id, nil)
+ _, err := app.Dao().FindRecordById(collection.Id, id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, id, err)
}
}
}
}
+
+func TestRecordUpsertAuthRecord(t *testing.T) {
+ scenarios := []struct {
+ testName string
+ existingId string
+ data map[string]any
+ manageAccess bool
+ expectError bool
+ }{
+ {
+ "empty create data",
+ "",
+ map[string]any{},
+ false,
+ true,
+ },
+ {
+ "empty update data",
+ "4q1xlclmfloku33",
+ map[string]any{},
+ false,
+ false,
+ },
+ {
+ "minimum valid create data",
+ "",
+ map[string]any{
+ "password": "12345678",
+ "passwordConfirm": "12345678",
+ },
+ false,
+ false,
+ },
+ {
+ "create with all allowed auth fields",
+ "",
+ map[string]any{
+ "username": "test_new",
+ "email": "test_new@example.com",
+ "emailVisibility": true,
+ "password": "12345678",
+ "passwordConfirm": "12345678",
+ },
+ false,
+ false,
+ },
+
+ // verified
+ {
+ "try to set verified without managed access",
+ "",
+ map[string]any{
+ "verified": true,
+ "password": "12345678",
+ "passwordConfirm": "12345678",
+ },
+ false,
+ true,
+ },
+ {
+ "try to update verified without managed access",
+ "4q1xlclmfloku33",
+ map[string]any{
+ "verified": true,
+ },
+ false,
+ true,
+ },
+ {
+ "set verified with managed access",
+ "",
+ map[string]any{
+ "verified": true,
+ "password": "12345678",
+ "passwordConfirm": "12345678",
+ },
+ true,
+ false,
+ },
+ {
+ "update verified with managed access",
+ "4q1xlclmfloku33",
+ map[string]any{
+ "verified": true,
+ },
+ true,
+ false,
+ },
+
+ // email
+ {
+ "try to update email without managed access",
+ "4q1xlclmfloku33",
+ map[string]any{
+ "email": "test_update@example.com",
+ },
+ false,
+ true,
+ },
+ {
+ "update email with managed access",
+ "4q1xlclmfloku33",
+ map[string]any{
+ "email": "test_update@example.com",
+ },
+ true,
+ false,
+ },
+
+ // password
+ {
+ "try to update password without managed access",
+ "4q1xlclmfloku33",
+ map[string]any{
+ "password": "1234567890",
+ "passwordConfirm": "1234567890",
+ },
+ false,
+ true,
+ },
+ {
+ "update password without managed access but with oldPassword",
+ "4q1xlclmfloku33",
+ map[string]any{
+ "oldPassword": "1234567890",
+ "password": "1234567890",
+ "passwordConfirm": "1234567890",
+ },
+ false,
+ false,
+ },
+ {
+ "update email with managed access (without oldPassword)",
+ "4q1xlclmfloku33",
+ map[string]any{
+ "password": "1234567890",
+ "passwordConfirm": "1234567890",
+ },
+ true,
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.Dao().FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record := models.NewRecord(collection)
+ if s.existingId != "" {
+ var err error
+ record, err = app.Dao().FindRecordById(collection.Id, s.existingId)
+ if err != nil {
+ t.Errorf("[%s] Failed to fetch auth record with id %s", s.testName, s.existingId)
+ continue
+ }
+ }
+
+ form := forms.NewRecordUpsert(app, record)
+ form.SetFullManageAccess(s.manageAccess)
+ if err := form.LoadData(s.data); err != nil {
+ t.Errorf("[%s] Failed to load form data", s.testName)
+ continue
+ }
+
+ submitErr := form.Submit()
+
+ hasErr := submitErr != nil
+ if hasErr != s.expectError {
+ t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.testName, s.expectError, hasErr, submitErr)
+ }
+
+ if !hasErr && record.Username() == "" {
+ t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.testName, record)
+ }
+ }
+}
diff --git a/forms/record_verification_confirm.go b/forms/record_verification_confirm.go
new file mode 100644
index 00000000..87bab326
--- /dev/null
+++ b/forms/record_verification_confirm.go
@@ -0,0 +1,103 @@
+package forms
+
+import (
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cast"
+)
+
+// RecordVerificationConfirm is an auth record email verification confirmation form.
+type RecordVerificationConfirm struct {
+ app core.App
+ collection *models.Collection
+ dao *daos.Dao
+
+ Token string `form:"token" json:"token"`
+}
+
+// NewRecordVerificationConfirm creates a new [RecordVerificationConfirm]
+// form initialized with from the provided [core.App] instance.
+//
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
+func NewRecordVerificationConfirm(app core.App, collection *models.Collection) *RecordVerificationConfirm {
+ return &RecordVerificationConfirm{
+ app: app,
+ dao: app.Dao(),
+ collection: collection,
+ }
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordVerificationConfirm) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+func (form *RecordVerificationConfirm) Validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
+ )
+}
+
+func (form *RecordVerificationConfirm) checkToken(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ claims, _ := security.ParseUnverifiedJWT(v)
+ email := cast.ToString(claims["email"])
+ if email == "" {
+ return validation.NewError("validation_invalid_token_claims", "Missing email token claim.")
+ }
+
+ record, err := form.dao.FindAuthRecordByToken(
+ v,
+ form.app.Settings().RecordVerificationToken.Secret,
+ )
+ if err != nil || record == nil {
+ return validation.NewError("validation_invalid_token", "Invalid or expired token.")
+ }
+
+ if record.Collection().Id != form.collection.Id {
+ return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
+ }
+
+ if record.Email() != email {
+ return validation.NewError("validation_token_email_mismatch", "The record email doesn't match with the requested token claims.")
+ }
+
+ return nil
+}
+
+// Submit validates and submits the form.
+// On success returns the verified auth record associated to `form.Token`.
+func (form *RecordVerificationConfirm) Submit() (*models.Record, error) {
+ if err := form.Validate(); err != nil {
+ return nil, err
+ }
+
+ record, err := form.dao.FindAuthRecordByToken(
+ form.Token,
+ form.app.Settings().RecordVerificationToken.Secret,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if record.Verified() {
+ return record, nil // already verified
+ }
+
+ record.SetVerified(true)
+
+ if err := form.dao.SaveRecord(record); err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
diff --git a/forms/record_verification_confirm_test.go b/forms/record_verification_confirm_test.go
new file mode 100644
index 00000000..7059a58a
--- /dev/null
+++ b/forms/record_verification_confirm_test.go
@@ -0,0 +1,79 @@
+package forms_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/forms"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ jsonData string
+ expectError bool
+ }{
+ // empty data (Validate call check)
+ {
+ `{}`,
+ true,
+ },
+ // expired token (Validate call check)
+ {
+ `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.Avbt9IP8sBisVz_2AGrlxLDvangVq4PhL2zqQVYLKlE"}`,
+ true,
+ },
+ // valid token (already verified record)
+ {
+ `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"}`,
+ false,
+ },
+ // valid token (unverified record)
+ {
+ `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"}`,
+ false,
+ },
+ }
+
+ for i, s := range scenarios {
+ form := forms.NewRecordVerificationConfirm(testApp, authCollection)
+
+ // load data
+ loadErr := json.Unmarshal([]byte(s.jsonData), form)
+ if loadErr != nil {
+ t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
+ continue
+ }
+
+ record, err := form.Submit()
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ continue
+ }
+
+ claims, _ := security.ParseUnverifiedJWT(form.Token)
+ tokenRecordId := claims["id"]
+
+ if record.Id != tokenRecordId {
+ t.Errorf("(%d) Expected record.Id %q, got %q", i, tokenRecordId, record.Id)
+ }
+
+ if !record.Verified() {
+ t.Errorf("(%d) Expected record.Verified() to be true, got false", i)
+ }
+ }
+}
diff --git a/forms/record_verification_request.go b/forms/record_verification_request.go
new file mode 100644
index 00000000..d702e936
--- /dev/null
+++ b/forms/record_verification_request.go
@@ -0,0 +1,94 @@
+package forms
+
+import (
+ "errors"
+ "time"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/go-ozzo/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/mails"
+ "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+// RecordVerificationRequest is an auth record email verification request form.
+type RecordVerificationRequest struct {
+ app core.App
+ collection *models.Collection
+ dao *daos.Dao
+ resendThreshold float64 // in seconds
+
+ Email string `form:"email" json:"email"`
+}
+
+// NewRecordVerificationRequest creates a new [RecordVerificationRequest]
+// form initialized with from the provided [core.App] instance.
+//
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
+func NewRecordVerificationRequest(app core.App, collection *models.Collection) *RecordVerificationRequest {
+ return &RecordVerificationRequest{
+ app: app,
+ dao: app.Dao(),
+ collection: collection,
+ resendThreshold: 120, // 2 min
+ }
+}
+
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *RecordVerificationRequest) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+//
+// // This method doesn't verify that auth record with `form.Email` exists (this is done on Submit).
+func (form *RecordVerificationRequest) Validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(
+ &form.Email,
+ validation.Required,
+ validation.Length(1, 255),
+ is.EmailFormat,
+ ),
+ )
+}
+
+// Submit validates and sends a verification request email
+// to the `form.Email` auth record.
+func (form *RecordVerificationRequest) Submit() error {
+ if err := form.Validate(); err != nil {
+ return err
+ }
+
+ record, err := form.dao.FindFirstRecordByData(
+ form.collection.Id,
+ schema.FieldNameEmail,
+ form.Email,
+ )
+ if err != nil {
+ return err
+ }
+
+ if record.GetBool(schema.FieldNameVerified) {
+ return nil // already verified
+ }
+
+ now := time.Now().UTC()
+ lastVerificationSentAt := record.LastVerificationSentAt().Time()
+ if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
+ return errors.New("A verification email was already sent.")
+ }
+
+ if err := mails.SendRecordVerification(form.app, record); err != nil {
+ return err
+ }
+
+ // update last sent timestamp
+ record.Set(schema.FieldNameLastVerificationSentAt, types.NowDateTime())
+
+ return form.dao.SaveRecord(record)
+}
diff --git a/forms/user_verification_request_test.go b/forms/record_verification_request_test.go
similarity index 54%
rename from forms/user_verification_request_test.go
rename to forms/record_verification_request_test.go
index 598d6d10..b0ce7166 100644
--- a/forms/user_verification_request_test.go
+++ b/forms/record_verification_request_test.go
@@ -5,86 +5,20 @@ import (
"testing"
"time"
- validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
-func TestUserVerificationRequestPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserVerificationRequest(nil)
-}
-
-func TestUserVerificationRequestValidate(t *testing.T) {
+func TestRecordVerificationRequestSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty data
- {
- `{}`,
- []string{"email"},
- },
- // empty fields
- {
- `{"email":""}`,
- []string{"email"},
- },
- // invalid email format
- {
- `{"email":"invalid"}`,
- []string{"email"},
- },
- // valid email
- {
- `{"email":"new@example.com"}`,
- []string{},
- },
+ authCollection, err := testApp.Dao().FindCollectionByNameOrId("clients")
+ if err != nil {
+ t.Fatal(err)
}
- for i, s := range scenarios {
- form := forms.NewUserVerificationRequest(testApp)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- // parse errors
- result := form.Validate()
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
- }
-}
-
-func TestUserVerificationRequestSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
scenarios := []struct {
jsonData string
expectError bool
@@ -139,7 +73,7 @@ func TestUserVerificationRequestSubmit(t *testing.T) {
for i, s := range scenarios {
testApp.TestMailer.TotalSend = 0 // reset
- form := forms.NewUserVerificationRequest(testApp)
+ form := forms.NewRecordVerificationRequest(testApp, authCollection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
@@ -167,15 +101,15 @@ func TestUserVerificationRequestSubmit(t *testing.T) {
continue
}
- user, err := testApp.Dao().FindUserByEmail(form.Email)
+ user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
if err != nil {
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
continue
}
// check whether LastVerificationSentAt was updated
- if !user.Verified && user.LastVerificationSentAt.Time().Sub(now.Time()) < 0 {
- t.Errorf("(%d) Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt)
+ if !user.Verified() && user.LastVerificationSentAt().Time().Sub(now.Time()) < 0 {
+ t.Errorf("(%d) Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt())
}
}
}
diff --git a/forms/settings_upsert.go b/forms/settings_upsert.go
index 8d5eb562..7ca42f3f 100644
--- a/forms/settings_upsert.go
+++ b/forms/settings_upsert.go
@@ -9,56 +9,36 @@ import (
"github.com/pocketbase/pocketbase/models"
)
-// SettingsUpsert specifies a [core.Settings] upsert (create/update) form.
+// SettingsUpsert is a [core.Settings] upsert (create/update) form.
type SettingsUpsert struct {
*core.Settings
- config SettingsUpsertConfig
-}
-
-// SettingsUpsertConfig is the [SettingsUpsert] factory initializer config.
-//
-// NB! App is required struct member.
-type SettingsUpsertConfig struct {
- App core.App
- Dao *daos.Dao
- LogsDao *daos.Dao
+ app core.App
+ dao *daos.Dao
}
// NewSettingsUpsert creates a new [SettingsUpsert] form with initializer
// config created from the provided [core.App] instance.
//
-// If you want to submit the form as part of another transaction, use
-// [NewSettingsUpsertWithConfig] with explicitly set Dao.
+// If you want to submit the form as part of a transaction,
+// you can change the default Dao via [SetDao()].
func NewSettingsUpsert(app core.App) *SettingsUpsert {
- return NewSettingsUpsertWithConfig(SettingsUpsertConfig{
- App: app,
- })
-}
-
-// NewSettingsUpsertWithConfig creates a new [SettingsUpsert] form
-// with the provided config or panics on invalid configuration.
-func NewSettingsUpsertWithConfig(config SettingsUpsertConfig) *SettingsUpsert {
- form := &SettingsUpsert{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- if form.config.LogsDao == nil {
- form.config.LogsDao = form.config.App.LogsDao()
+ form := &SettingsUpsert{
+ app: app,
+ dao: app.Dao(),
}
// load the application settings into the form
- form.Settings, _ = config.App.Settings().Clone()
+ form.Settings, _ = app.Settings().Clone()
return form
}
+// SetDao replaces the default form Dao instance with the provided one.
+func (form *SettingsUpsert) SetDao(dao *daos.Dao) {
+ form.dao = dao
+}
+
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *SettingsUpsert) Validate() error {
return form.Settings.Validate()
@@ -75,10 +55,10 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
- encryptionKey := os.Getenv(form.config.App.EncryptionEnv())
+ encryptionKey := os.Getenv(form.app.EncryptionEnv())
return runInterceptors(func() error {
- saveErr := form.config.Dao.SaveParam(
+ saveErr := form.dao.SaveParam(
models.ParamAppSettings,
form.Settings,
encryptionKey,
@@ -88,11 +68,11 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
}
// explicitly trigger old logs deletion
- form.config.LogsDao.DeleteOldRequests(
+ form.app.LogsDao().DeleteOldRequests(
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
)
// merge the application settings with the form ones
- return form.config.App.Settings().Merge(form.Settings)
+ return form.app.Settings().Merge(form.Settings)
}, interceptors...)
}
diff --git a/forms/settings_upsert_test.go b/forms/settings_upsert_test.go
index 1d3806a4..494545c0 100644
--- a/forms/settings_upsert_test.go
+++ b/forms/settings_upsert_test.go
@@ -12,16 +12,6 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
-func TestSettingsUpsertPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewSettingsUpsert(nil)
-}
-
func TestNewSettingsUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -38,29 +28,7 @@ func TestNewSettingsUpsert(t *testing.T) {
}
}
-func TestSettingsUpsertValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- form := forms.NewSettingsUpsert(app)
-
- // check if settings validations are triggered
- // (there are already individual tests for each setting)
- form.Meta.AppName = ""
- form.Logs.MaxDays = -10
-
- // parse errors
- err := form.Validate()
- jsonResult, _ := json.Marshal(err)
-
- expected := `{"logs":{"maxDays":"must be no less than 0"},"meta":{"appName":"cannot be blank"}}`
-
- if string(jsonResult) != expected {
- t.Errorf("Expected %v, got %v", expected, string(jsonResult))
- }
-}
-
-func TestSettingsUpsertSubmit(t *testing.T) {
+func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -75,19 +43,19 @@ func TestSettingsUpsertSubmit(t *testing.T) {
{"{}", true, nil},
// failure - invalid data
{
- `{"emailAuth": {"minPasswordLength": 1}, "logs": {"maxDays": -1}}`,
+ `{"meta": {"appName": ""}, "logs": {"maxDays": -1}}`,
false,
- []string{"emailAuth", "logs"},
+ []string{"meta", "logs"},
},
// success - valid data (plain)
{
- `{"emailAuth": {"minPasswordLength": 6}, "logs": {"maxDays": 0}}`,
+ `{"meta": {"appName": "test"}, "logs": {"maxDays": 0}}`,
false,
nil,
},
// success - valid data (encrypt)
{
- `{"emailAuth": {"minPasswordLength": 6}, "logs": {"maxDays": 0}}`,
+ `{"meta": {"appName": "test"}, "logs": {"maxDays": 7}}`,
true,
nil,
},
diff --git a/forms/test_email_send.go b/forms/test_email_send.go
index 61c21175..5dd902e4 100644
--- a/forms/test_email_send.go
+++ b/forms/test_email_send.go
@@ -6,6 +6,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/models/schema"
)
const (
@@ -39,7 +40,7 @@ func (form *TestEmailSend) Validate() error {
validation.Field(
&form.Template,
validation.Required,
- validation.In(templateVerification, templateEmailChange, templatePasswordReset),
+ validation.In(templateVerification, templatePasswordReset, templateEmailChange),
),
)
}
@@ -50,19 +51,26 @@ func (form *TestEmailSend) Submit() error {
return err
}
- // create a test user
- user := &models.User{}
- user.Id = "__pb_test_id__"
- user.Email = form.Email
- user.RefreshTokenKey()
+ // create a test auth record
+ collection := &models.Collection{
+ BaseModel: models.BaseModel{Id: "__pb_test_collection_id__"},
+ Name: "__pb_test_collection_name__",
+ Type: models.CollectionTypeAuth,
+ }
+
+ record := models.NewRecord(collection)
+ record.Id = "__pb_test_id__"
+ record.Set(schema.FieldNameUsername, "pb_test")
+ record.Set(schema.FieldNameEmail, form.Email)
+ record.RefreshTokenKey()
switch form.Template {
case templateVerification:
- return mails.SendUserVerification(form.app, user)
+ return mails.SendRecordVerification(form.app, record)
case templatePasswordReset:
- return mails.SendUserPasswordReset(form.app, user)
+ return mails.SendRecordPasswordReset(form.app, record)
case templateEmailChange:
- return mails.SendUserChangeEmail(form.app, user, form.Email)
+ return mails.SendRecordChangeEmail(form.app, record, form.Email)
}
return nil
diff --git a/forms/test_email_send_test.go b/forms/test_email_send_test.go
index 21995ad0..551d1760 100644
--- a/forms/test_email_send_test.go
+++ b/forms/test_email_send_test.go
@@ -9,10 +9,7 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
-func TestEmailSendValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
+func TestEmailSendValidateAndSubmit(t *testing.T) {
scenarios := []struct {
template string
email string
@@ -27,11 +24,14 @@ func TestEmailSendValidate(t *testing.T) {
}
for i, s := range scenarios {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
form := forms.NewTestEmailSend(app)
form.Email = s.email
form.Template = s.template
- result := form.Validate()
+ result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
@@ -43,52 +43,28 @@ func TestEmailSendValidate(t *testing.T) {
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
+ continue
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
+ continue
}
}
- }
-}
-func TestEmailSendSubmit(t *testing.T) {
- scenarios := []struct {
- template string
- email string
- expectError bool
- }{
- {"", "", true},
- {"invalid", "test@example.com", true},
- {"verification", "invalid", true},
- {"verification", "test@example.com", false},
- {"password-reset", "test@example.com", false},
- {"email-change", "test@example.com", false},
- }
-
- for i, s := range scenarios {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- form := forms.NewTestEmailSend(app)
- form.Email = s.email
- form.Template = s.template
-
- err := form.Submit()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
+ expectedEmails := 1
+ if len(s.expectedErrors) > 0 {
+ expectedEmails = 0
}
- if hasErr {
+ if app.TestMailer.TotalSend != expectedEmails {
+ t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
+ }
+
+ if len(s.expectedErrors) > 0 {
continue
}
- if app.TestMailer.TotalSend != 1 {
- t.Errorf("(%d) Expected one email to be sent, got %d", i, app.TestMailer.TotalSend)
- }
-
expectedContent := "Verify"
if s.template == "password-reset" {
expectedContent = "Reset password"
diff --git a/forms/user_email_change_confirm.go b/forms/user_email_change_confirm.go
deleted file mode 100644
index 3e9db0d5..00000000
--- a/forms/user_email_change_confirm.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-// UserEmailChangeConfirm specifies a user email change confirmation form.
-type UserEmailChangeConfirm struct {
- config UserEmailChangeConfirmConfig
-
- Token string `form:"token" json:"token"`
- Password string `form:"password" json:"password"`
-}
-
-// UserEmailChangeConfirmConfig is the [UserEmailChangeConfirm] factory initializer config.
-//
-// NB! App is required struct member.
-type UserEmailChangeConfirmConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
-// NewUserEmailChangeConfirm creates a new [UserEmailChangeConfirm]
-// form with initializer config created from the provided [core.App] instance.
-//
-// This factory method is used primarily for convenience (and backward compatibility).
-// If you want to submit the form as part of another transaction, use
-// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
-func NewUserEmailChangeConfirm(app core.App) *UserEmailChangeConfirm {
- return NewUserEmailChangeConfirmWithConfig(UserEmailChangeConfirmConfig{
- App: app,
- })
-}
-
-// NewUserEmailChangeConfirmWithConfig creates a new [UserEmailChangeConfirm]
-// form with the provided config or panics on invalid configuration.
-func NewUserEmailChangeConfirmWithConfig(config UserEmailChangeConfirmConfig) *UserEmailChangeConfirm {
- form := &UserEmailChangeConfirm{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *UserEmailChangeConfirm) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Token,
- validation.Required,
- validation.By(form.checkToken),
- ),
- validation.Field(
- &form.Password,
- validation.Required,
- validation.Length(1, 100),
- validation.By(form.checkPassword),
- ),
- )
-}
-
-func (form *UserEmailChangeConfirm) checkToken(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- _, _, err := form.parseToken(v)
-
- return err
-}
-
-func (form *UserEmailChangeConfirm) checkPassword(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- user, _, _ := form.parseToken(form.Token)
- if user == nil || !user.ValidatePassword(v) {
- return validation.NewError("validation_invalid_password", "Missing or invalid user password.")
- }
-
- return nil
-}
-
-func (form *UserEmailChangeConfirm) parseToken(token string) (*models.User, string, error) {
- // check token payload
- claims, _ := security.ParseUnverifiedJWT(token)
- newEmail, _ := claims["newEmail"].(string)
- if newEmail == "" {
- return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
- }
-
- // ensure that there aren't other users with the new email
- if !form.config.Dao.IsUserEmailUnique(newEmail, "") {
- return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
- }
-
- // verify that the token is not expired and its signature is valid
- user, err := form.config.Dao.FindUserByToken(
- token,
- form.config.App.Settings().UserEmailChangeToken.Secret,
- )
- if err != nil || user == nil {
- return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
- }
-
- return user, newEmail, nil
-}
-
-// Submit validates and submits the user email change confirmation form.
-// On success returns the updated user model associated to `form.Token`.
-func (form *UserEmailChangeConfirm) Submit() (*models.User, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- user, newEmail, err := form.parseToken(form.Token)
- if err != nil {
- return nil, err
- }
-
- user.Email = newEmail
- user.Verified = true
- user.RefreshTokenKey() // invalidate old tokens
-
- if err := form.config.Dao.SaveUser(user); err != nil {
- return nil, err
- }
-
- return user, nil
-}
diff --git a/forms/user_email_change_confirm_test.go b/forms/user_email_change_confirm_test.go
deleted file mode 100644
index d68f9ca7..00000000
--- a/forms/user_email_change_confirm_test.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestUserEmailChangeConfirmPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserEmailChangeConfirm(nil)
-}
-
-func TestUserEmailChangeConfirmValidateAndSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty payload
- {"{}", []string{"token", "password"}},
- // empty data
- {
- `{"token": "", "password": ""}`,
- []string{"token", "password"},
- },
- // invalid token payload
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTE2NDYxfQ.VjT3wc3IES--1Vye-1KRuk8RpO5mfdhVp2aKGbNluZ0",
- "password": "123456"
- }`,
- []string{"token", "password"},
- },
- // expired token
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.oPxbpJjcBpdZVBFbIW35FEXTCMkzJ7-RmQdHrz7zP3s",
- "password": "123456"
- }`,
- []string{"token", "password"},
- },
- // existing new email
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.RwHRZma5YpCwxHdj3y2obeBNy_GQrG6lT9CQHIUz6Ys",
- "password": "123456"
- }`,
- []string{"token", "password"},
- },
- // wrong confirmation password
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.nS2qDonX25tOf9-6bKCwJXOm1CE88z_EVAA2B72NYM0",
- "password": "1234"
- }`,
- []string{"password"},
- },
- // valid data
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.nS2qDonX25tOf9-6bKCwJXOm1CE88z_EVAA2B72NYM0",
- "password": "123456"
- }`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewUserEmailChangeConfirm(app)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- user, err := form.Submit()
-
- // parse errors
- errs, ok := err.(validation.Errors)
- if !ok && err != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, err)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
-
- if len(s.expectedErrors) > 0 {
- continue
- }
-
- claims, _ := security.ParseUnverifiedJWT(form.Token)
- newEmail, _ := claims["newEmail"].(string)
-
- // check whether the user was updated
- // ---
- if user.Email != newEmail {
- t.Errorf("(%d) Expected user email %q, got %q", i, newEmail, user.Email)
- }
-
- if !user.Verified {
- t.Errorf("(%d) Expected user to be verified, got false", i)
- }
-
- // shouldn't validate second time due to refreshed user token
- if err := form.Validate(); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- }
-}
diff --git a/forms/user_email_change_request.go b/forms/user_email_change_request.go
deleted file mode 100644
index 48823992..00000000
--- a/forms/user_email_change_request.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// UserEmailChangeRequest defines a user email change request form.
-type UserEmailChangeRequest struct {
- config UserEmailChangeRequestConfig
- user *models.User
-
- NewEmail string `form:"newEmail" json:"newEmail"`
-}
-
-// UserEmailChangeRequestConfig is the [UserEmailChangeRequest] factory initializer config.
-//
-// NB! App is required struct member.
-type UserEmailChangeRequestConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
-// NewUserEmailChangeRequest creates a new [UserEmailChangeRequest]
-// form with initializer config created from the provided [core.App] instance.
-//
-// If you want to submit the form as part of another transaction, use
-// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
-func NewUserEmailChangeRequest(app core.App, user *models.User) *UserEmailChangeRequest {
- return NewUserEmailChangeRequestWithConfig(UserEmailChangeRequestConfig{
- App: app,
- }, user)
-}
-
-// NewUserEmailChangeRequestWithConfig creates a new [UserEmailChangeRequest]
-// form with the provided config or panics on invalid configuration.
-func NewUserEmailChangeRequestWithConfig(config UserEmailChangeRequestConfig, user *models.User) *UserEmailChangeRequest {
- form := &UserEmailChangeRequest{
- config: config,
- user: user,
- }
-
- if form.config.App == nil || form.user == nil {
- panic("Invalid initializer config or nil user model.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *UserEmailChangeRequest) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.NewEmail,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- validation.By(form.checkUniqueEmail),
- ),
- )
-}
-
-func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error {
- v, _ := value.(string)
-
- if !form.config.Dao.IsUserEmailUnique(v, "") {
- return validation.NewError("validation_user_email_exists", "User email already exists.")
- }
-
- return nil
-}
-
-// Submit validates and sends the change email request.
-func (form *UserEmailChangeRequest) Submit() error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- return mails.SendUserChangeEmail(form.config.App, form.user, form.NewEmail)
-}
diff --git a/forms/user_email_login.go b/forms/user_email_login.go
deleted file mode 100644
index d3946add..00000000
--- a/forms/user_email_login.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// UserEmailLogin specifies a user email/pass login form.
-type UserEmailLogin struct {
- config UserEmailLoginConfig
-
- Email string `form:"email" json:"email"`
- Password string `form:"password" json:"password"`
-}
-
-// UserEmailLoginConfig is the [UserEmailLogin] factory initializer config.
-//
-// NB! App is required struct member.
-type UserEmailLoginConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
-// NewUserEmailLogin creates a new [UserEmailLogin] form with
-// initializer config created from the provided [core.App] instance.
-//
-// This factory method is used primarily for convenience (and backward compatibility).
-// If you want to submit the form as part of another transaction, use
-// [NewUserEmailLoginWithConfig] with explicitly set Dao.
-func NewUserEmailLogin(app core.App) *UserEmailLogin {
- return NewUserEmailLoginWithConfig(UserEmailLoginConfig{
- App: app,
- })
-}
-
-// NewUserEmailLoginWithConfig creates a new [UserEmailLogin]
-// form with the provided config or panics on invalid configuration.
-func NewUserEmailLoginWithConfig(config UserEmailLoginConfig) *UserEmailLogin {
- form := &UserEmailLogin{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *UserEmailLogin) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.EmailFormat),
- validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
- )
-}
-
-// Submit validates and submits the form.
-// On success returns the authorized user model.
-func (form *UserEmailLogin) Submit() (*models.User, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- user, err := form.config.Dao.FindUserByEmail(form.Email)
- if err != nil {
- return nil, err
- }
-
- if !user.ValidatePassword(form.Password) {
- return nil, validation.NewError("invalid_login", "Invalid login credentials.")
- }
-
- return user, nil
-}
diff --git a/forms/user_email_login_test.go b/forms/user_email_login_test.go
deleted file mode 100644
index 1049f12a..00000000
--- a/forms/user_email_login_test.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestUserEmailLoginPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserEmailLogin(nil)
-}
-
-func TestUserEmailLoginValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty payload
- {"{}", []string{"email", "password"}},
- // empty data
- {
- `{"email": "","password": ""}`,
- []string{"email", "password"},
- },
- // invalid email
- {
- `{"email": "invalid","password": "123"}`,
- []string{"email"},
- },
- // valid email
- {
- `{"email": "test@example.com","password": "123"}`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewUserEmailLogin(app)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- err := form.Validate()
-
- // parse errors
- errs, ok := err.(validation.Errors)
- if !ok && err != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, err)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
- }
-}
-
-func TestUserEmailLoginSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- email string
- password string
- expectError bool
- }{
- // invalid email
- {"invalid", "123456", true},
- // missing user
- {"missing@example.com", "123456", true},
- // invalid password
- {"test@example.com", "123", true},
- // valid email and password
- {"test@example.com", "123456", false},
- }
-
- for i, s := range scenarios {
- form := forms.NewUserEmailLogin(app)
- form.Email = s.email
- form.Password = s.password
-
- user, err := form.Submit()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- continue
- }
-
- if !s.expectError && user.Email != s.email {
- t.Errorf("(%d) Expected user with email %q, got %q", i, s.email, user.Email)
- }
- }
-}
diff --git a/forms/user_oauth2_login.go b/forms/user_oauth2_login.go
deleted file mode 100644
index b337ca5e..00000000
--- a/forms/user_oauth2_login.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package forms
-
-import (
- "errors"
- "fmt"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/auth"
- "github.com/pocketbase/pocketbase/tools/security"
- "golang.org/x/oauth2"
-)
-
-// UserOauth2Login specifies a user Oauth2 login form.
-type UserOauth2Login struct {
- config UserOauth2LoginConfig
-
- // The name of the OAuth2 client provider (eg. "google")
- Provider string `form:"provider" json:"provider"`
-
- // The authorization code returned from the initial request.
- Code string `form:"code" json:"code"`
-
- // The code verifier sent with the initial request as part of the code_challenge.
- CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
-
- // The redirect url sent with the initial request.
- RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
-}
-
-// UserOauth2LoginConfig is the [UserOauth2Login] factory initializer config.
-//
-// NB! App is required struct member.
-type UserOauth2LoginConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
-// NewUserOauth2Login creates a new [UserOauth2Login] form with
-// initializer config created from the provided [core.App] instance.
-//
-// If you want to submit the form as part of another transaction, use
-// [NewUserOauth2LoginWithConfig] with explicitly set Dao.
-func NewUserOauth2Login(app core.App) *UserOauth2Login {
- return NewUserOauth2LoginWithConfig(UserOauth2LoginConfig{
- App: app,
- })
-}
-
-// NewUserOauth2LoginWithConfig creates a new [UserOauth2Login]
-// form with the provided config or panics on invalid configuration.
-func NewUserOauth2LoginWithConfig(config UserOauth2LoginConfig) *UserOauth2Login {
- form := &UserOauth2Login{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *UserOauth2Login) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
- validation.Field(&form.Code, validation.Required),
- validation.Field(&form.CodeVerifier, validation.Required),
- validation.Field(&form.RedirectUrl, validation.Required, is.URL),
- )
-}
-
-func (form *UserOauth2Login) checkProviderName(value any) error {
- name, _ := value.(string)
-
- config, ok := form.config.App.Settings().NamedAuthProviderConfigs()[name]
- if !ok || !config.Enabled {
- return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
- }
-
- return nil
-}
-
-// Submit validates and submits the form.
-// On success returns the authorized user model and the fetched provider's data.
-func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
- if err := form.Validate(); err != nil {
- return nil, nil, err
- }
-
- provider, err := auth.NewProviderByName(form.Provider)
- if err != nil {
- return nil, nil, err
- }
-
- // load provider configuration
- config := form.config.App.Settings().NamedAuthProviderConfigs()[form.Provider]
- if err := config.SetupProvider(provider); err != nil {
- return nil, nil, err
- }
-
- provider.SetRedirectUrl(form.RedirectUrl)
-
- // fetch token
- token, err := provider.FetchToken(
- form.Code,
- oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
- )
- if err != nil {
- return nil, nil, err
- }
-
- // fetch external auth user
- authData, err := provider.FetchAuthUser(token)
- if err != nil {
- return nil, nil, err
- }
-
- var user *models.User
-
- // check for existing relation with the external auth user
- rel, _ := form.config.Dao.FindExternalAuthByProvider(form.Provider, authData.Id)
- if rel != nil {
- user, err = form.config.Dao.FindUserById(rel.UserId)
- if err != nil {
- return nil, authData, err
- }
- } else if authData.Email != "" {
- // look for an existing user by the external user's email
- user, _ = form.config.Dao.FindUserByEmail(authData.Email)
- }
-
- if user == nil && !config.AllowRegistrations {
- return nil, authData, errors.New("New users registration is not allowed for the authorized provider.")
- }
-
- saveErr := form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
- if user == nil {
- user = &models.User{}
- user.Verified = true
- user.Email = authData.Email
- user.SetPassword(security.RandomString(30))
-
- // create the new user
- if err := txDao.SaveUser(user); err != nil {
- return err
- }
- } else {
- // update the existing user empty email if the authData has one
- // (this in case previously the user was created with
- // an OAuth2 provider that didn't return an email address)
- if user.Email == "" && authData.Email != "" {
- user.Email = authData.Email
- if err := txDao.SaveUser(user); err != nil {
- return err
- }
- }
-
- // update the existing user verified state
- // (only if the user doesn't have an email or the user email match with the one in authData)
- if !user.Verified && (user.Email == "" || user.Email == authData.Email) {
- user.Verified = true
- if err := txDao.SaveUser(user); err != nil {
- return err
- }
- }
- }
-
- // create ExternalAuth relation if missing
- if rel == nil {
- rel = &models.ExternalAuth{
- UserId: user.Id,
- Provider: form.Provider,
- ProviderId: authData.Id,
- }
- if err := txDao.SaveExternalAuth(rel); err != nil {
- return err
- }
- }
-
- return nil
- })
-
- if saveErr != nil {
- return nil, authData, saveErr
- }
-
- return user, authData, nil
-}
diff --git a/forms/user_password_reset_confirm.go b/forms/user_password_reset_confirm.go
deleted file mode 100644
index ab9680e0..00000000
--- a/forms/user_password_reset_confirm.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// UserPasswordResetConfirm specifies a user password reset confirmation form.
-type UserPasswordResetConfirm struct {
- config UserPasswordResetConfirmConfig
-
- Token string `form:"token" json:"token"`
- Password string `form:"password" json:"password"`
- PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
-}
-
-// UserPasswordResetConfirmConfig is the [UserPasswordResetConfirm]
-// factory initializer config.
-//
-// NB! App is required struct member.
-type UserPasswordResetConfirmConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
-// NewUserPasswordResetConfirm creates a new [UserPasswordResetConfirm]
-// form with initializer config created from the provided [core.App] instance.
-//
-// If you want to submit the form as part of another transaction, use
-// [NewUserPasswordResetConfirmWithConfig] with explicitly set Dao.
-func NewUserPasswordResetConfirm(app core.App) *UserPasswordResetConfirm {
- return NewUserPasswordResetConfirmWithConfig(UserPasswordResetConfirmConfig{
- App: app,
- })
-}
-
-// NewUserPasswordResetConfirmWithConfig creates a new [UserPasswordResetConfirm]
-// form with the provided config or panics on invalid configuration.
-func NewUserPasswordResetConfirmWithConfig(config UserPasswordResetConfirmConfig) *UserPasswordResetConfirm {
- form := &UserPasswordResetConfirm{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *UserPasswordResetConfirm) Validate() error {
- minPasswordLength := form.config.App.Settings().EmailAuth.MinPasswordLength
-
- return validation.ValidateStruct(form,
- validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
- validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)),
- validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
- )
-}
-
-func (form *UserPasswordResetConfirm) checkToken(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- user, err := form.config.Dao.FindUserByToken(
- v,
- form.config.App.Settings().UserPasswordResetToken.Secret,
- )
- if err != nil || user == nil {
- return validation.NewError("validation_invalid_token", "Invalid or expired token.")
- }
-
- return nil
-}
-
-// Submit validates and submits the form.
-// On success returns the updated user model associated to `form.Token`.
-func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- user, err := form.config.Dao.FindUserByToken(
- form.Token,
- form.config.App.Settings().UserPasswordResetToken.Secret,
- )
- if err != nil {
- return nil, err
- }
-
- if err := user.SetPassword(form.Password); err != nil {
- return nil, err
- }
-
- if err := form.config.Dao.SaveUser(user); err != nil {
- return nil, err
- }
-
- return user, nil
-}
diff --git a/forms/user_password_reset_confirm_test.go b/forms/user_password_reset_confirm_test.go
deleted file mode 100644
index f9437a3a..00000000
--- a/forms/user_password_reset_confirm_test.go
+++ /dev/null
@@ -1,175 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestUserPasswordResetConfirmPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserPasswordResetConfirm(nil)
-}
-
-func TestUserPasswordResetConfirmValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty data
- {
- `{}`,
- []string{"token", "password", "passwordConfirm"},
- },
- // empty fields
- {
- `{"token":"","password":"","passwordConfirm":""}`,
- []string{"token", "password", "passwordConfirm"},
- },
- // invalid password length
- {
- `{"token":"invalid","password":"1234","passwordConfirm":"1234"}`,
- []string{"token", "password"},
- },
- // mismatched passwords
- {
- `{"token":"invalid","password":"12345678","passwordConfirm":"87654321"}`,
- []string{"token", "passwordConfirm"},
- },
- // invalid JWT token
- {
- `{"token":"invalid","password":"12345678","passwordConfirm":"12345678"}`,
- []string{"token"},
- },
- // expired token
- {
- `{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.cSUFKWLAKEvulWV4fqPD6RRtkZYoyat_Tb8lrA2xqtw",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`,
- []string{"token"},
- },
- // valid data
- {
- `{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDYxfQ.YfpL4VOdsYh2gS30VIiPShgwwqPgt2CySD8TuuB1XD4",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewUserPasswordResetConfirm(app)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- // parse errors
- result := form.Validate()
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
- }
-}
-
-func TestUserPasswordResetConfirmSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- jsonData string
- expectError bool
- }{
- // empty data (Validate call check)
- {
- `{}`,
- true,
- },
- // expired token
- {
- `{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.cSUFKWLAKEvulWV4fqPD6RRtkZYoyat_Tb8lrA2xqtw",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`,
- true,
- },
- // valid data
- {
- `{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDYxfQ.YfpL4VOdsYh2gS30VIiPShgwwqPgt2CySD8TuuB1XD4",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`,
- false,
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewUserPasswordResetConfirm(app)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- user, err := form.Submit()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- if s.expectError {
- continue
- }
-
- claims, _ := security.ParseUnverifiedJWT(form.Token)
- tokenUserId := claims["id"]
-
- if user.Id != tokenUserId {
- t.Errorf("(%d) Expected user with id %s, got %v", i, tokenUserId, user)
- }
-
- if !user.LastResetSentAt.IsZero() {
- t.Errorf("(%d) Expected user.LastResetSentAt to be empty, got %v", i, user.LastResetSentAt)
- }
-
- if !user.ValidatePassword(form.Password) {
- t.Errorf("(%d) Expected the user password to have been updated to %q", i, form.Password)
- }
- }
-}
diff --git a/forms/user_password_reset_request.go b/forms/user_password_reset_request.go
deleted file mode 100644
index b5c6e491..00000000
--- a/forms/user_password_reset_request.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package forms
-
-import (
- "errors"
- "time"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// UserPasswordResetRequest specifies a user password reset request form.
-type UserPasswordResetRequest struct {
- config UserPasswordResetRequestConfig
-
- Email string `form:"email" json:"email"`
-}
-
-// UserPasswordResetRequestConfig is the [UserPasswordResetRequest]
-// factory initializer config.
-//
-// NB! App is required struct member.
-type UserPasswordResetRequestConfig struct {
- App core.App
- Dao *daos.Dao
- ResendThreshold float64 // in seconds
-}
-
-// NewUserPasswordResetRequest creates a new [UserPasswordResetRequest]
-// form with initializer config created from the provided [core.App] instance.
-//
-// If you want to submit the form as part of another transaction, use
-// [NewUserPasswordResetRequestWithConfig] with explicitly set Dao.
-func NewUserPasswordResetRequest(app core.App) *UserPasswordResetRequest {
- return NewUserPasswordResetRequestWithConfig(UserPasswordResetRequestConfig{
- App: app,
- ResendThreshold: 120, // 2 min
- })
-}
-
-// NewUserPasswordResetRequestWithConfig creates a new [UserPasswordResetRequest]
-// form with the provided config or panics on invalid configuration.
-func NewUserPasswordResetRequestWithConfig(config UserPasswordResetRequestConfig) *UserPasswordResetRequest {
- form := &UserPasswordResetRequest{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-//
-// This method doesn't checks whether user with `form.Email` exists (this is done on Submit).
-func (form *UserPasswordResetRequest) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Email,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- ),
- )
-}
-
-// Submit validates and submits the form.
-// On success sends a password reset email to the `form.Email` user.
-func (form *UserPasswordResetRequest) Submit() error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- user, err := form.config.Dao.FindUserByEmail(form.Email)
- if err != nil {
- return err
- }
-
- now := time.Now().UTC()
- lastResetSentAt := user.LastResetSentAt.Time()
- if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
- return errors.New("You've already requested a password reset.")
- }
-
- if err := mails.SendUserPasswordReset(form.config.App, user); err != nil {
- return err
- }
-
- // update last sent timestamp
- user.LastResetSentAt = types.NowDateTime()
-
- return form.config.Dao.SaveUser(user)
-}
diff --git a/forms/user_upsert.go b/forms/user_upsert.go
deleted file mode 100644
index f6bf5c48..00000000
--- a/forms/user_upsert.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package forms
-
-import (
- "strings"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// UserUpsert specifies a [models.User] upsert (create/update) form.
-type UserUpsert struct {
- config UserUpsertConfig
- user *models.User
-
- Id string `form:"id" json:"id"`
- Email string `form:"email" json:"email"`
- Password string `form:"password" json:"password"`
- PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
-}
-
-// UserUpsertConfig is the [UserUpsert] factory initializer config.
-//
-// NB! App is required struct member.
-type UserUpsertConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
-// NewUserUpsert creates a new [UserUpsert] form with initializer
-// config created from the provided [core.App] instance
-// (for create you could pass a pointer to an empty User - `&models.User{}`).
-//
-// If you want to submit the form as part of another transaction, use
-// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
-func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
- return NewUserUpsertWithConfig(UserUpsertConfig{
- App: app,
- }, user)
-}
-
-// NewUserUpsertWithConfig creates a new [UserUpsert] form with the provided
-// config and [models.User] instance or panics on invalid configuration
-// (for create you could pass a pointer to an empty User - `&models.User{}`).
-func NewUserUpsertWithConfig(config UserUpsertConfig, user *models.User) *UserUpsert {
- form := &UserUpsert{
- config: config,
- user: user,
- }
-
- if form.config.App == nil || form.user == nil {
- panic("Invalid initializer config or nil upsert model.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- // load defaults
- form.Id = user.Id
- form.Email = user.Email
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *UserUpsert) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Id,
- validation.When(
- form.user.IsNew(),
- validation.Length(models.DefaultIdLength, models.DefaultIdLength),
- validation.Match(idRegex),
- ).Else(validation.In(form.user.Id)),
- ),
- validation.Field(
- &form.Email,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- validation.By(form.checkEmailDomain),
- validation.By(form.checkUniqueEmail),
- ),
- validation.Field(
- &form.Password,
- validation.When(form.user.IsNew(), validation.Required),
- validation.Length(form.config.App.Settings().EmailAuth.MinPasswordLength, 100),
- ),
- validation.Field(
- &form.PasswordConfirm,
- validation.When(form.user.IsNew() || form.Password != "", validation.Required),
- validation.By(validators.Compare(form.Password)),
- ),
- )
-}
-
-func (form *UserUpsert) checkUniqueEmail(value any) error {
- v, _ := value.(string)
-
- if v == "" || form.config.Dao.IsUserEmailUnique(v, form.user.Id) {
- return nil
- }
-
- return validation.NewError("validation_user_email_exists", "User email already exists.")
-}
-
-func (form *UserUpsert) checkEmailDomain(value any) error {
- val, _ := value.(string)
- if val == "" {
- return nil // nothing to check
- }
-
- domain := val[strings.LastIndex(val, "@")+1:]
- only := form.config.App.Settings().EmailAuth.OnlyDomains
- except := form.config.App.Settings().EmailAuth.ExceptDomains
-
- // only domains check
- if len(only) > 0 && !list.ExistInSlice(domain, only) {
- return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
- }
-
- // except domains check
- if len(except) > 0 && list.ExistInSlice(domain, except) {
- return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
- }
-
- return nil
-}
-
-// Submit validates the form and upserts the form user model.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- if form.Password != "" {
- form.user.SetPassword(form.Password)
- }
-
- // custom insertion id can be set only on create
- if form.user.IsNew() && form.Id != "" {
- form.user.MarkAsNew()
- form.user.SetId(form.Id)
- }
-
- if !form.user.IsNew() && form.Email != form.user.Email {
- form.user.Verified = false
- form.user.LastVerificationSentAt = types.DateTime{} // reset
- }
-
- form.user.Email = form.Email
-
- return runInterceptors(func() error {
- return form.config.Dao.SaveUser(form.user)
- }, interceptors...)
-}
diff --git a/forms/user_upsert_test.go b/forms/user_upsert_test.go
deleted file mode 100644
index e1ae1487..00000000
--- a/forms/user_upsert_test.go
+++ /dev/null
@@ -1,432 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestUserUpsertPanic1(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserUpsert(nil, nil)
-}
-
-func TestUserUpsertPanic2(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserUpsert(app, nil)
-}
-
-func TestNewUserUpsert(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- user := &models.User{}
- user.Email = "new@example.com"
-
- form := forms.NewUserUpsert(app, user)
-
- // check defaults loading
- if form.Email != user.Email {
- t.Fatalf("Expected email %q, got %q", user.Email, form.Email)
- }
-}
-
-func TestUserUpsertValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // mock app constraints
- app.Settings().EmailAuth.MinPasswordLength = 5
- app.Settings().EmailAuth.ExceptDomains = []string{"test.com"}
- app.Settings().EmailAuth.OnlyDomains = []string{"example.com", "test.com"}
-
- scenarios := []struct {
- id string
- jsonData string
- expectedErrors []string
- }{
- // empty data - create
- {
- "",
- `{}`,
- []string{"email", "password", "passwordConfirm"},
- },
- // empty data - update
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{}`,
- []string{},
- },
- // invalid email address
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"email":"invalid"}`,
- []string{"email"},
- },
- // unique email constraint check (same email, aka. no changes)
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"email":"test@example.com"}`,
- []string{},
- },
- // unique email constraint check (existing email)
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"email":"test2@something.com"}`,
- []string{"email"},
- },
- // unique email constraint check (new email)
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"email":"new@example.com"}`,
- []string{},
- },
- // EmailAuth.OnlyDomains constraints check
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"email":"test@something.com"}`,
- []string{"email"},
- },
- // EmailAuth.ExceptDomains constraints check
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"email":"test@test.com"}`,
- []string{"email"},
- },
- // password length constraint check
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"password":"1234", "passwordConfirm": "1234"}`,
- []string{"password"},
- },
- // passwords mismatch
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"password":"12345", "passwordConfirm": "54321"}`,
- []string{"passwordConfirm"},
- },
- // valid data - all fields
- {
- "",
- `{"email":"new@example.com","password":"12345","passwordConfirm":"12345"}`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- user := &models.User{}
- if s.id != "" {
- user, _ = app.Dao().FindUserById(s.id)
- }
-
- form := forms.NewUserUpsert(app, user)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- // parse errors
- result := form.Validate()
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
- }
-}
-
-func TestUserUpsertSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- id string
- jsonData string
- expectError bool
- }{
- // empty fields - create (Validate call check)
- {
- "",
- `{}`,
- true,
- },
- // empty fields - update (Validate call check)
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{}`,
- false,
- },
- // updating with existing user email
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"email":"test2@example.com"}`,
- true,
- },
- // updating with nonexisting user email
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"email":"update_new@example.com"}`,
- false,
- },
- // changing password
- {
- "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- `{"password":"123456789","passwordConfirm":"123456789"}`,
- false,
- },
- // creating user (existing email)
- {
- "",
- `{"email":"test3@example.com","password":"123456789","passwordConfirm":"123456789"}`,
- true,
- },
- // creating user (new email)
- {
- "",
- `{"email":"create_new@example.com","password":"123456789","passwordConfirm":"123456789"}`,
- false,
- },
- }
-
- for i, s := range scenarios {
- user := &models.User{}
- originalUser := &models.User{}
- if s.id != "" {
- user, _ = app.Dao().FindUserById(s.id)
- originalUser, _ = app.Dao().FindUserById(s.id)
- }
-
- form := forms.NewUserUpsert(app, user)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
-
- err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
- return func() error {
- interceptorCalls++
- return next()
- }
- })
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- expectInterceptorCall := 1
- if s.expectError {
- expectInterceptorCall = 0
- }
- if interceptorCalls != expectInterceptorCall {
- t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
- }
-
- if s.expectError {
- continue
- }
-
- if user.Email != form.Email {
- t.Errorf("(%d) Expected email %q, got %q", i, form.Email, user.Email)
- }
-
- // on email change Verified should reset
- if user.Email != originalUser.Email && user.Verified {
- t.Errorf("(%d) Expected Verified to be false, got true", i)
- }
-
- if form.Password != "" && !user.ValidatePassword(form.Password) {
- t.Errorf("(%d) Expected password to be updated to %q", i, form.Password)
- }
- if form.Password != "" && originalUser.TokenKey == user.TokenKey {
- t.Errorf("(%d) Expected TokenKey to change, got %q", i, user.TokenKey)
- }
- }
-}
-
-func TestUserUpsertSubmitInterceptors(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- user := &models.User{}
- form := forms.NewUserUpsert(app, user)
- form.Email = "test_new@example.com"
- form.Password = "1234567890"
- form.PasswordConfirm = form.Password
-
- testErr := errors.New("test_error")
- interceptorUserEmail := ""
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
- return func() error {
- interceptor1Called = true
- return next()
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
- return func() error {
- interceptorUserEmail = user.Email // to check if the record was filled
- interceptor2Called = true
- return testErr
- }
- }
-
- err := form.Submit(interceptor1, interceptor2)
- if err != testErr {
- t.Fatalf("Expected error %v, got %v", testErr, err)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorUserEmail != form.Email {
- t.Fatalf("Expected the form model to be filled before calling the interceptors")
- }
-}
-
-func TestUserUpsertWithCustomId(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- existingUser, err := app.Dao().FindUserByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- name string
- jsonData string
- collection *models.User
- expectError bool
- }{
- {
- "empty data",
- "{}",
- &models.User{},
- false,
- },
- {
- "empty id",
- `{"id":""}`,
- &models.User{},
- false,
- },
- {
- "id < 15 chars",
- `{"id":"a23"}`,
- &models.User{},
- true,
- },
- {
- "id > 15 chars",
- `{"id":"a234567890123456"}`,
- &models.User{},
- true,
- },
- {
- "id = 15 chars (invalid chars)",
- `{"id":"a@3456789012345"}`,
- &models.User{},
- true,
- },
- {
- "id = 15 chars (valid chars)",
- `{"id":"a23456789012345"}`,
- &models.User{},
- false,
- },
- {
- "changing the id of an existing item",
- `{"id":"b23456789012345"}`,
- existingUser,
- true,
- },
- {
- "using the same existing item id",
- `{"id":"` + existingUser.Id + `"}`,
- existingUser,
- false,
- },
- {
- "skipping the id for existing item",
- `{}`,
- existingUser,
- false,
- },
- }
-
- for i, scenario := range scenarios {
- form := forms.NewUserUpsert(app, scenario.collection)
- if form.Email == "" {
- form.Email = fmt.Sprintf("test_id_%d@example.com", i)
- }
- form.Password = "1234567890"
- form.PasswordConfirm = form.Password
-
- // load data
- loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
- if loadErr != nil {
- t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
- continue
- }
-
- submitErr := form.Submit()
- hasErr := submitErr != nil
-
- if hasErr != scenario.expectError {
- t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
- }
-
- if !hasErr && form.Id != "" {
- _, err := app.Dao().FindUserById(form.Id)
- if err != nil {
- t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
- }
- }
- }
-}
diff --git a/forms/user_verification_confirm.go b/forms/user_verification_confirm.go
deleted file mode 100644
index ff0dcb92..00000000
--- a/forms/user_verification_confirm.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// UserVerificationConfirm specifies a user email verification confirmation form.
-type UserVerificationConfirm struct {
- config UserVerificationConfirmConfig
-
- Token string `form:"token" json:"token"`
-}
-
-// UserVerificationConfirmConfig is the [UserVerificationConfirm]
-// factory initializer config.
-//
-// NB! App is required struct member.
-type UserVerificationConfirmConfig struct {
- App core.App
- Dao *daos.Dao
-}
-
-// NewUserVerificationConfirm creates a new [UserVerificationConfirm]
-// form with initializer config created from the provided [core.App] instance.
-//
-// If you want to submit the form as part of another transaction, use
-// [NewUserVerificationConfirmWithConfig] with explicitly set Dao.
-func NewUserVerificationConfirm(app core.App) *UserVerificationConfirm {
- return NewUserVerificationConfirmWithConfig(UserVerificationConfirmConfig{
- App: app,
- })
-}
-
-// NewUserVerificationConfirmWithConfig creates a new [UserVerificationConfirmConfig]
-// form with the provided config or panics on invalid configuration.
-func NewUserVerificationConfirmWithConfig(config UserVerificationConfirmConfig) *UserVerificationConfirm {
- form := &UserVerificationConfirm{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *UserVerificationConfirm) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
- )
-}
-
-func (form *UserVerificationConfirm) checkToken(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- user, err := form.config.Dao.FindUserByToken(
- v,
- form.config.App.Settings().UserVerificationToken.Secret,
- )
- if err != nil || user == nil {
- return validation.NewError("validation_invalid_token", "Invalid or expired token.")
- }
-
- return nil
-}
-
-// Submit validates and submits the form.
-// On success returns the verified user model associated to `form.Token`.
-func (form *UserVerificationConfirm) Submit() (*models.User, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- user, err := form.config.Dao.FindUserByToken(
- form.Token,
- form.config.App.Settings().UserVerificationToken.Secret,
- )
- if err != nil {
- return nil, err
- }
-
- if user.Verified {
- return user, nil // already verified
- }
-
- user.Verified = true
-
- if err := form.config.Dao.SaveUser(user); err != nil {
- return nil, err
- }
-
- return user, nil
-}
diff --git a/forms/user_verification_confirm_test.go b/forms/user_verification_confirm_test.go
deleted file mode 100644
index cff5c68e..00000000
--- a/forms/user_verification_confirm_test.go
+++ /dev/null
@@ -1,150 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestUserVerificationConfirmPanic(t *testing.T) {
- defer func() {
- if recover() == nil {
- t.Fatal("The form did not panic")
- }
- }()
-
- forms.NewUserVerificationConfirm(nil)
-}
-
-func TestUserVerificationConfirmValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty data
- {
- `{}`,
- []string{"token"},
- },
- // empty fields
- {
- `{"token":""}`,
- []string{"token"},
- },
- // invalid JWT token
- {
- `{"token":"invalid"}`,
- []string{"token"},
- },
- // expired token
- {
- `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.6KBn19eFa9aFAZ6hvuhQtK7Ovxb6QlBQ97vJtulb_P8"}`,
- []string{"token"},
- },
- // valid token
- {
- `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxOTA2MTA2NDIxfQ.yvH96FwtPHGvzhFSKl8Tsi1FnGytKpMrvb7K9F2_zQA"}`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewUserVerificationConfirm(app)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- // parse errors
- result := form.Validate()
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
- }
-}
-
-func TestUserVerificationConfirmSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- jsonData string
- expectError bool
- }{
- // empty data (Validate call check)
- {
- `{}`,
- true,
- },
- // expired token (Validate call check)
- {
- `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.6KBn19eFa9aFAZ6hvuhQtK7Ovxb6QlBQ97vJtulb_P8"}`,
- true,
- },
- // valid token (already verified user)
- {
- `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxOTA2MTA2NDIxfQ.yvH96FwtPHGvzhFSKl8Tsi1FnGytKpMrvb7K9F2_zQA"}`,
- false,
- },
- // valid token (unverified user)
- {
- `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTkwNjEwNjQyMX0.KbSucLGasQqTkGxUgqaaCjKNOHJ3ZVkL1WTzSApc6oM"}`,
- false,
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewUserVerificationConfirm(app)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- user, err := form.Submit()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- if s.expectError {
- continue
- }
-
- claims, _ := security.ParseUnverifiedJWT(form.Token)
- tokenUserId := claims["id"]
-
- if user.Id != tokenUserId {
- t.Errorf("(%d) Expected user.Id %q, got %q", i, tokenUserId, user.Id)
- }
-
- if !user.Verified {
- t.Errorf("(%d) Expected user.Verified to be true, got false", i)
- }
- }
-}
diff --git a/forms/user_verification_request.go b/forms/user_verification_request.go
deleted file mode 100644
index 76a3aaa9..00000000
--- a/forms/user_verification_request.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package forms
-
-import (
- "errors"
- "time"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// UserVerificationRequest defines a user email verification request form.
-type UserVerificationRequest struct {
- config UserVerificationRequestConfig
-
- Email string `form:"email" json:"email"`
-}
-
-// UserVerificationRequestConfig is the [UserVerificationRequest]
-// factory initializer config.
-//
-// NB! App is required struct member.
-type UserVerificationRequestConfig struct {
- App core.App
- Dao *daos.Dao
- ResendThreshold float64 // in seconds
-}
-
-// NewUserVerificationRequest creates a new [UserVerificationRequest]
-// form with initializer config created from the provided [core.App] instance.
-//
-// If you want to submit the form as part of another transaction, use
-// [NewUserVerificationRequestWithConfig] with explicitly set Dao.
-func NewUserVerificationRequest(app core.App) *UserVerificationRequest {
- return NewUserVerificationRequestWithConfig(UserVerificationRequestConfig{
- App: app,
- ResendThreshold: 120, // 2 min
- })
-}
-
-// NewUserVerificationRequestWithConfig creates a new [UserVerificationRequest]
-// form with the provided config or panics on invalid configuration.
-func NewUserVerificationRequestWithConfig(config UserVerificationRequestConfig) *UserVerificationRequest {
- form := &UserVerificationRequest{config: config}
-
- if form.config.App == nil {
- panic("Missing required config.App instance.")
- }
-
- if form.config.Dao == nil {
- form.config.Dao = form.config.App.Dao()
- }
-
- return form
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-//
-// // This method doesn't verify that user with `form.Email` exists (this is done on Submit).
-func (form *UserVerificationRequest) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Email,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- ),
- )
-}
-
-// Submit validates and sends a verification request email
-// to the `form.Email` user.
-func (form *UserVerificationRequest) Submit() error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- user, err := form.config.Dao.FindUserByEmail(form.Email)
- if err != nil {
- return err
- }
-
- if user.Verified {
- return nil // already verified
- }
-
- now := time.Now().UTC()
- lastVerificationSentAt := user.LastVerificationSentAt.Time()
- if (now.Sub(lastVerificationSentAt)).Seconds() < form.config.ResendThreshold {
- return errors.New("A verification email was already sent.")
- }
-
- if err := mails.SendUserVerification(form.config.App, user); err != nil {
- return err
- }
-
- // update last sent timestamp
- user.LastVerificationSentAt = types.NowDateTime()
-
- return form.config.Dao.SaveUser(user)
-}
diff --git a/forms/validators/file.go b/forms/validators/file.go
index 05473198..c1fbdca4 100644
--- a/forms/validators/file.go
+++ b/forms/validators/file.go
@@ -1,7 +1,6 @@
package validators
import (
- "encoding/binary"
"fmt"
"strings"
@@ -22,7 +21,7 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
return nil // nothing to validate
}
- if binary.Size(v.Bytes()) > maxBytes {
+ if int(v.Header().Size) > maxBytes {
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
}
@@ -47,7 +46,16 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
}
- filetype := mimetype.Detect(v.Bytes())
+ f, err := v.Header().Open()
+ if err != nil {
+ return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
+ }
+ defer f.Close()
+
+ filetype, err := mimetype.DetectReader(f)
+ if err != nil {
+ return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
+ }
for _, t := range validTypes {
if filetype.Is(t) {
diff --git a/forms/validators/record_data.go b/forms/validators/record_data.go
index a5f09f95..106b0404 100644
--- a/forms/validators/record_data.go
+++ b/forms/validators/record_data.go
@@ -28,7 +28,7 @@ var requiredErr = validation.NewError("validation_required", "Missing required v
func NewRecordDataValidator(
dao *daos.Dao,
record *models.Record,
- uploadedFiles []*rest.UploadedFile,
+ uploadedFiles map[string][]*rest.UploadedFile,
) *RecordDataValidator {
return &RecordDataValidator{
dao: dao,
@@ -42,7 +42,7 @@ func NewRecordDataValidator(
type RecordDataValidator struct {
dao *daos.Dao
record *models.Record
- uploadedFiles []*rest.UploadedFile
+ uploadedFiles map[string][]*rest.UploadedFile
}
// Validate validates the provided `data` by checking it against
@@ -88,7 +88,7 @@ func (validator *RecordDataValidator) Validate(data map[string]any) error {
// check unique constraint
if field.Unique && !validator.dao.IsRecordValueUnique(
- validator.record.Collection(),
+ validator.record.Collection().Id,
key,
value,
validator.record.GetId(),
@@ -127,8 +127,6 @@ func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField,
return validator.checkFileValue(field, value)
case schema.FieldTypeRelation:
return validator.checkRelationValue(field, value)
- case schema.FieldTypeUser:
- return validator.checkUserValue(field, value)
}
return nil
@@ -316,8 +314,8 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
}
// extract the uploaded files
- files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles))
- for _, file := range validator.uploadedFiles {
+ files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles[field.Name]))
+ for _, file := range validator.uploadedFiles[field.Name] {
if list.ExistInSlice(file.Name(), names) {
files = append(files, file)
}
@@ -351,8 +349,8 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
options, _ := field.Options.(*schema.RelationOptions)
- if len(ids) > options.MaxSelect {
- return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
+ if options.MaxSelect != nil && len(ids) > *options.MaxSelect {
+ return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", *options.MaxSelect))
}
// check if the related records exist
@@ -374,31 +372,3 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
return nil
}
-
-func (validator *RecordDataValidator) checkUserValue(field *schema.SchemaField, value any) error {
- ids := list.ToUniqueStringSlice(value)
- if len(ids) == 0 {
- if field.Required {
- return requiredErr
- }
- return nil // nothing to check
- }
-
- options, _ := field.Options.(*schema.UserOptions)
-
- if len(ids) > options.MaxSelect {
- return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
- }
-
- // check if the related users exist
- var total int
- validator.dao.UserQuery().
- Select("count(*)").
- AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
- Row(&total)
- if total != len(ids) {
- return validation.NewError("validation_missing_users", "Failed to fetch all users with the provided ids")
- }
-
- return nil
-}
diff --git a/forms/validators/record_data_test.go b/forms/validators/record_data_test.go
index 3af5d5f4..a3d552a7 100644
--- a/forms/validators/record_data_test.go
+++ b/forms/validators/record_data_test.go
@@ -20,7 +20,7 @@ import (
type testDataFieldScenario struct {
name string
data map[string]any
- files []*rest.UploadedFile
+ files map[string][]*rest.UploadedFile
expectedErrors []string
}
@@ -28,7 +28,7 @@ func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo")
+ collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
record := models.NewRecord(collection)
validator := validators.NewRecordDataValidator(app.Dao(), record, nil)
@@ -80,9 +80,9 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", "test")
- dummy.SetDataValue("field2", "test")
- dummy.SetDataValue("field3", "test")
+ dummy.Set("field1", "test")
+ dummy.Set("field2", "test")
+ dummy.Set("field3", "test")
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -196,9 +196,9 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", 123)
- dummy.SetDataValue("field2", 123)
- dummy.SetDataValue("field3", 123)
+ dummy.Set("field1", 123)
+ dummy.Set("field2", 123)
+ dummy.Set("field3", 123)
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -307,9 +307,9 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", false)
- dummy.SetDataValue("field2", true)
- dummy.SetDataValue("field3", true)
+ dummy.Set("field1", false)
+ dummy.Set("field2", true)
+ dummy.Set("field3", true)
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -403,9 +403,9 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", "test@demo.com")
- dummy.SetDataValue("field2", "test@test.com")
- dummy.SetDataValue("field3", "test@example.com")
+ dummy.Set("field1", "test@demo.com")
+ dummy.Set("field2", "test@test.com")
+ dummy.Set("field3", "test@example.com")
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -519,9 +519,9 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", "http://demo.com")
- dummy.SetDataValue("field2", "http://test.com")
- dummy.SetDataValue("field3", "http://example.com")
+ dummy.Set("field1", "http://demo.com")
+ dummy.Set("field2", "http://test.com")
+ dummy.Set("field3", "http://example.com")
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -647,9 +647,9 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", "2022-01-01 01:01:01")
- dummy.SetDataValue("field2", "2029-01-01 01:01:01.123")
- dummy.SetDataValue("field3", "2029-01-01 01:01:01.123")
+ dummy.Set("field1", "2022-01-01 01:01:01")
+ dummy.Set("field2", "2029-01-01 01:01:01.123")
+ dummy.Set("field3", "2029-01-01 01:01:01.123")
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -779,9 +779,9 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", "a")
- dummy.SetDataValue("field2", []string{"a", "b"})
- dummy.SetDataValue("field3", []string{"a", "b", "c"})
+ dummy.Set("field1", "a")
+ dummy.Set("field2", []string{"a", "b"})
+ dummy.Set("field3", []string{"a", "b", "c"})
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -909,9 +909,9 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", `{"test":123}`)
- dummy.SetDataValue("field2", `{"test":123}`)
- dummy.SetDataValue("field3", `{"test":123}`)
+ dummy.Set("field1", `{"test":123}`)
+ dummy.Set("field2", `{"test":123}`)
+ dummy.Set("field3", `{"test":123}`)
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -1080,7 +1080,9 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{"test1", testFiles[0].Name(), testFiles[3].Name()},
"field3": []string{"test1", "test2", "test3", "test4"},
},
- []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
+ map[string][]*rest.UploadedFile{
+ "field2": {testFiles[0], testFiles[3]},
+ },
[]string{"field2", "field3"},
},
{
@@ -1090,7 +1092,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{"test1", testFiles[0].Name()},
"field3": []string{"test1", "test2", "test3"},
},
- []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
+ map[string][]*rest.UploadedFile{
+ "field1": {testFiles[0]},
+ "field2": {testFiles[0]},
+ },
[]string{"field1"},
},
{
@@ -1100,7 +1105,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{"test1", testFiles[0].Name()},
"field3": []string{testFiles[1].Name(), testFiles[2].Name()},
},
- []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
+ map[string][]*rest.UploadedFile{
+ "field2": {testFiles[0], testFiles[1], testFiles[2]},
+ "field3": {testFiles[1], testFiles[2]},
+ },
[]string{"field3"},
},
{
@@ -1120,7 +1128,9 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{testFiles[0].Name(), testFiles[1].Name()},
"field3": nil,
},
- []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
+ map[string][]*rest.UploadedFile{
+ "field2": {testFiles[0], testFiles[1]},
+ },
[]string{},
},
{
@@ -1130,7 +1140,9 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{"test1", testFiles[0].Name()},
"field3": "test1", // will be casted
},
- []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
+ map[string][]*rest.UploadedFile{
+ "field2": {testFiles[0], testFiles[1], testFiles[2]},
+ },
[]string{},
},
}
@@ -1142,17 +1154,17 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- demo, _ := app.Dao().FindCollectionByNameOrId("demo4")
+ demo, _ := app.Dao().FindCollectionByNameOrId("demo3")
- // demo4 rel ids
- relId1 := "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"
- relId2 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125"
- relId3 := "b84cd893-7119-43c9-8505-3c4e22da28a9"
- relId4 := "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2"
+ // demo3 rel ids
+ relId1 := "mk5fmymtx4wsprk"
+ relId2 := "7nwo8tuiatetxdm"
+ relId3 := "lcl9d87w22ml6jy"
+ relId4 := "1tmknxy2868d869"
// record rel ids from different collections
- diffRelId1 := "63c2ab80-84ab-4057-a592-4604a731f78f"
- diffRelId2 := "2c542824-9de1-42fe-8924-e57c86267760"
+ diffRelId1 := "0yxhwia2amd8gec"
+ diffRelId2 := "llvuca81nly1qls"
// create new test collection
collection := &models.Collection{}
@@ -1162,7 +1174,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Name: "field1",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
- MaxSelect: 1,
+ MaxSelect: types.Pointer(1),
CollectionId: demo.Id,
},
},
@@ -1171,7 +1183,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Required: true,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
- MaxSelect: 2,
+ MaxSelect: types.Pointer(2),
CollectionId: demo.Id,
},
},
@@ -1180,7 +1192,6 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Unique: true,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
- MaxSelect: 3,
CollectionId: demo.Id,
},
},
@@ -1188,7 +1199,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Name: "field4",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
- MaxSelect: 3,
+ MaxSelect: types.Pointer(3),
CollectionId: "", // missing or non-existing collection id
},
},
@@ -1199,9 +1210,9 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", relId1)
- dummy.SetDataValue("field2", []string{relId1, relId2})
- dummy.SetDataValue("field3", []string{relId1, relId2, relId3})
+ dummy.Set("field1", relId1)
+ dummy.Set("field2", []string{relId1, relId2})
+ dummy.Set("field3", []string{relId1, relId2, relId3})
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -1254,7 +1265,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
"field3": []string{relId1, relId2, relId3, relId4},
},
nil,
- []string{"field2", "field3"},
+ []string{"field2"},
},
{
"check with ids from different collections",
@@ -1289,130 +1300,6 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
}
-func TestRecordDataValidatorValidateUser(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- userId1 := "97cc3d3d-6ba2-383f-b42a-7bc84d27410c"
- userId2 := "7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"
- userId3 := "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"
- missingUserId := "00000000-84ab-4057-a592-4604a731f78f"
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{
- MaxSelect: 1,
- },
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{
- MaxSelect: 2,
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{
- MaxSelect: 3,
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.SetDataValue("field1", userId1)
- dummy.SetDataValue("field2", []string{userId1, userId2})
- dummy.SetDataValue("field3", []string{userId1, userId2, userId3})
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "check required constraint - nil",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "check required constraint - zero id",
- map[string]any{
- "field1": "",
- "field2": "",
- "field3": "",
- },
- nil,
- []string{"field2"},
- },
- {
- "check unique constraint",
- map[string]any{
- "field1": nil,
- "field2": userId1,
- "field3": []string{userId1, userId2, userId3, userId3}, // repeating values are collapsed
- },
- nil,
- []string{"field3"},
- },
- {
- "check MaxSelect constraint",
- map[string]any{
- "field1": []string{userId1, userId2}, // maxSelect is 1 and will be normalized to userId1 only
- "field2": []string{userId1, userId2, userId3},
- "field3": []string{userId1, userId3, userId2},
- },
- nil,
- []string{"field2"},
- },
- {
- "check with mixed existing and nonexisting user ids",
- map[string]any{
- "field1": missingUserId,
- "field2": []string{missingUserId, userId1},
- "field3": []string{userId1, missingUserId},
- },
- nil,
- []string{"field1", "field2", "field3"},
- },
- {
- "valid data - only required fields",
- map[string]any{
- "field2": []string{userId1, userId2},
- },
- nil,
- []string{},
- },
- {
- "valid data - all fields with normalization",
- map[string]any{
- "field1": []string{userId1, userId2},
- "field2": userId2,
- "field3": []string{userId3, userId2, userId1}, // unique is not triggered because the order is different
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) {
for i, s := range scenarios {
validator := validators.NewRecordDataValidator(dao, record, s.files)
diff --git a/go.mod b/go.mod
index dcec85d2..ac8ca6ea 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.6
- github.com/aws/aws-sdk-go v1.44.102
+ github.com/aws/aws-sdk-go v1.44.126
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.3.4
github.com/fatih/color v1.13.0
@@ -13,71 +13,71 @@ require (
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v4 v4.4.2
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
- github.com/mattn/go-sqlite3 v1.14.15
- github.com/pocketbase/dbx v1.6.0
+ github.com/mattn/go-sqlite3 v1.14.16
+ github.com/pocketbase/dbx v1.7.0
github.com/spf13/cast v1.5.0
- github.com/spf13/cobra v1.5.0
- gocloud.dev v0.26.0
- golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0
- golang.org/x/net v0.0.0-20220921155015-db77216a4ee9
- golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1
- modernc.org/sqlite v1.19.1
+ github.com/spf13/cobra v1.6.1
+ gocloud.dev v0.27.0
+ golang.org/x/crypto v0.1.0
+ golang.org/x/net v0.1.0
+ golang.org/x/oauth2 v0.1.0
+ modernc.org/sqlite v1.19.3
)
require (
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
- github.com/aws/aws-sdk-go-v2 v1.16.16 // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.17.7 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.12.20 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.17 // indirect
- github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.33 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.3.24 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.9 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17 // indirect
- github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.11.23 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.5 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.16.19 // indirect
- github.com/aws/smithy-go v1.13.3 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.17.1 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.17.10 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.12.23 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.37 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.29.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.17.1 // indirect
+ github.com/aws/smithy-go v1.13.4 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/wire v0.5.0 // indirect
- github.com/googleapis/gax-go/v2 v2.5.1 // indirect
+ github.com/googleapis/gax-go/v2 v2.6.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // 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/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
- github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasttemplate v1.2.1 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.23.0 // indirect
- golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
- golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
- golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect
- golang.org/x/term v0.0.0-20220919170432-7a66f970e087 // indirect
- golang.org/x/text v0.3.7 // indirect
- golang.org/x/time v0.0.0-20220920022843-2ce7c2934d45 // indirect
- golang.org/x/tools v0.1.12 // indirect
+ golang.org/x/image v0.1.0 // indirect
+ golang.org/x/mod v0.6.0 // indirect
+ golang.org/x/sys v0.1.0 // indirect
+ golang.org/x/term v0.1.0 // indirect
+ golang.org/x/text v0.4.0 // indirect
+ golang.org/x/time v0.1.0 // indirect
+ golang.org/x/tools v0.2.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
- google.golang.org/api v0.96.0 // indirect
+ google.golang.org/api v0.101.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
- google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006 // indirect
- google.golang.org/grpc v1.49.0 // indirect
+ google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c // indirect
+ google.golang.org/grpc v1.50.1 // indirect
google.golang.org/protobuf v1.28.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
- modernc.org/cc/v3 v3.39.0 // indirect
- modernc.org/ccgo/v3 v3.16.9 // indirect
- modernc.org/libc v1.19.0 // indirect
+ modernc.org/cc/v3 v3.40.0 // indirect
+ modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8 // indirect
+ modernc.org/libc v1.21.4 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/opt v0.1.3 // indirect
diff --git a/go.sum b/go.sum
index 38301dab..ab307b5e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=
+bazil.org/fuse v0.0.0-20200407214033-5883e5a4b512/go.mod h1:FbcW6z/2VytnFDhZfumh8Ss8zxHE6qpMP5sHTRe0EaM=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -29,8 +31,10 @@ cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Ud
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
-cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8=
cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
+cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU=
+cloud.google.com/go v0.103.0/go.mod h1:vwLx1nqLrzLX/fpwSMOXmFIqBOyHsvHbnAdbGSJ+mKk=
+cloud.google.com/go v0.105.0 h1:DNtEKRBAAzeS4KyIory52wWHuClNaXJ5x1F7xa4q+5Y=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -38,200 +42,282 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
-cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
-cloud.google.com/go/compute v1.7.0 h1:v/k9Eueb8aAJ0vZuxKMrgm6kPhCLZU9HxFU+AFDs9Uk=
cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
+cloud.google.com/go/compute v1.10.0 h1:aoLIYaA1fX3ywihqpBk2APQKOo20nXsp1GEZQbx5Jk4=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c=
-cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
-cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
-cloud.google.com/go/kms v1.1.0/go.mod h1:WdbppnCDMDpOvoYBMn1+gNmOeEoZYqAv+HeuKARGCXI=
+cloud.google.com/go/iam v0.6.0 h1:nsqQC88kT5Iwlm4MeNGTpfMWddp6NB/UOLFTH6m1QfQ=
cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA=
+cloud.google.com/go/longrunning v0.1.1 h1:y50CXG4j0+qvEukslYFBCrzaXX0qpFbBzc3PchSu/LE=
cloud.google.com/go/monitoring v1.1.0/go.mod h1:L81pzz7HKn14QCMaCs6NTQkdBnE87TElyanS95vIcl4=
-cloud.google.com/go/monitoring v1.4.0/go.mod h1:y6xnxfwI3hTFWOdkOaD7nfJVlwuC3/mS/5kvtT131p4=
+cloud.google.com/go/monitoring v1.5.0/go.mod h1:/o9y8NYX5j91JjD/JvGLYbi86kL11OjyJXq2XziLJu4=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/pubsub v1.19.0/go.mod h1:/O9kmSe9bb9KRnIAWkzmqhPjHo6LtzGOBYd/kr06XSs=
-cloud.google.com/go/secretmanager v1.3.0/go.mod h1:+oLTkouyiYiabAQNugCeTS3PAArGiMJuBqvJnJsyH+U=
+cloud.google.com/go/pubsub v1.24.0/go.mod h1:rWv09Te1SsRpRGPiWOMDKraMQTJyJps4MkUCoMGUgqw=
+cloud.google.com/go/secretmanager v1.5.0/go.mod h1:5C9kM+RwSpkURNovKySkNvGQLUaOgyoR5W0RUx2SyHQ=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
-cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg=
cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
+cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
+cloud.google.com/go/storage v1.24.0 h1:a4N0gIkx83uoVFGz8B2eAV3OhN90QoWF5OZWLKl39ig=
+cloud.google.com/go/storage v1.24.0/go.mod h1:3xrJEFMXBsQLgxwThyjuD3aYlroL0TMRec1ypGUQ0KE=
cloud.google.com/go/trace v1.0.0/go.mod h1:4iErSByzxkyHWzzlAj63/Gmjz0NH1ASqhJguHpGcr6A=
cloud.google.com/go/trace v1.2.0/go.mod h1:Wc8y/uYyOhPy12KEnXG9XGrvfMz5F5SrYecQlbW1rwM=
+code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8=
contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA=
-contrib.go.opencensus.io/exporter/stackdriver v0.13.10/go.mod h1:I5htMbyta491eUxufwwZPQdcKvvgzMB4O9ni41YnIM8=
+contrib.go.opencensus.io/exporter/stackdriver v0.13.13/go.mod h1:5pSSGY0Bhuk7waTHuDf4aQ8D2DrhgETRo9fy6k3Xlzc=
contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/AlecAivazis/survey/v2 v2.3.5 h1:A8cYupsAZkjaUmhtTYv3sSqc7LO5mp1XDfqe5E/9wRQ=
-github.com/AlecAivazis/survey/v2 v2.3.5/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20210715213245-6c3934b029d8/go.mod h1:CzsSbkDixRphAF5hS6wbMKq0eI6ccJRb7/A0M6JBnwg=
github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw=
github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI=
-github.com/Azure/azure-amqp-common-go/v3 v3.2.1/go.mod h1:O6X1iYHP7s2x7NjUKsXVhkwWrQhxrd+d8/3rRadj4CI=
-github.com/Azure/azure-amqp-common-go/v3 v3.2.2/go.mod h1:O6X1iYHP7s2x7NjUKsXVhkwWrQhxrd+d8/3rRadj4CI=
-github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
-github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
-github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-amqp-common-go/v3 v3.2.3/go.mod h1:7rPmbSfszeovxGfc5fSAXE4ehlXQZHpMja2OtxC2Tas=
+github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-sdk-for-go v63.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-sdk-for-go v65.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-sdk-for-go v66.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.1/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0/go.mod h1:+6sju8gk8FRmSajX3Oz4G5Gm7P+mbqE9FVaXXFYTkCM=
github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
-github.com/Azure/azure-service-bus-go v0.11.5/go.mod h1:MI6ge2CuQWBVq+ly456MY7XqNLJip5LO1iSFodbNLbU=
-github.com/Azure/azure-storage-blob-go v0.14.0 h1:1BCg74AmVdYwO3dlKwtFU1V0wU2PZdREkXvAmZJRUlM=
-github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck=
-github.com/Azure/go-amqp v0.16.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg=
-github.com/Azure/go-amqp v0.16.4/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
+github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.0.2/go.mod h1:LH9XQnMr2ZYxQdVdCrzLO9mxeDyrDFa6wbSI3x5zCZk=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1/go.mod h1:eZ4g6GUvXiGulfIbbhh1Xr4XwUYaYaWMqzGD/284wCA=
+github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg=
+github.com/Azure/go-amqp v0.17.5/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg=
+github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
+github.com/Azure/go-ansiterm v0.0.0-20210608223527-2377c96fe795/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
+github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA=
-github.com/Azure/go-autorest/autorest v0.11.19/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA=
-github.com/Azure/go-autorest/autorest v0.11.22/go.mod h1:BAWYUWGPEtKPzjVkp0Q6an0MJcJDsoh5Z1BFAEFs4Xs=
+github.com/Azure/go-autorest/autorest v0.11.24/go.mod h1:G6kyRlFnTuSbEYkQGawPfsCswgme4iYf6rfSKUDzbCc=
+github.com/Azure/go-autorest/autorest v0.11.25/go.mod h1:7l8ybrIdUmGqZMTD0sRtAr8NvbHjfofbf8RSP2q7w7U=
+github.com/Azure/go-autorest/autorest v0.11.27/go.mod h1:7l8ybrIdUmGqZMTD0sRtAr8NvbHjfofbf8RSP2q7w7U=
+github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA=
+github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
-github.com/Azure/go-autorest/autorest/adal v0.9.14/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
-github.com/Azure/go-autorest/autorest/adal v0.9.17/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ=
-github.com/Azure/go-autorest/autorest/azure/auth v0.5.9/go.mod h1:hg3/1yw0Bq87O3KvvnJoAh34/0zbP7SFizX/qN5JvjU=
-github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM=
+github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ=
+github.com/Azure/go-autorest/autorest/adal v0.9.20/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ=
+github.com/Azure/go-autorest/autorest/adal v0.9.21/go.mod h1:zua7mBUaCc5YnSLKYgGJR/w5ePdMDA6H56upLsHzA9U=
+github.com/Azure/go-autorest/autorest/azure/auth v0.5.11/go.mod h1:84w/uV8E37feW2NCJ08uT9VBfjfUHpgLVnG2InYD6cg=
+github.com/Azure/go-autorest/autorest/azure/cli v0.4.5/go.mod h1:ADQAXrkgm7acgWVUNamOgh8YNrv4p27l3Wc55oVfpzg=
+github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0=
github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
+github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
+github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU=
github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE=
github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E=
+github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
+github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/GoogleCloudPlatform/cloudsql-proxy v1.29.0/go.mod h1:spvB9eLJH9dutlbPSRmHvSXXHOwGRyeXh1jVdquA2G8=
+github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
+github.com/GoogleCloudPlatform/cloudsql-proxy v1.31.2/go.mod h1:qR6jVnZTKDCW3j+fC9mOEPHm++1nKDMkqbbkD6KNsfo=
+github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
+github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
+github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
+github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
+github.com/Microsoft/go-winio v0.4.16-0.20201130162521-d1ffc52c7331/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
+github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
+github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
+github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
+github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
+github.com/Microsoft/hcsshim v0.8.9/go.mod h1:5692vkUqntj1idxauYlpoINNKeqCiG6Sg38RRsjT5y8=
+github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg=
+github.com/Microsoft/hcsshim v0.8.15/go.mod h1:x38A4YbHbdxJtc0sF6oIz+RG0npwSCAvn69iY6URG00=
+github.com/Microsoft/hcsshim v0.8.16/go.mod h1:o5/SZqmR7x9JNKsW3pu+nqHm0MF8vbA+VxGOoXdC600=
+github.com/Microsoft/hcsshim v0.8.20/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4=
+github.com/Microsoft/hcsshim v0.8.21/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4=
+github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01nnU2M8jKDg=
+github.com/Microsoft/hcsshim v0.9.2/go.mod h1:7pLA8lDk46WKDWlVsENo92gC0XFa8rbKfyFRBqxEbCc=
+github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU=
+github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY=
+github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
+github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
+github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
+github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
+github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
+github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
+github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
+github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
+github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
+github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
+github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
+github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0=
+github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
+github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
+github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
+github.com/armon/go-metrics v0.3.3/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
+github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
+github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
+github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
-github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
+github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
+github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
+github.com/aws/aws-sdk-go v1.43.11/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
-github.com/aws/aws-sdk-go v1.44.85 h1:JM2rkKY/GtTDCQXW0StkImbLn6n4Q/Dm2bj+u1rm7Kw=
-github.com/aws/aws-sdk-go v1.44.85/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
-github.com/aws/aws-sdk-go v1.44.102 h1:6tUCTGL2UDbFZae1TLGk8vTgeXuzkb8KbAe2FiAeKHc=
-github.com/aws/aws-sdk-go v1.44.102/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
-github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU=
-github.com/aws/aws-sdk-go-v2 v1.16.11 h1:xM1ZPSvty3xVmdxiGr7ay/wlqv+MWhH0rMlyLdbC0YQ=
-github.com/aws/aws-sdk-go-v2 v1.16.11/go.mod h1:WTACcleLz6VZTp7fak4EO5b9Q4foxbn+8PIz3PmyKlo=
-github.com/aws/aws-sdk-go-v2 v1.16.16 h1:M1fj4FE2lB4NzRb9Y0xdWsn2P0+2UHVxwKyOa4YJNjk=
-github.com/aws/aws-sdk-go-v2 v1.16.16/go.mod h1:SwiyXi/1zTUZ6KIAmLK5V5ll8SiURNUYOqTerZPaF9k=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1/go.mod h1:n8Bs1ElDD2wJ9kCRTczA83gYbBmjSwZp3umc6zF4EeM=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.4 h1:zfT11pa7ifu/VlLDpmc5OY2W4nYmnKkFDGeMVnmqAI0=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.4/go.mod h1:ES0I1GBs+YYgcDS1ek47Erbn4TOL811JKqBXtgzqyZ8=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8 h1:tcFliCWne+zOuUfKNRn8JdFBuWPDuISDH08wD2ULkhk=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8/go.mod h1:JTnlBSot91steJeti4ryyu/tLd4Sk84O5W22L7O2EQU=
-github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg=
-github.com/aws/aws-sdk-go-v2/config v1.17.1 h1:BWxTjokU/69BZ4DnLrZco6OvBDii6ToEdfBL/y5I1nA=
-github.com/aws/aws-sdk-go-v2/config v1.17.1/go.mod h1:uOxDHjBemNTF2Zos+fgG0NNfE86wn1OAHDTGxjMEYi0=
-github.com/aws/aws-sdk-go-v2/config v1.17.7 h1:odVM52tFHhpqZBKNjVW5h+Zt1tKHbhdTQRb+0WHrNtw=
-github.com/aws/aws-sdk-go-v2/config v1.17.7/go.mod h1:dN2gja/QXxFF15hQreyrqYhLBaQo1d9ZKe/v/uplQoI=
-github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g=
-github.com/aws/aws-sdk-go-v2/credentials v1.12.14 h1:AtVG/amkjbDBfnPr/tuW2IG18HGNznP6L12Dx0rLz+Q=
-github.com/aws/aws-sdk-go-v2/credentials v1.12.14/go.mod h1:opAndTyq+YN7IpVG57z2CeNuXSQMqTYxGGlYH0m0RMY=
-github.com/aws/aws-sdk-go-v2/credentials v1.12.20 h1:9+ZhlDY7N9dPnUmf7CDfW9In4sW5Ff3bh7oy4DzS1IE=
-github.com/aws/aws-sdk-go-v2/credentials v1.12.20/go.mod h1:UKY5HyIux08bbNA7Blv4PcXQ8cTkGh7ghHMFklaviR4=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12 h1:wgJBHO58Pc1V1QAnzdVM3JK3WbE/6eUF0JxCZ+/izz0=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12/go.mod h1:aZ4vZnyUuxedC7eD4JyEHpGnCz+O2sHQEx3VvAwklSE=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.17 h1:r08j4sbZu/RVi+BNxkBJwPMUYY3P8mgSDuKkZ/ZN1lE=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.17/go.mod h1:yIkQcCDYNsZfXpd5UX2Cy+sWA1jPgIhGTw9cOBzfVnQ=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.27 h1:xFXIMBci0UXStoOHq/8w0XIZPB2hgb9CD7uATJhqt10=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.27/go.mod h1:+tj2cHQkChanggNZn1J2fJ1Cv6RO1TV0AA3472do31I=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.33 h1:fAoVmNGhir6BR+RU0/EI+6+D7abM+MCwWf8v4ip5jNI=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.33/go.mod h1:84XgODVR8uRhmOnUkKGUZKqIMxmjmLOR8Uyp7G/TPwc=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.18 h1:OmiwoVyLKEqqD5GvB683dbSqxiOfvx4U2lDZhG2Esc4=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.18/go.mod h1:348MLhzV1GSlZSMusdwQpXKbhD7X2gbI/TxwAPKkYZQ=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 h1:s4g/wnzMf+qepSNgTvaQQHNxyMLKSawNhKCPNy++2xY=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23/go.mod h1:2DFxAQ9pfIRy0imBCJv+vZ2X6RKxves6fbnEuSry6b4=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.12 h1:5mvQDtNWtI6H56+E4LUnLWEmATMB7oEh+Z9RurtIuC0=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.12/go.mod h1:ckaCVTEdGAxO6KwTGzgskxR1xM+iJW4lxMyDFVda2Fc=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 h1:/K482T5A3623WJgWT8w1yRAFK4RzGzEl7y39yhtn9eA=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17/go.mod h1:pRwaTYCJemADaqCbUAxltMoHKata7hmB5PjEXeu0kfg=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10/go.mod h1:8DcYQcz0+ZJaSxANlHIsbbi6S+zMwjwdDqwW3r9AzaE=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.19 h1:g5qq9sgtEzt2szMaDqQO6fqKe026T6dHTFJp5NsPzkQ=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.19/go.mod h1:cVHo8KTuHjShb9V8/VjH3S/8+xPu16qx8fdGwmotJhE=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.24 h1:wj5Rwc05hvUSvKuOF29IYb9QrCLjU+rHAy/x/o0DK2c=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.24/go.mod h1:jULHjqqjDlbyTa7pfM7WICATnOv+iOhjletM3N0Xbu8=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.9 h1:agLpf3vtYX1rtKTrOGpevdP3iC2W0hKDmzmhhxJzL+A=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.9/go.mod h1:cv+n1mdyh+0B8tAtlEBzTYFA2Uv15SISEn6kabYhIgE=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14 h1:ZSIPAkAsCCjYrhqfw2+lNzWDzxzHXEckFkTePL5RSWQ=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14/go.mod h1:AyGgqiKv9ECM6IZeNQtdT8NnMvUb3/2wokeq2Fgryto=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1/go.mod h1:GeUru+8VzrTXV/83XyMJ80KpH8xO89VPoUileyNQ+tc=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.5 h1:g1ITJ9i9ixa+/WVggLNK20KyliAA8ltnuxfZEDfo2hM=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.5/go.mod h1:oehQLbMQkppKLXvpx/1Eo0X47Fe+0971DXC9UjGnKcI=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.9 h1:Lh1AShsuIJTwMkoxVCAYPJgNG5H+eN6SmoUn8nOZ5wE=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.9/go.mod h1:a9j48l6yL5XINLHLcOKInjdvknN+vWqPBxqeIDw7ktw=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3/go.mod h1:Seb8KNmD6kVTjwRjVEgOT5hPin6sq+v4C2ycJQDwuH8=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.13 h1:3GamN8jcdz/a3nvL/ZVtoH/6xxeshfsiXj5O+6GW4Rg=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.13/go.mod h1:89CSPn69UECDLVn0H6FwKNgbtirksl8C8i3aBeeeihw=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18 h1:BBYoNQt2kUZUUK4bIPsKrCcjVPUMNsgQpNAwhznK/zo=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18/go.mod h1:NS55eQ4YixUJPTC+INxi2/jCqe1y2Uw3rnh9wEOVJxY=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3/go.mod h1:wlY6SVjuwvh3TVRpTqdy4I1JpBFLX4UGeKZdWntaocw=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.12 h1:7iPTTX4SAI2U2VOogD7/gmHlsgnYSgoNHt7MSQXtG2M=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.12/go.mod h1:1TODGhheLWjpQWSuhYuAUWYTCKwEjx2iblIFKDHjeTc=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17 h1:Jrd/oMh0PKQc6+BowB+pLEwLIgaQF29eYbe7E1Av9Ug=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17/go.mod h1:4nYOrY41Lrbk2170/BGkcJKBhws9Pfn8MG3aGqjjeFI=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3/go.mod h1:Bm/v2IaN6rZ+Op7zX+bOUMdL4fsrYZiD0dsjLhNKwZc=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.12 h1:QFjSOmHSb77qRTv7KI9UFon9X5wLWY5/M+6la3dTcZc=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.12/go.mod h1:MADjAN0GHFDuc5lRa5Y5ki+oIO/w7X4qczHy+OUx0IA=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17 h1:HfVVR1vItaG6le+Bpw6P4midjBDMKnjMyZnw9MXYUcE=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17/go.mod h1:YqMdV+gEKCQ59NrB7rzrJdALeBIsYiVi8Inj3+KcqHI=
-github.com/aws/aws-sdk-go-v2/service/kms v1.16.3/go.mod h1:QuiHPBqlOFCi4LqdSskYYAWpQlx3PKmohy+rE2F+o5g=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3/go.mod h1:g1qvDuRsJY+XghsV6zg00Z4KJ7DtFFCx8fJD2a491Ak=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.27.5 h1:h9qqTedYnA9JcWjKyLV6UYIMSdp91ExLCUbjbpDLH7A=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.27.5/go.mod h1:J8SS5Tp/zeLxaubB0xGfKnVrvssNBNLwTipreTKLhjQ=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11 h1:3/gm/JTX9bX8CpzTgIlrtYpB3EVBDxyg/GY/QdcIEZw=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11/go.mod h1:fmgDANqTUCxciViKl9hb/zD5LFbvPINFRgWhDbR+vZo=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.4/go.mod h1:PJc8s+lxyU8rrre0/4a0pn2wgwiDvOEzoOjcJUBr67o=
-github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZPFD9DME/eC6oHBXvFzQ9Bcw=
-github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM=
-github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.17 h1:pXxu9u2z1UqSbjO9YA8kmFJBhFc1EVTDaf7A+S+Ivq8=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.17/go.mod h1:mS5xqLZc/6kc06IpXn5vRxdLaED+jEuaSRv5BxtnsiY=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.23 h1:pwvCchFUEnlceKIgPUouBJwK81aCkQ8UDMORfeFtW10=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.23/go.mod h1:/w0eg9IhFGjGyyncHIQrXtU8wvNsTJOP0R6PPj0wf80=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.5 h1:GUnZ62TevLqIoDyHeiWj2P7EqaosgakBKVvWriIdLQY=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.5/go.mod h1:csZuQY65DAdFBt1oIjO5hhBR49kQqop4+lcuCjf2arA=
-github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8=
-github.com/aws/aws-sdk-go-v2/service/sts v1.16.13 h1:dl8T0PJlN92rvEGOEUiD0+YPYdPEaCZK0TqHukvSfII=
-github.com/aws/aws-sdk-go-v2/service/sts v1.16.13/go.mod h1:Ru3QVMLygVs/07UQ3YDur1AQZZp2tUNje8wfloFttC0=
-github.com/aws/aws-sdk-go-v2/service/sts v1.16.19 h1:9pPi0PsFNAGILFfPCk8Y0iyEBGc6lu6OQ97U7hmdesg=
-github.com/aws/aws-sdk-go-v2/service/sts v1.16.19/go.mod h1:h4J3oPZQbxLhzGnk+j9dfYHi5qIOVJ5kczZd658/ydM=
-github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM=
-github.com/aws/smithy-go v1.12.1 h1:yQRC55aXN/y1W10HgwHle01DRuV9Dpf31iGkotjt3Ag=
-github.com/aws/smithy-go v1.12.1/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
-github.com/aws/smithy-go v1.13.3 h1:l7LYxGuzK6/K+NzJ2mC+VvLUbae0sL3bXU//04MkmnA=
-github.com/aws/smithy-go v1.13.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
+github.com/aws/aws-sdk-go v1.44.45/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
+github.com/aws/aws-sdk-go v1.44.68/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
+github.com/aws/aws-sdk-go v1.44.126 h1:7HQJw2DNiwpxqMe2H7odGNT2rhO4SRrUe5/8dYXl0Jk=
+github.com/aws/aws-sdk-go v1.44.126/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
+github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
+github.com/aws/aws-sdk-go-v2 v1.16.8/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw=
+github.com/aws/aws-sdk-go-v2 v1.17.1 h1:02c72fDJr87N8RAC2s3Qu0YuvMRZKNZJ9F+lAehCazk=
+github.com/aws/aws-sdk-go-v2 v1.17.1/go.mod h1:JLnGeGONAyi2lWXI1p0PCIOIy333JMVK1U7Hf0aRFLw=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3/go.mod h1:gNsR5CaXKmQSSzrmGxmwmct/r+ZBfbxorAuXYsj/M5Y=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9 h1:RKci2D7tMwpvGpDNZnGQw9wk6v7o/xSwFcUAuNPoB8k=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9/go.mod h1:vCmV1q1VK8eoQJ5+aYE7PkK1K6v41qJ5pJdK3ggCDvg=
+github.com/aws/aws-sdk-go-v2/config v1.15.15/go.mod h1:A1Lzyy/o21I5/s2FbyX5AevQfSVXpvvIDCoVFD0BC4E=
+github.com/aws/aws-sdk-go-v2/config v1.17.10 h1:zBy5QQ/mkvHElM1rygHPAzuH+sl8nsdSaxSWj0+rpdE=
+github.com/aws/aws-sdk-go-v2/config v1.17.10/go.mod h1:/4np+UiJJKpWHN7Q+LZvqXYgyjgeXm5+lLfDI6TPZao=
+github.com/aws/aws-sdk-go-v2/credentials v1.12.10/go.mod h1:g5eIM5XRs/OzIIK81QMBl+dAuDyoLN0VYaLP+tBqEOk=
+github.com/aws/aws-sdk-go-v2/credentials v1.12.23 h1:LctvcJMIb8pxvk5hQhChpCu0WlU6oKQmcYb1HA4IZSA=
+github.com/aws/aws-sdk-go-v2/credentials v1.12.23/go.mod h1:0awX9iRr/+UO7OwRQFpV1hNtXxOVuehpjVEzrIAYNcA=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.9/go.mod h1:KDCCm4ONIdHtUloDcFvK2+vshZvx4Zmj7UMDfusuz5s=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 h1:E3PXZSI3F2bzyj6XxUXdTIfvp425HHhwKsFvmzBwHgs=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19/go.mod h1:VihW95zQpeKQWVPGkwT+2+WJNQV8UXFfMTWdU6VErL8=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.21/go.mod h1:iIYPrQ2rYfZiB/iADYlhj9HHZ9TTi6PqKQPAqygohbE=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.37 h1:e1VtTBo+cLNjres0wTlMkmwCGGRjDEkkrz3frxxcaCs=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.37/go.mod h1:kdAV1UMnCkyG6tZJUC4mHbPoRjPA3dIK0L8mnsHERiM=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.15/go.mod h1:pWrr2OoHlT7M/Pd2y4HV3gJyPb3qj5qMmnPkKSNPYK4=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 h1:nBO/RFxeq/IS5G9Of+ZrgucRciie2qpLy++3UGZ+q2E=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25/go.mod h1:Zb29PYkf42vVYQY6pvSyJCJcFHlPIiY+YKdPtwnvMkY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.9/go.mod h1:08tUpeSGN33QKSO7fwxXczNfiwCpbj+GxK6XKwqWVv0=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 h1:oRHDrwCTVT8ZXi4sr9Ld+EXk7N/KGssOr2ygNeojEhw=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19/go.mod h1:6Q0546uHDp421okhmmGfbxzq2hBqbXFNpi4k+Q1JnQA=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.16/go.mod h1:CYmI+7x03jjJih8kBEEFKRQc40UjUokT0k7GbvrhhTc=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 h1:Mza+vlnZr+fPKFKRq/lKGVvM6B/8ZZmNdEopOwSQLms=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26/go.mod h1:Y2OJ+P+MC1u1VKnavT+PshiEuGPyh/7DqxoDNij4/bg=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.6/go.mod h1:O7Oc4peGZDEKlddivslfYFvAbgzvl/GH3J8j3JIGBXc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16 h1:2EXB7dtGwRYIN3XQ9qwIW504DVbKIw3r89xQnonGdsQ=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16/go.mod h1:XH+3h395e3WVdd6T2Z3mPxuI+x/HVtdqVOREkTiyubs=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3/go.mod h1:gkb2qADY+OHaGLKNTYxMaQNacfeyQpZ4csDTQMeFmcw=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 h1:dpiPHgmFstgkLG07KaYAewvuptq5kvo52xn7tVSrtrQ=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10/go.mod h1:9cBNUHI2aW4ho0A5T87O294iPDuuUOSIEDjnd1Lq/z0=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.10/go.mod h1:Qks+dxK3O+Z2deAhNo6cJ8ls1bam3tUGUAcgxQP1c70=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20 h1:KSvtm1+fPXE0swe9GPjc6msyrdTT0LB/BP8eLugL1FI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20/go.mod h1:Mp4XI/CkWGD79AQxZ5lIFlgvC0A+gl+4BmyG1F+SfNc=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.9/go.mod h1:yQowTpvdZkFVuHrLBXmczat4W+WJKg/PafBZnGBLga0=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 h1:GE25AWCdNUPh9AOJzI9KIJnja7IwUc1WyUqz/JTyJ/I=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19/go.mod h1:02CP6iuYP+IVnBX5HULVdSAku/85eHB2Y9EsFhrkEwU=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.9/go.mod h1:Rc5+wn2k8gFSi3V1Ch4mhxOzjMh+bYSXVFfVaqowQOY=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19 h1:piDBAaWkaxkkVV3xJJbTehXCZRXYs49kvpi/LG6LR2o=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19/go.mod h1:BmQWRVkLTmyNzYPFAZgon53qKLWBNSvonugD1MrSWUs=
+github.com/aws/aws-sdk-go-v2/service/kms v1.18.1/go.mod h1:4PZMUkc9rXHWGVB5J9vKaZy3D7Nai79ORworQ3ASMiM=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.27.2/go.mod h1:u+566cosFI+d+motIz3USXEh6sN8Nq4GrNXSg2RXVMo=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.29.1 h1:/EMdFPW/Ppieh0WUtQf1+qCGNLdsq5UWUyevBQ6vMVc=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.29.1/go.mod h1:/NHbqPRiwxSPVOB2Xr+StDEH+GWV/64WwnUjv4KYzV0=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.14/go.mod h1:xakbH8KMsQQKqzX87uyyzTHshc/0/Df8bsTneTS5pFU=
+github.com/aws/aws-sdk-go-v2/service/sns v1.17.10/go.mod h1:uITsRNVMeCB3MkWpXxXw0eDz8pW4TYLzj+eyQtbhSxM=
+github.com/aws/aws-sdk-go-v2/service/sqs v1.19.1/go.mod h1:A94o564Gj+Yn+7QO1eLFeI7UVv3riy/YBFOfICVqFvU=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.27.6/go.mod h1:fiFzQgj4xNOg4/wqmAiPvzgDMXPD+cUEplX/CYn+0j0=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.13/go.mod h1:d7ptRksDDgvXaUvxyHZ9SYh+iMDymm94JbVcgvSYSzU=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 h1:GFZitO48N/7EsFDt8fMa5iYdmWqkUDDB3Eje6z3kbG0=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.25/go.mod h1:IARHuzTXmj1C0KS35vboR0FeJ89OkEy1M9mWbK2ifCI=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 h1:jcw6kKZrtNfBPJkaHrscDOZoe5gvi9wjudnxvozYFJo=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8/go.mod h1:er2JHN+kBY6FcMfcBBKNGCT3CarImmdFzishsqBmSRI=
+github.com/aws/aws-sdk-go-v2/service/sts v1.16.10/go.mod h1:cftkHYN6tCDNfkSasAmclSfl4l7cySoay8vz7p/ce0E=
+github.com/aws/aws-sdk-go-v2/service/sts v1.17.1 h1:KRAix/KHvjGODaHAMXnxRk9t0D+4IJVUuS/uwXxngXk=
+github.com/aws/aws-sdk-go-v2/service/sts v1.17.1/go.mod h1:bXcN3koeVYiJcdDU89n3kCYILob7Y34AeLopUbZgLT4=
+github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
+github.com/aws/smithy-go v1.13.4 h1:/RN2z1txIJWeXeOkzX+Hk/4Uuvv7dWtCjbmVJcrskyk=
+github.com/aws/smithy-go v1.13.4/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
+github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
+github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
+github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
+github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
+github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
+github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
+github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
+github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
+github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
+github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
+github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
+github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
+github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw=
+github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M=
+github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg=
+github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc=
+github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs=
+github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
+github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
+github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA=
+github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
+github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
+github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@@ -242,28 +328,192 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
+github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
+github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo=
+github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA=
+github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI=
+github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
+github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE=
+github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU=
+github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
+github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
+github.com/containerd/btrfs v0.0.0-20201111183144-404b9149801e/go.mod h1:jg2QkJcsabfHugurUvvPhS3E08Oxiuh5W/g1ybB4e0E=
+github.com/containerd/btrfs v0.0.0-20210316141732-918d888fb676/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss=
+github.com/containerd/btrfs v1.0.0/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss=
+github.com/containerd/cgroups v0.0.0-20190717030353-c4b9ac5c7601/go.mod h1:X9rLEHIqSf/wfK8NsPqxJmeZgW4pcfzdXITDrUSJ6uI=
+github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko=
+github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1pT8KYB3TCXK/ocprsh7MAkoW8bZVzPdih9snmM=
+github.com/containerd/cgroups v0.0.0-20200710171044-318312a37340/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo=
+github.com/containerd/cgroups v0.0.0-20200824123100-0b889c03f102/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo=
+github.com/containerd/cgroups v0.0.0-20210114181951-8a68de567b68/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE=
+github.com/containerd/cgroups v1.0.1/go.mod h1:0SJrPIenamHDcZhEcJMNBB85rHcUsw4f25ZfBiPYRkU=
+github.com/containerd/cgroups v1.0.3/go.mod h1:/ofk34relqNjSGyqPrmEULrO4Sc8LJhvJmWbUCUKqj8=
+github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
+github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
+github.com/containerd/console v0.0.0-20191206165004-02ecf6a7291e/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE=
+github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
+github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/containerd/containerd v1.2.10/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.3.1-0.20191213020239-082f7e3aed57/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.4.0-beta.2.0.20200729163537-40b22ef07410/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.4.9/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.5.0-beta.1/go.mod h1:5HfvG1V2FsKesEGQ17k5/T7V960Tmcumvqn8Mc+pCYQ=
+github.com/containerd/containerd v1.5.0-beta.3/go.mod h1:/wr9AVtEM7x9c+n0+stptlo/uBBoBORwEx6ardVcmKU=
+github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09ZvgqEq8EfBp/m3lcVZIvPHhI=
+github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s=
+github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g=
+github.com/containerd/containerd v1.5.7/go.mod h1:gyvv6+ugqY25TiXxcZC3L5yOeYgEw0QMhscqVp1AR9c=
+github.com/containerd/containerd v1.5.8/go.mod h1:YdFSv5bTFLpG2HIYmfqDpSYYTDX+mc5qtSuYx1YUb/s=
+github.com/containerd/containerd v1.6.1/go.mod h1:1nJz5xCZPusx6jJU8Frfct988y0NpumIq9ODB0kLtoE=
+github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
+github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
+github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
+github.com/containerd/continuity v0.0.0-20200710164510-efbc4488d8fe/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo=
+github.com/containerd/continuity v0.0.0-20201208142359-180525291bb7/go.mod h1:kR3BEg7bDFaEddKm54WSmrol1fKWDU1nKYkgrcgZT7Y=
+github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ=
+github.com/containerd/continuity v0.1.0/go.mod h1:ICJu0PwR54nI0yPEnJ6jcS+J7CZAUXrLh8lPo2knzsM=
+github.com/containerd/continuity v0.2.2/go.mod h1:pWygW9u7LtS1o4N/Tn0FoCFDIXZ7rxcMX7HX1Dmibvk=
+github.com/containerd/fifo v0.0.0-20180307165137-3d5202aec260/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
+github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
+github.com/containerd/fifo v0.0.0-20200410184934-f15a3290365b/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0=
+github.com/containerd/fifo v0.0.0-20201026212402-0724c46b320c/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0=
+github.com/containerd/fifo v0.0.0-20210316144830-115abcc95a1d/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4=
+github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4=
+github.com/containerd/go-cni v1.0.1/go.mod h1:+vUpYxKvAF72G9i1WoDOiPGRtQpqsNW/ZHtSlv++smU=
+github.com/containerd/go-cni v1.0.2/go.mod h1:nrNABBHzu0ZwCug9Ije8hL2xBCYh/pjfMb1aZGrrohk=
+github.com/containerd/go-cni v1.1.0/go.mod h1:Rflh2EJ/++BA2/vY5ao3K6WJRR/bZKsX123aPk+kUtA=
+github.com/containerd/go-cni v1.1.3/go.mod h1:Rflh2EJ/++BA2/vY5ao3K6WJRR/bZKsX123aPk+kUtA=
+github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0=
+github.com/containerd/go-runc v0.0.0-20190911050354-e029b79d8cda/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0=
+github.com/containerd/go-runc v0.0.0-20200220073739-7016d3ce2328/go.mod h1:PpyHrqVs8FTi9vpyHwPwiNEGaACDxT/N/pLcvMSRA9g=
+github.com/containerd/go-runc v0.0.0-20201020171139-16b287bc67d0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok=
+github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok=
+github.com/containerd/imgcrypt v1.0.1/go.mod h1:mdd8cEPW7TPgNG4FpuP3sGBiQ7Yi/zak9TYCG3juvb0=
+github.com/containerd/imgcrypt v1.0.4-0.20210301171431-0ae5c75f59ba/go.mod h1:6TNsg0ctmizkrOgXRNQjAPFWpMYRWuiB6dSF4Pfa5SA=
+github.com/containerd/imgcrypt v1.1.1-0.20210312161619-7ed62a527887/go.mod h1:5AZJNI6sLHJljKuI9IHnw1pWqo/F0nGDOuR9zgTs7ow=
+github.com/containerd/imgcrypt v1.1.1/go.mod h1:xpLnwiQmEUJPvQoAapeb2SNCxz7Xr6PJrXQb0Dpc4ms=
+github.com/containerd/imgcrypt v1.1.3/go.mod h1:/TPA1GIDXMzbj01yd8pIbQiLdQxed5ue1wb8bP7PQu4=
+github.com/containerd/nri v0.0.0-20201007170849-eb1350a75164/go.mod h1:+2wGSDGFYfE5+So4M5syatU0N0f0LbWpuqyMi4/BE8c=
+github.com/containerd/nri v0.0.0-20210316161719-dbaa18c31c14/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY=
+github.com/containerd/nri v0.1.0/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY=
+github.com/containerd/stargz-snapshotter/estargz v0.4.1/go.mod h1:x7Q9dg9QYb4+ELgxmo4gBUeJB0tl5dqH1Sdz0nJU1QM=
+github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
+github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
+github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8=
+github.com/containerd/ttrpc v1.0.1/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y=
+github.com/containerd/ttrpc v1.0.2/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y=
+github.com/containerd/ttrpc v1.1.0/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ=
+github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc=
+github.com/containerd/typeurl v0.0.0-20190911142611-5eb25027c9fd/go.mod h1:GeKYzf2pQcqv7tJ0AoCuuhtnqhva5LNU3U+OyKxxJpk=
+github.com/containerd/typeurl v1.0.1/go.mod h1:TB1hUtrpaiO88KEK56ijojHS1+NeF0izUACaJW2mdXg=
+github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s=
+github.com/containerd/zfs v0.0.0-20200918131355-0a33824f23a2/go.mod h1:8IgZOBdv8fAgXddBT4dBXJPtxyRsejFIpXoklgxgEjw=
+github.com/containerd/zfs v0.0.0-20210301145711-11e8f1707f62/go.mod h1:A9zfAbMlQwE+/is6hi0Xw8ktpL+6glmqZYtevJgaB8Y=
+github.com/containerd/zfs v0.0.0-20210315114300-dde8f0fda960/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
+github.com/containerd/zfs v0.0.0-20210324211415-d5c4544f0433/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
+github.com/containerd/zfs v1.0.0/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
+github.com/containernetworking/cni v0.7.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
+github.com/containernetworking/cni v0.8.0/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
+github.com/containernetworking/cni v0.8.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
+github.com/containernetworking/cni v1.0.1/go.mod h1:AKuhXbN5EzmD4yTNtfSsX3tPcmtrBI6QcRV0NiNt15Y=
+github.com/containernetworking/plugins v0.8.6/go.mod h1:qnw5mN19D8fIwkqW7oHHYDHVlzhJpcY6TQxn/fUyDDM=
+github.com/containernetworking/plugins v0.9.1/go.mod h1:xP/idU2ldlzN6m4p5LmGiwRDjeJr6FLK6vuiUwoH7P8=
+github.com/containernetworking/plugins v1.0.1/go.mod h1:QHCfGpaTwYTbbH+nZXKVTxNBDZcxSOplJT5ico8/FLE=
+github.com/containers/ocicrypt v1.0.1/go.mod h1:MeJDzk1RJHv89LjsH0Sp5KTY3ZYkjXO/C+bKAeWFIrc=
+github.com/containers/ocicrypt v1.1.0/go.mod h1:b8AOe0YR67uU8OqfVNcznfFpAzu3rdgUV4GP9qXPfu4=
+github.com/containers/ocicrypt v1.1.1/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY=
+github.com/containers/ocicrypt v1.1.2/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY=
+github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
+github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
+github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
+github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
+github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
+github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ=
+github.com/d2g/dhcp4client v1.0.0/go.mod h1:j0hNfjhrt2SxUOw55nL0ATM/z4Yt3t2Kd1mW34z5W5s=
+github.com/d2g/dhcp4server v0.0.0-20181031114812-7d4a0a7f59a5/go.mod h1:Eo87+Kg/IX2hfWJfwxMzLyuSZyxSoAug2nGa1G2QAi8=
+github.com/d2g/hardwareaddr v0.0.0-20190221164911-e7d9fbe030e4/go.mod h1:bMl4RjIciD2oAxI7DmWRx6gbeqrkoLqv3MV0vzNad+I=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/denisenkom/go-mssqldb v0.12.0/go.mod h1:iiK0YP1ZeepvmBQk/QpLEhhTNJgfzrpArPY/aFvc9yU=
+github.com/denisenkom/go-mssqldb v0.12.2/go.mod h1:lnIw1mZukFRZDJYQ0Pb833QS2IaC3l5HkEfra2LJ+sk=
+github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA=
+github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0=
github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
-github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
+github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/dgryski/go-sip13 v0.0.0-20200911182023-62edffca9245/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/digitalocean/godo v1.78.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs=
+github.com/digitalocean/godo v1.81.0/go.mod h1:BPCqvwbjbGqxuUnIKB4EvS/AX7IDnNmt5fwvIkWo+ew=
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
+github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
+github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
+github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
+github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
+github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v20.10.14+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v20.10.17+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
+github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
+github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
+github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
+github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
+github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
+github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
+github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
+github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/domodwyer/mailyak/v3 v3.3.4 h1:AG/pvcz2/ocFqZkPEG7lPAa0MhCq1warfUEKJt6Fagk=
github.com/domodwyer/mailyak/v3 v3.3.4/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
+github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
+github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
+github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
+github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
+github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
+github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
+github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
+github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -272,28 +522,97 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
+github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
+github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo=
+github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
+github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
+github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
+github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
+github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
+github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
+github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/ganigeorgiev/fexpr v0.1.1 h1:La9kYEgTcIutvOnqNZ8pOUD0O0Q/Gn15sTVEX+IeBE8=
github.com/ganigeorgiev/fexpr v0.1.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
+github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
+github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
+github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
+github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
-github.com/gin-gonic/gin v1.7.3/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
+github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
+github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
+github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
+github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.1/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/stdr v1.2.0/go.mod h1:YkVgnZu1ZjjL7xTxrfm/LLZBfkhTqSR1ydtm6jTKKwI=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-openapi/analysis v0.21.2/go.mod h1:HZwRk4RRisyG8vx2Oe6aqeSQcoxRp47Xkp3+K6q+LdY=
+github.com/go-openapi/errors v0.19.8/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
+github.com/go-openapi/errors v0.19.9/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
+github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
+github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0=
+github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
+github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg=
+github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
+github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
+github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
+github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
+github.com/go-openapi/loads v0.21.1/go.mod h1:/DtAMXXneXFjbQMGEtbamCZb+4x7eGwkvZCvBmwUG+g=
+github.com/go-openapi/runtime v0.23.1/go.mod h1:AKurw9fNre+h3ELZfk6ILsfvPN+bvvlaU/M9q/r9hpk=
+github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc=
+github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
+github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
+github.com/go-openapi/strfmt v0.21.0/go.mod h1:ZRQ409bWMj+SOgXofQAGTIo2Ebu72Gs+WaRADcS5iNg=
+github.com/go-openapi/strfmt v0.21.1/go.mod h1:I/XVKeLc5+MM5oPNN7P6urMOpuLXEcNrCX/rPGuWb0k=
+github.com/go-openapi/strfmt v0.21.2/go.mod h1:I/XVKeLc5+MM5oPNN7P6urMOpuLXEcNrCX/rPGuWb0k=
+github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I=
+github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
+github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
+github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
+github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
+github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
+github.com/go-openapi/validate v0.21.0/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -301,22 +620,73 @@ github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTM
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
+github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8=
+github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
+github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0=
+github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY=
+github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg=
+github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
+github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
+github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs=
+github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI=
+github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI=
+github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk=
+github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28=
+github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo=
+github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk=
+github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw=
+github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360=
+github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg=
+github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE=
+github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8=
+github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
+github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
+github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4=
+github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4=
+github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ=
+github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
+github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
+github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
+github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
+github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
+github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
+github.com/gogo/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU=
+github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
+github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
+github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
+github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
-github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
-github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+wXQnTPR4KqTKDgJukSZ6amVRtWMPEjE6sQoK8=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
+github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -349,9 +719,14 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
+github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -365,13 +740,18 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
-github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0=
+github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-replayers/grpcreplay v1.1.0 h1:S5+I3zYyZ+GQz68OfbURDdt/+cSMqCK1wrvNx7WBzTE=
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
github.com/google/go-replayers/httpreplay v1.1.1 h1:H91sIMlt1NZzN7R+/ASswyouLJfW0WLW7fhyUFvDEkY=
github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:xmapqc1AyLoB+ddYT6r04bD9lIjlOqGaREovi0SzFaE=
github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -395,8 +775,12 @@ github.com/google/pprof v0.0.0-20210506205249-923b5ab0fc1a/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20220318212150-b2ab0324ddda/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
+github.com/google/pprof v0.0.0-20220608213341-c488b8fa1db3/go.mod h1:gSuNB+gJaOiQKLEZ+q+PK9Mq3SOzhRcw2GsGS/FhYDk=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
+github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
@@ -404,8 +788,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
-github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw=
github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
+github.com/googleapis/enterprise-certificate-proxy v0.2.0 h1:y8Yozv7SZtlU//QXbezB6QkpuE6jMD2/gfzk4AftXjs=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -413,23 +797,110 @@ github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
-github.com/googleapis/gax-go/v2 v2.5.1 h1:kBRZU0PSuI7PspsSb/ChWoVResUcwNVIdpB049pKTiw=
-github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
-github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
+github.com/googleapis/gax-go/v2 v2.6.0 h1:SXk3ABtQYDT/OH8jAyvEOQ58mgawq5C4o/4/89qN2ZU=
+github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
+github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
+github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU=
+github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
+github.com/gophercloud/gophercloud v0.24.0/go.mod h1:Q8fZtyi5zZxPS/j9aj3sSxtvj41AdQMDwyo1myduD5c=
+github.com/gophercloud/gophercloud v0.25.0/go.mod h1:Q8fZtyi5zZxPS/j9aj3sSxtvj41AdQMDwyo1myduD5c=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
+github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
+github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grafana/regexp v0.0.0-20220304095617-2e8d9baf4ac2/go.mod h1:M5qHK+eWfAv8VR/265dIuEpL3fNfeC21tXXp9itM24A=
+github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.10.2/go.mod h1:chrfS3YoLAlKTRE5cFWvCbt8uGAjshktT4PveTUpsFQ=
github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok=
github.com/hanwen/go-fuse/v2 v2.1.0/go.mod h1:oRyA5eK+pvJyv5otpO/DgccS8y/RvYMaO00GgRLGryc=
+github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
+github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
+github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=
+github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ=
+github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
+github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
+github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
+github.com/hashicorp/cronexpr v1.1.1/go.mod h1:P4wA0KBl9C5q2hABiMO7cp6jcIg96CDh1Efb3g1PWA4=
+github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
+github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
+github.com/hashicorp/go-hclog v0.12.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
+github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-immutable-radix v1.2.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
+github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
+github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
+github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
+github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
+github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
+github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
+github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
+github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
+github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
+github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
+github.com/hashicorp/memberlist v0.3.1/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
+github.com/hashicorp/nomad/api v0.0.0-20220629141207-c2428e1673ec/go.mod h1:jP79oXjopTyH6E8LF0CEMq67STgrlmBRIyijA0tuR5o=
+github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
+github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
+github.com/hetznercloud/hcloud-go v1.33.1/go.mod h1:XX/TQub3ge0yWR2yHWmnDVIrB+MQbda1pHxkUmDlUME=
+github.com/hetznercloud/hcloud-go v1.35.0/go.mod h1:mepQwR6va27S3UQthaEPGS86jtzSY9xWL1e9dyxXpgA=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
+github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
+github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
+github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
+github.com/intel/goresctrl v0.2.0/go.mod h1:+CZdzouYFn5EsxgqAQTEzMfwKwuc0fVdMrT9FCCAVRQ=
+github.com/ionos-cloud/sdk-go/v6 v6.1.0/go.mod h1:Ox3W0iiEz0GHnfY9e5LmAxwklsxguuNFEUSu0gVRTME=
+github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA=
+github.com/j-keck/arping v1.0.2/go.mod h1:aJbELhR92bSk7tp79AWM/ftfc90EfEi2bQJrbBFOsPw=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
@@ -439,7 +910,7 @@ github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsU
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
-github.com/jackc/pgconn v1.11.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
+github.com/jackc/pgconn v1.12.1/go.mod h1:ZkhRC59Llhrq3oSfrikvwQ5NaxYExr6twkdkMLaKono=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
@@ -452,46 +923,80 @@ github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvW
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
-github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.3.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
-github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
+github.com/jackc/pgtype v1.11.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
-github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw=
+github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
+github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
+github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198 h1:lFz33AOOXwTpqOiHvrN8nmTdkxSfuNLHLPjgQ1muPpU=
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198/go.mod h1:uh3YlzsEJj7OG57rDWj6c3WEkOF1ZHGBQkDuUZw3rE8=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
@@ -499,85 +1004,436 @@ github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
+github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
+github.com/linode/linodego v1.4.0/go.mod h1:PVsRxSlOiJyvG4/scTszpmZDTdgS+to3X6eS8pRrWI8=
+github.com/linode/linodego v1.8.0/go.mod h1:heqhl91D8QTPVm2k9qZHP78zzbOdTFLXE9NJc3bcc50=
+github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo=
+github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
+github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
+github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
+github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
+github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
+github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-ieproxy v0.0.1 h1:qiyop7gCflfhwCzGyeT0gro3sF9AIg9HU98JORTkqfI=
-github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
+github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
-github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
+github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
+github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
+github.com/maxbrunsfeld/counterfeiter/v6 v6.2.2/go.mod h1:eD9eIE7cdwcMi9rYluz88Jz2VyhSmden33/aXg4oVIY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U=
+github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
+github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
+github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
+github.com/miekg/dns v1.1.48/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
+github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
+github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
+github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4=
+github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
+github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
+github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
+github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
+github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
+github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
+github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
+github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
+github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
+github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
+github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU=
+github.com/moby/sys/signal v0.6.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg=
+github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ=
+github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs=
+github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
+github.com/moby/term v0.0.0-20210610120745-9d4ed1856297/go.mod h1:vgPCkQMyxTZ7IDy8SXRufE172gr8+K/JE/7hHFxHW3A=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
+github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
+github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
+github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
+github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
+github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
+github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
+github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
+github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
+github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
+github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
+github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
+github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
+github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0=
+github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
+github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
+github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
+github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
+github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0=
+github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
+github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
+github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
+github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
+github.com/opencontainers/go-digest v1.0.0-rc1.0.20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/image-spec v1.0.2-0.20211117181255-693428a734f5/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
+github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
+github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
+github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
+github.com/opencontainers/runc v1.0.0-rc93/go.mod h1:3NOsor4w32B2tC0Zbl8Knk4Wg84SM2ImC1fxBuqJ/H0=
+github.com/opencontainers/runc v1.0.2/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0=
+github.com/opencontainers/runc v1.1.0/go.mod h1:Tj1hFw6eFWp/o33uxGf5yF2BX5yz2Z6iptFpuvbbKqc=
+github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-spec v1.0.3-0.20200929063507-e6143ca7d51d/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs=
+github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqiriPsEqVhc+svHE=
+github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo=
+github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8=
+github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI=
+github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
+github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
+github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
+github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
+github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
+github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
+github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
+github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
+github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
+github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
+github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
+github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
+github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
+github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
+github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
+github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
+github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
+github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
+github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pocketbase/dbx v1.6.0 h1:iPQi99GpaMRne0KRVnd/kCfxayCP/f4QDb6hGxMRI3I=
-github.com/pocketbase/dbx v1.6.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
+github.com/pocketbase/dbx v1.7.0 h1:MY6/up//aIeH6WA8VqYt3EeQt082bEdKcUDcEF4UrWw=
+github.com/pocketbase/dbx v1.7.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
+github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
+github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
+github.com/prometheus/alertmanager v0.24.0/go.mod h1:r6fy/D7FRuZh5YbnX6J3MBY0eI4Pb5yPYS7/bPSXXqI=
+github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
+github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
+github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
+github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
+github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
+github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
+github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
+github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
+github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
+github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
+github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
+github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
+github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
+github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
+github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
+github.com/prometheus/common v0.29.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/common v0.34.0/go.mod h1:gB3sOl7P0TvJabZpLY5uQMpUqRCPPCyRLCZYc7JZTNE=
+github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
+github.com/prometheus/common/assets v0.1.0/go.mod h1:D17UVUE12bHbim7HzwUvtqm6gwBEaDQ0F+hIGbFbccI=
+github.com/prometheus/common/assets v0.2.0/go.mod h1:D17UVUE12bHbim7HzwUvtqm6gwBEaDQ0F+hIGbFbccI=
+github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI=
+github.com/prometheus/exporter-toolkit v0.7.1/go.mod h1:ZUBIj498ePooX9t/2xtDjeQYwvRpiPP2lh5u4iblj2g=
+github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.0.0-20190522114515-bc1a522cf7b1/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
+github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
+github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
+github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/prometheus v0.35.0/go.mod h1:7HaLx5kEPKJ0GDgbODG0fZgXbQ8K/XjZNJXQmbmgQlY=
+github.com/prometheus/prometheus v0.37.0/go.mod h1:egARUgz+K93zwqsVIAneFlLZefyGOON44WyAp4Xqbbk=
+github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
+github.com/rakyll/embedmd v0.0.0-20171029212350-c8060a0752a2/go.mod h1:7jOTMgqac46PZcF54q6l2hkLEG8op93fZu61KmxWDV4=
+github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa h1:tEkEyxYeZ43TR55QU/hsIt9aRGBxbgGuz9CGykjvogY=
+github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
+github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
+github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/scaleway/scaleway-sdk-go v1.0.0-beta.9/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg=
+github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
+github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
+github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
+github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
+github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
+github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
+github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
+github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
+github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
+github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
+github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
-github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
-github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
+github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
+github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
+github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
+github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
+github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
+github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
+github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
+github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
+github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
+github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8=
+github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
+github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
+github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
+github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
+github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
+github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
+github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
+github.com/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I=
+github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0=
+github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
+github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
+github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
+github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
+github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
+github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
+github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
-github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
+github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
+github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
+github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
+github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI=
+github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
+github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
+github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
+github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI=
+github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
+github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
+github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
+github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
+github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
+github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
+github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
+go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
+go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
+go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
+go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3CSBatqGNg7GRmsnfLWtoW60w4eDYfh7vHDg=
+go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
+go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
+go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
+go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0=
+go.etcd.io/etcd/pkg/v3 v3.5.0/go.mod h1:UzJGatBQ1lXChBkQF0AuAtkRQMYnHubxAEYIrC3MSsE=
+go.etcd.io/etcd/raft/v3 v3.5.0/go.mod h1:UFOHSIvO/nKwd4lhkwabrTD3cqW5yVyYYf/KlD00Szc=
+go.etcd.io/etcd/server/v3 v3.5.0/go.mod h1:3Ah5ruV+M+7RZr0+Y/5mNLwC+eQlni+mQmOVdCRJoS4=
+go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg=
+go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng=
+go.mongodb.org/mongo-driver v1.8.3/go.mod h1:0sQWfOeY63QTntERDJJ/0SuKK0T1uVSgKCuAROlKEPY=
+go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0=
+go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
+go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -586,14 +1442,60 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
+go.opentelemetry.io/contrib v0.20.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0/go.mod h1:oVGt1LRbBOBq1A5BQLlUg9UaU/54aiHw8cgjV3aWZ/E=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.28.0/go.mod h1:vEhqr0m4eTc+DWxfsXoXue2GBgV2uUwVznkGIHW/e5w=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0/go.mod h1:2AboqHi0CiIZU0qwhtUfCYD1GeUzvvIXWNkhDt7ZMG4=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.31.0/go.mod h1:PFmBsWbldL1kiWZk9+0LBZz2brhByaGsvp6pRICMlPE=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.32.0/go.mod h1:5eCOqeGphOyz6TsY3ZDNjE33SM/TFAK3RGuCL2naTgY=
+go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo=
+go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs=
+go.opentelemetry.io/otel v1.6.0/go.mod h1:bfJD2DZVw0LBxghOTlgnlI0CV3hLDu9XF/QKOUXMTQQ=
+go.opentelemetry.io/otel v1.6.1/go.mod h1:blzUabWHkX6LJewxvadmzafgh/wnvBSDBdOuwkAtrWQ=
+go.opentelemetry.io/otel v1.7.0/go.mod h1:5BdUoMIz5WEs0vt0CUEMtSSaTSHBBVwrhnz7+nrD5xk=
+go.opentelemetry.io/otel/exporters/otlp v0.20.0/go.mod h1:YIieizyaN77rtLJra0buKiNBOm9XQfkPEKBeuhoMwAM=
+go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.3.0/go.mod h1:VpP4/RMn8bv8gNo9uK7/IMY4mtWLELsS+JIP0inH0h4=
+go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.6.1/go.mod h1:NEu79Xo32iVb+0gVNV8PMd7GoWqnyDXRlj04yFjqz40=
+go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.7.0/go.mod h1:M1hVZHNxcbkAlcvrOMlpQ4YOO3Awf+4N2dxkZL3xm04=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.3.0/go.mod h1:hO1KLR7jcKaDDKDkvI9dP/FIhpmna5lkqPUQdEjFAM8=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.6.1/go.mod h1:YJ/JbY5ag/tSQFXzH3mtDmHqzF3aFn3DI/aB1n7pt4w=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.7.0/go.mod h1:ceUgdyfNv4h4gLxHR0WNfDiiVmZFodZhZSbOLhpxqXE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.3.0/go.mod h1:keUU7UfnwWTWpJ+FWnyqmogPa82nuU5VUANFq49hlMY=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.6.1/go.mod h1:UJJXJj0rltNIemDMwkOJyggsvyMG9QHfJeFH0HS5JjM=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.7.0/go.mod h1:E+/KKhwOSw8yoPxSSuUHG6vKppkvhN+S1Jc7Nib3k3o=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.3.0/go.mod h1:QNX1aly8ehqqX1LEa6YniTU7VY9I6R3X/oPxhGdTceE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.6.1/go.mod h1:DAKwdo06hFLc0U88O10x4xnb5sc7dDRDqRuiN+io8JE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.7.0/go.mod h1:aFXT9Ng2seM9eizF+LfKiyPBGy8xIZKwhusC1gIu3hA=
+go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU=
+go.opentelemetry.io/otel/metric v0.28.0/go.mod h1:TrzsfQAmQaB1PDcdhBauLMk7nyyg9hm+GoQq/ekE9Iw=
+go.opentelemetry.io/otel/metric v0.30.0/go.mod h1:/ShZ7+TS4dHzDFmfi1kSXMhMVubNoP0oIaBp70J6UXU=
+go.opentelemetry.io/otel/oteltest v0.20.0/go.mod h1:L7bgKf9ZB7qCwT9Up7i9/pn0PWIa9FqQ2IQ8LoxiGnw=
+go.opentelemetry.io/otel/sdk v0.20.0/go.mod h1:g/IcepuwNsoiX5Byy2nNV0ySUF1em498m7hBWC279Yc=
+go.opentelemetry.io/otel/sdk v1.3.0/go.mod h1:rIo4suHNhQwBIPg9axF8V9CA72Wz2mKF1teNrup8yzs=
+go.opentelemetry.io/otel/sdk v1.6.1/go.mod h1:IVYrddmFZ+eJqu2k38qD3WezFR2pymCzm8tdxyh3R4E=
+go.opentelemetry.io/otel/sdk v1.7.0/go.mod h1:uTEOTwaqIVuTGiJN7ii13Ibp75wJmYUDe374q6cZwUU=
+go.opentelemetry.io/otel/sdk/export/metric v0.20.0/go.mod h1:h7RBNMsDJ5pmI1zExLi+bJK+Dr8NQCh0qGhm1KDnNlE=
+go.opentelemetry.io/otel/sdk/metric v0.20.0/go.mod h1:knxiS8Xd4E/N+ZqKmUPf3gTTZ4/0TjTXukfxjzSTpHE=
+go.opentelemetry.io/otel/trace v0.20.0/go.mod h1:6GjCW8zgDjwGHGa6GkyeB8+/5vjT16gUEi0Nf1iBdgw=
+go.opentelemetry.io/otel/trace v1.3.0/go.mod h1:c/VDhno8888bvQYmbYLqe41/Ldmr/KKunbvWM4/fEjk=
+go.opentelemetry.io/otel/trace v1.6.0/go.mod h1:qs7BrU5cZ8dXQHBGxHMOxwME/27YH2qEp4/+tZLLwJE=
+go.opentelemetry.io/otel/trace v1.6.1/go.mod h1:RkFRM1m0puWIq10oxImnGEduNBzxiN7TXluRBtE+5j0=
+go.opentelemetry.io/otel/trace v1.7.0/go.mod h1:fzLSB9nqR2eXzxPXb2JW9IKE+ScyXA48yyE4TNvoHqU=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
+go.opentelemetry.io/proto/otlp v0.11.0/go.mod h1:QpEjXPrNQzrFDZgoTo49dgHR9RYRSrg3NAKnUGl9YpQ=
+go.opentelemetry.io/proto/otlp v0.12.1/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
+go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
+go.opentelemetry.io/proto/otlp v0.16.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/automaxprocs v1.5.1/go.mod h1:BF4eumQw0P9GtnuxxovUd06vwm1o18oMzFtK66vU6XU=
+go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
@@ -603,28 +1505,44 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
+go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
-gocloud.dev v0.26.0 h1:4rM/SVL0lLs+rhC0Gmc+gt/82DBpb7nbpIZKXXnfMXg=
-gocloud.dev v0.26.0/go.mod h1:mkUgejbnbLotorqDyvedJO20XcZNTynmSeVSQS9btVg=
+gocloud.dev v0.27.0 h1:j0WTUsnKTxCsWO7y8T+YCiBZUmLl9w/WIowqAY3yo0g=
+gocloud.dev v0.27.0/go.mod h1:YlYKhYsY5/1JdHGWQDkAuqkezVKowu7qbe9aIeUF6p0=
+golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
+golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220824171710-5757bc0c5503 h1:vJ2V3lFLg+bBhgroYuRfyN583UzVveQmIXjc8T/y3to=
-golang.org/x/crypto v0.0.0-20220824171710-5757bc0c5503/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 h1:a5Yg6ylndHHYJqIPrdq0AhvR6KTvDTAvgBtaidhEevY=
-golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
+golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -638,10 +1556,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
-golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
-golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY=
-golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
+golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
+golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -666,22 +1582,37 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
+golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
+golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -692,35 +1623,49 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
+golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
+golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.0.0-20220822230855-b0a4917ee28c h1:JVAXQ10yGGVbSyoer5VILysz6YKjdNT2bsvlayjqhes=
-golang.org/x/net v0.0.0-20220822230855-b0a4917ee28c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
-golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
-golang.org/x/net v0.0.0-20220921155015-db77216a4ee9 h1:SdDGdqRuKrF2R4XGcnPzcvZ63c/55GvhoHUus0o+BNI=
-golang.org/x/net v0.0.0-20220921155015-db77216a4ee9/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
+golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -743,14 +1688,16 @@ golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
-golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 h1:2o1E+E8TpNLklK9nHiPiK1uzIYrIHt+cQx3ynCwq9V8=
-golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
-golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 h1:lxqLZaMad/dJHMFZH0NiNpiEZI/nhgWhe4wgzpE+MuA=
-golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
+golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
+golang.org/x/oauth2 v0.0.0-20220628200809-02e64fa58f26/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
+golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
+golang.org/x/oauth2 v0.1.0 h1:isLCZuhj4v+tYv7eskaN4v/TM+A1begWWgyVJDdl1+Y=
+golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -759,30 +1706,62 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190522044717-8097e1b27ff5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190812073006-9eafafc0a87e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -790,65 +1769,96 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200922070232-aee5d888a860/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201117170446-d9b008d0a637/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210903071746-97244b99971b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
-golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc=
-golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
+golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc=
-golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w=
-golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
+golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -857,37 +1867,58 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=
+golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20220920022843-2ce7c2934d45 h1:yuLAip3bfURHClMG9VBdzPrQvCWjWiWUTBGV+/fCbUs=
-golang.org/x/time v0.0.0-20220920022843-2ce7c2934d45/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA=
+golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190706070813-72ffa07ba3db/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -908,26 +1939,36 @@ golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjs
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200616133436-c1934b75d054/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
+golang.org/x/tools v0.0.0-20200916195026-c9a70fc28ce3/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
+golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
+golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
+golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE=
+golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -936,10 +1977,11 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0=
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
+google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
+google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -974,11 +2016,7 @@ google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3l
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
-google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
-google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
-google.golang.org/api v0.68.0/go.mod h1:sOM8pTpwgflXRhz+oC8H2Dr+UcbMqkPPWNJo88Q7TH8=
-google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80=
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
@@ -986,11 +2024,14 @@ google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69
google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
-google.golang.org/api v0.94.0 h1:KtKM9ru3nzQioV1HLlUf1cR7vMYJIpgls5VhAYQXIwA=
-google.golang.org/api v0.94.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI=
-google.golang.org/api v0.96.0 h1:F60cuQPJq7K7FzsxMYHAUJSiXh2oKctHxBMbDygxhfM=
-google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
+google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
+google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.91.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.101.0 h1:lJPPeEBIRxGpGLwnBTam1NPEM8Z2BmmXEd3z812pjwM=
+google.golang.org/api v0.101.0/go.mod h1:CjxAAWWt3A3VrUE2IGDY2bgK5qhoG/OkyWVlYcP05MY=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
@@ -998,11 +2039,14 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190522204451-c2c4e71fbf69/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
+google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
@@ -1011,6 +2055,7 @@ google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvx
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200117163144-32f20d992d24/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
@@ -1019,17 +2064,21 @@ google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfG
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
@@ -1066,21 +2115,14 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
-google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220329172620-7be39ac1afc7/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
@@ -1088,18 +2130,27 @@ google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc h1:Nf+EdcTLHR8qDNN/KfkQL0u0ssxt9OhbaWCl5C0ucEI=
-google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
-google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006 h1:mmbq5q8M1t7dhkLw320YK4PsOXm6jdnUAkErImaIqOg=
-google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw=
+google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220802133213-ce4fa296bf78/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
+google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c h1:QgY/XxIAIeccR+Ca/rDdKubLIU9rcJ3xfy1DC/Wd2Oo=
+google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo=
+google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
+google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
@@ -1122,13 +2173,16 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
-google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
-google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
+google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY=
+google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@@ -1146,19 +2200,51 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
+gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
+gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
+gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
+gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
+honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1166,51 +2252,104 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo=
+k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ=
+k8s.io/api v0.20.6/go.mod h1:X9e8Qag6JV/bL5G6bU8sdVRltWKmdHsFUGS3eVndqE8=
+k8s.io/api v0.22.5/go.mod h1:mEhXyLaSD1qTOf40rRiKXkc+2iCem09rWLlFwhCEiAs=
+k8s.io/api v0.23.5/go.mod h1:Na4XuKng8PXJ2JsploYYrivXrINeTaycCGcYgF91Xm8=
+k8s.io/api v0.24.2/go.mod h1:AHqbSkTm6YrQ0ObxjO3Pmp/ubFF/KuM7jU+3khoBsOg=
+k8s.io/apimachinery v0.20.1/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
+k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
+k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc=
+k8s.io/apimachinery v0.22.1/go.mod h1:O3oNtNadZdeOMxHFVxOreoznohCpy0z6mocxbZr7oJ0=
+k8s.io/apimachinery v0.22.5/go.mod h1:xziclGKwuuJ2RM5/rSFQSYAj0zdbci3DH8kj+WvyN0U=
+k8s.io/apimachinery v0.23.5/go.mod h1:BEuFMMBaIbcOqVIJqNZJXGFTP4W6AycEpb5+m/97hrM=
+k8s.io/apimachinery v0.24.2/go.mod h1:82Bi4sCzVBdpYjyI4jY6aHX+YCUchUIrZrXKedjd2UM=
+k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU=
+k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
+k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q=
+k8s.io/apiserver v0.22.5/go.mod h1:s2WbtgZAkTKt679sYtSudEQrTGWUSQAPe6MupLnlmaQ=
+k8s.io/client-go v0.20.1/go.mod h1:/zcHdt1TeWSd5HoUe6elJmHSQ6uLLgp4bIJHVEuy+/Y=
+k8s.io/client-go v0.20.4/go.mod h1:LiMv25ND1gLUdBeYxBIwKpkSC5IsozMMmOOeSJboP+k=
+k8s.io/client-go v0.20.6/go.mod h1:nNQMnOvEUEsOzRRFIIkdmYOjAZrC8bgq0ExboWSU1I0=
+k8s.io/client-go v0.22.5/go.mod h1:cs6yf/61q2T1SdQL5Rdcjg9J1ElXSwbjSrW2vFImM4Y=
+k8s.io/client-go v0.23.5/go.mod h1:flkeinTO1CirYgzMPRWxUCnV0G4Fbu2vLhYCObnt/r4=
+k8s.io/client-go v0.24.2/go.mod h1:zg4Xaoo+umDsfCWr4fCnmLEtQXyCNXCvJuSsglNcV30=
+k8s.io/code-generator v0.19.7/go.mod h1:lwEq3YnLYb/7uVXLorOJfxg+cUu2oihFhHZ0n9NIla0=
+k8s.io/component-base v0.20.1/go.mod h1:guxkoJnNoh8LNrbtiQOlyp2Y2XFCZQmrcg2n/DeYNLk=
+k8s.io/component-base v0.20.4/go.mod h1:t4p9EdiagbVCJKrQ1RsA5/V4rFQNDfRlevJajlGwgjI=
+k8s.io/component-base v0.20.6/go.mod h1:6f1MPBAeI+mvuts3sIdtpjljHWBQ2cIy38oBIWMYnrM=
+k8s.io/component-base v0.22.5/go.mod h1:VK3I+TjuF9eaa+Ln67dKxhGar5ynVbwnGrUiNF4MqCI=
+k8s.io/cri-api v0.17.3/go.mod h1:X1sbHmuXhwaHs9xxYffLqJogVsnI+f6cPRcgPel7ywM=
+k8s.io/cri-api v0.20.1/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI=
+k8s.io/cri-api v0.20.4/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI=
+k8s.io/cri-api v0.20.6/go.mod h1:ew44AjNXwyn1s0U4xCKGodU7J1HzBeZ1MpGrpa5r8Yc=
+k8s.io/cri-api v0.23.1/go.mod h1:REJE3PSU0h/LOV1APBrupxrEJqnoxZC8KWzkBUHwrK4=
+k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
+k8s.io/gengo v0.0.0-20200428234225-8167cfdcfc14/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
+k8s.io/gengo v0.0.0-20201113003025-83324d819ded/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E=
+k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E=
+k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
+k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE=
+k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
+k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
+k8s.io/klog/v2 v2.9.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec=
+k8s.io/klog/v2 v2.30.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
+k8s.io/klog/v2 v2.40.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
+k8s.io/klog/v2 v2.60.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
+k8s.io/klog/v2 v2.70.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
+k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o=
+k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM=
+k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw=
+k8s.io/kube-openapi v0.0.0-20211109043538-20434351676c/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw=
+k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk=
+k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42/go.mod h1:Z/45zLw8lUo4wdiUkI+v/ImEGAvu3WatcZl3lPMR4Rk=
+k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk=
+k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
-modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
-modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
-modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
-modernc.org/cc/v3 v3.39.0 h1:sd+UyMj63acEV1jaFqxFGPQfllSncJgL+roJjFlo6lI=
-modernc.org/cc/v3 v3.39.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
-modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
-modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
+modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
+modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
+modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8 h1:0+dsXf0zeLx9ixj4nilg6jKe5Bg1ilzBwSFq4kJmIUc=
+modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
-modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
-modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
-modernc.org/libc v1.17.0 h1:nbL2Lv0I323wLc1GmTh/AqVtI9JeBVc7Nhapdg9EONs=
-modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
-modernc.org/libc v1.19.0 h1:bXyVhGQg6KIClTr8FMVIDPl7jtbcs7aS5WP7vLDaxPs=
-modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
-modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
-modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/libc v1.21.4 h1:CzTlumWeIbPV5/HVIMzYHNPCRP8uiU/CWiN2gtd/Qu8=
+modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
-modernc.org/memory v1.2.0 h1:zXehBrt9n+Pjn+4RoRCZ0KqRA/0ePFqcecxZ/hXCIVw=
-modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
-modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
-modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
-modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
-modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
-modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
-modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
-modernc.org/strutil v1.1.2 h1:iFBDH6j1Z0bN/Q9udJnnFoFpENA4252qe/7/5woE5MI=
-modernc.org/strutil v1.1.2/go.mod h1:OYajnUAcI/MX+XD/Wx7v1bbdvcQSvxgtb0gC+u3d3eg=
+modernc.org/sqlite v1.19.3 h1:dIoagx6yIQT3V/zOSeAyZ8OqQyEr17YTgETOXTZNJMA=
+modernc.org/sqlite v1.19.3/go.mod h1:xiyJD7FY8mTZXnQwE/gEL1STtFrrnDx03V8KhVQmcr8=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
-modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
-modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
-modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
-nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
+modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
+nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.14/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.15/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.22/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
+sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs=
+sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2/go.mod h1:B+TnT182UBxE84DiCz4CVE26eOSDAeYCpfDnC2kdKMY=
+sigs.k8s.io/structured-merge-diff/v4 v4.0.1/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
+sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
+sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
+sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
+sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
+sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
+sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
diff --git a/mails/user.go b/mails/record.go
similarity index 62%
rename from mails/user.go
rename to mails/record.go
index d21909a8..6371f6b4 100644
--- a/mails/user.go
+++ b/mails/record.go
@@ -7,25 +7,26 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/mails/templates"
"github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tokens"
)
-// SendUserPasswordReset sends a password reset request email to the specified user.
-func SendUserPasswordReset(app core.App, user *models.User) error {
- token, tokenErr := tokens.NewUserResetPasswordToken(app, user)
+// SendRecordPasswordReset sends a password reset request email to the specified user.
+func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
+ token, tokenErr := tokens.NewRecordResetPasswordToken(app, authRecord)
if tokenErr != nil {
return tokenErr
}
mailClient := app.NewMailClient()
- event := &core.MailerUserEvent{
+ event := &core.MailerRecordEvent{
MailClient: mailClient,
- User: user,
+ Record: authRecord,
Meta: map[string]any{"token": token},
}
- sendErr := app.OnMailerBeforeUserResetPasswordSend().Trigger(event, func(e *core.MailerUserEvent) error {
+ sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
settings := app.Settings()
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ResetPasswordTemplate)
@@ -38,7 +39,7 @@ func SendUserPasswordReset(app core.App, user *models.User) error {
Name: settings.Meta.SenderName,
Address: settings.Meta.SenderAddress,
},
- mail.Address{Address: e.User.Email},
+ mail.Address{Address: e.Record.GetString(schema.FieldNameEmail)},
subject,
body,
nil,
@@ -46,28 +47,28 @@ func SendUserPasswordReset(app core.App, user *models.User) error {
})
if sendErr == nil {
- app.OnMailerAfterUserResetPasswordSend().Trigger(event)
+ app.OnMailerAfterRecordResetPasswordSend().Trigger(event)
}
return sendErr
}
-// SendUserVerification sends a verification request email to the specified user.
-func SendUserVerification(app core.App, user *models.User) error {
- token, tokenErr := tokens.NewUserVerifyToken(app, user)
+// SendRecordVerification sends a verification request email to the specified user.
+func SendRecordVerification(app core.App, authRecord *models.Record) error {
+ token, tokenErr := tokens.NewRecordVerifyToken(app, authRecord)
if tokenErr != nil {
return tokenErr
}
mailClient := app.NewMailClient()
- event := &core.MailerUserEvent{
+ event := &core.MailerRecordEvent{
MailClient: mailClient,
- User: user,
+ Record: authRecord,
Meta: map[string]any{"token": token},
}
- sendErr := app.OnMailerBeforeUserVerificationSend().Trigger(event, func(e *core.MailerUserEvent) error {
+ sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
settings := app.Settings()
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.VerificationTemplate)
@@ -80,7 +81,7 @@ func SendUserVerification(app core.App, user *models.User) error {
Name: settings.Meta.SenderName,
Address: settings.Meta.SenderAddress,
},
- mail.Address{Address: e.User.Email},
+ mail.Address{Address: e.Record.GetString(schema.FieldNameEmail)},
subject,
body,
nil,
@@ -88,31 +89,31 @@ func SendUserVerification(app core.App, user *models.User) error {
})
if sendErr == nil {
- app.OnMailerAfterUserVerificationSend().Trigger(event)
+ app.OnMailerAfterRecordVerificationSend().Trigger(event)
}
return sendErr
}
// SendUserChangeEmail sends a change email confirmation email to the specified user.
-func SendUserChangeEmail(app core.App, user *models.User, newEmail string) error {
- token, tokenErr := tokens.NewUserChangeEmailToken(app, user, newEmail)
+func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string) error {
+ token, tokenErr := tokens.NewRecordChangeEmailToken(app, record, newEmail)
if tokenErr != nil {
return tokenErr
}
mailClient := app.NewMailClient()
- event := &core.MailerUserEvent{
+ event := &core.MailerRecordEvent{
MailClient: mailClient,
- User: user,
+ Record: record,
Meta: map[string]any{
"token": token,
"newEmail": newEmail,
},
}
- sendErr := app.OnMailerBeforeUserChangeEmailSend().Trigger(event, func(e *core.MailerUserEvent) error {
+ sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
settings := app.Settings()
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ConfirmEmailChangeTemplate)
@@ -133,7 +134,7 @@ func SendUserChangeEmail(app core.App, user *models.User, newEmail string) error
})
if sendErr == nil {
- app.OnMailerAfterUserChangeEmailSend().Trigger(event)
+ app.OnMailerAfterRecordChangeEmailSend().Trigger(event)
}
return sendErr
diff --git a/mails/user_test.go b/mails/record_test.go
similarity index 64%
rename from mails/user_test.go
rename to mails/record_test.go
index ddaf6463..2f74c402 100644
--- a/mails/user_test.go
+++ b/mails/record_test.go
@@ -8,16 +8,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
-func TestSendUserPasswordReset(t *testing.T) {
+func TestSendRecordPasswordReset(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// ensure that action url normalization will be applied
testApp.Settings().Meta.AppUrl = "http://localhost:8090////"
- user, _ := testApp.Dao().FindUserByEmail("test@example.com")
+ user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
- err := mails.SendUserPasswordReset(testApp, user)
+ err := mails.SendRecordPasswordReset(testApp, user)
if err != nil {
t.Fatal(err)
}
@@ -27,7 +27,7 @@ func TestSendUserPasswordReset(t *testing.T) {
}
expectedParts := []string{
- "http://localhost:8090/_/#/users/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
+ "http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
@@ -36,13 +36,13 @@ func TestSendUserPasswordReset(t *testing.T) {
}
}
-func TestSendUserVerification(t *testing.T) {
+func TestSendRecordVerification(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- user, _ := testApp.Dao().FindUserByEmail("test@example.com")
+ user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
- err := mails.SendUserVerification(testApp, user)
+ err := mails.SendRecordVerification(testApp, user)
if err != nil {
t.Fatal(err)
}
@@ -52,7 +52,7 @@ func TestSendUserVerification(t *testing.T) {
}
expectedParts := []string{
- "http://localhost:8090/_/#/users/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
+ "http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
@@ -61,13 +61,13 @@ func TestSendUserVerification(t *testing.T) {
}
}
-func TestSendUserChangeEmail(t *testing.T) {
+func TestSendRecordChangeEmail(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- user, _ := testApp.Dao().FindUserByEmail("test@example.com")
+ user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
- err := mails.SendUserChangeEmail(testApp, user, "new_test@example.com")
+ err := mails.SendRecordChangeEmail(testApp, user, "new_test@example.com")
if err != nil {
t.Fatal(err)
}
@@ -77,7 +77,7 @@ func TestSendUserChangeEmail(t *testing.T) {
}
expectedParts := []string{
- "http://localhost:8090/_/#/users/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
+ "http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
diff --git a/migrations/1640988000_init.go b/migrations/1640988000_init.go
index 439c9138..9533f375 100644
--- a/migrations/1640988000_init.go
+++ b/migrations/1640988000_init.go
@@ -2,7 +2,6 @@
package migrations
import (
- "fmt"
"path/filepath"
"runtime"
@@ -11,6 +10,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/migrate"
+ "github.com/pocketbase/pocketbase/tools/types"
)
var AppMigrations migrate.MigrationsList
@@ -46,21 +46,10 @@ func init() {
[[updated]] TEXT DEFAULT "" NOT NULL
);
- CREATE TABLE {{_users}} (
- [[id]] TEXT PRIMARY KEY,
- [[verified]] BOOLEAN DEFAULT FALSE NOT NULL,
- [[email]] TEXT UNIQUE NOT NULL,
- [[tokenKey]] TEXT UNIQUE NOT NULL,
- [[passwordHash]] TEXT NOT NULL,
- [[lastResetSentAt]] TEXT DEFAULT "" NOT NULL,
- [[lastVerificationSentAt]] TEXT DEFAULT "" NOT NULL,
- [[created]] TEXT DEFAULT "" NOT NULL,
- [[updated]] TEXT DEFAULT "" NOT NULL
- );
-
CREATE TABLE {{_collections}} (
[[id]] TEXT PRIMARY KEY,
[[system]] BOOLEAN DEFAULT FALSE NOT NULL,
+ [[type]] TEXT DEFAULT "base" NOT NULL,
[[name]] TEXT UNIQUE NOT NULL,
[[schema]] JSON DEFAULT "[]" NOT NULL,
[[listRule]] TEXT DEFAULT NULL,
@@ -68,6 +57,7 @@ func init() {
[[createRule]] TEXT DEFAULT NULL,
[[updateRule]] TEXT DEFAULT NULL,
[[deleteRule]] TEXT DEFAULT NULL,
+ [[options]] JSON DEFAULT "{}" NOT NULL,
[[created]] TEXT DEFAULT "" NOT NULL,
[[updated]] TEXT DEFAULT "" NOT NULL
);
@@ -79,6 +69,21 @@ func init() {
[[created]] TEXT DEFAULT "" NOT NULL,
[[updated]] TEXT DEFAULT "" NOT NULL
);
+
+ CREATE TABLE {{_externalAuths}} (
+ [[id]] TEXT PRIMARY KEY,
+ [[collectionId]] TEXT NOT NULL,
+ [[recordId]] TEXT NOT NULL,
+ [[provider]] TEXT NOT NULL,
+ [[providerId]] TEXT NOT NULL,
+ [[created]] TEXT DEFAULT "" NOT NULL,
+ [[updated]] TEXT DEFAULT "" NOT NULL,
+ ---
+ FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
+ );
+
+ CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
+ CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
`).Execute()
if tablesErr != nil {
return tablesErr
@@ -86,62 +91,61 @@ func init() {
// inserts the system profiles collection
// -----------------------------------------------------------
- profileOwnerRule := fmt.Sprintf("%s = @request.user.id", models.ProfileCollectionUserFieldName)
- collection := &models.Collection{
- Name: models.ProfileCollectionName,
- System: true,
- CreateRule: &profileOwnerRule,
- ListRule: &profileOwnerRule,
- ViewRule: &profileOwnerRule,
- UpdateRule: &profileOwnerRule,
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Id: "pbfielduser",
- Name: models.ProfileCollectionUserFieldName,
- Type: schema.FieldTypeUser,
- Unique: true,
- Required: true,
- System: true,
- Options: &schema.UserOptions{
- MaxSelect: 1,
- CascadeDelete: true,
- },
- },
- &schema.SchemaField{
- Id: "pbfieldname",
- Name: "name",
- Type: schema.FieldTypeText,
- Options: &schema.TextOptions{},
- },
- &schema.SchemaField{
- Id: "pbfieldavatar",
- Name: "avatar",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 1,
- MaxSize: 5242880,
- MimeTypes: []string{
- "image/jpg",
- "image/jpeg",
- "image/png",
- "image/svg+xml",
- "image/gif",
- },
- },
- },
- ),
- }
- collection.Id = "systemprofiles0"
- collection.MarkAsNew()
+ usersCollection := &models.Collection{}
+ usersCollection.MarkAsNew()
+ usersCollection.Id = "_pb_users_auth_"
+ usersCollection.Name = "users"
+ usersCollection.Type = models.CollectionTypeAuth
+ usersCollection.ListRule = types.Pointer("id = @request.auth.id")
+ usersCollection.ViewRule = types.Pointer("id = @request.auth.id")
+ usersCollection.CreateRule = types.Pointer("")
+ usersCollection.UpdateRule = types.Pointer("id = @request.auth.id")
+ usersCollection.DeleteRule = types.Pointer("id = @request.auth.id")
- return daos.New(db).SaveCollection(collection)
+ // set auth options
+ usersCollection.SetOptions(models.CollectionAuthOptions{
+ ManageRule: nil,
+ AllowOAuth2Auth: true,
+ AllowUsernameAuth: true,
+ AllowEmailAuth: true,
+ MinPasswordLength: 8,
+ RequireEmail: false,
+ })
+
+ // set optional default fields
+ usersCollection.Schema = schema.NewSchema(
+ &schema.SchemaField{
+ Id: "users_name",
+ Type: schema.FieldTypeText,
+ Name: "name",
+ Options: &schema.TextOptions{},
+ },
+ &schema.SchemaField{
+ Id: "users_avatar",
+ Type: schema.FieldTypeFile,
+ Name: "avatar",
+ Options: &schema.FileOptions{
+ MaxSelect: 1,
+ MaxSize: 5242880,
+ MimeTypes: []string{
+ "image/jpg",
+ "image/jpeg",
+ "image/png",
+ "image/svg+xml",
+ "image/gif",
+ },
+ },
+ },
+ )
+
+ return daos.New(db).SaveCollection(usersCollection)
}, func(db dbx.Builder) error {
tables := []string{
+ "users",
+ "_externalAuths",
"_params",
"_collections",
- "_users",
"_admins",
- models.ProfileCollectionName,
}
for _, name := range tables {
diff --git a/migrations/1661586591_add_externalAuths_table.go b/migrations/1661586591_add_externalAuths_table.go
deleted file mode 100644
index 71bde5d6..00000000
--- a/migrations/1661586591_add_externalAuths_table.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package migrations
-
-import "github.com/pocketbase/dbx"
-
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- _, createErr := db.NewQuery(`
- CREATE TABLE {{_externalAuths}} (
- [[id]] TEXT PRIMARY KEY,
- [[userId]] TEXT NOT NULL,
- [[provider]] TEXT NOT NULL,
- [[providerId]] TEXT NOT NULL,
- [[created]] TEXT DEFAULT "" NOT NULL,
- [[updated]] TEXT DEFAULT "" NOT NULL,
- ---
- FOREIGN KEY ([[userId]]) REFERENCES {{_users}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
- );
-
- CREATE UNIQUE INDEX _externalAuths_userId_provider_idx on {{_externalAuths}} ([[userId]], [[provider]]);
- CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
- `).Execute()
- if createErr != nil {
- return createErr
- }
-
- // remove the unique email index from the _users table and
- // replace it with partial index
- _, alterErr := db.NewQuery(`
- -- crate new users table
- CREATE TABLE {{_newUsers}} (
- [[id]] TEXT PRIMARY KEY,
- [[verified]] BOOLEAN DEFAULT FALSE NOT NULL,
- [[email]] TEXT DEFAULT "" NOT NULL,
- [[tokenKey]] TEXT NOT NULL,
- [[passwordHash]] TEXT NOT NULL,
- [[lastResetSentAt]] TEXT DEFAULT "" NOT NULL,
- [[lastVerificationSentAt]] TEXT DEFAULT "" NOT NULL,
- [[created]] TEXT DEFAULT "" NOT NULL,
- [[updated]] TEXT DEFAULT "" NOT NULL
- );
-
- -- copy all data from the old users table to the new one
- INSERT INTO {{_newUsers}} SELECT * FROM {{_users}};
-
- -- drop old table
- DROP TABLE {{_users}};
-
- -- rename new table
- ALTER TABLE {{_newUsers}} RENAME TO {{_users}};
-
- -- create named indexes
- CREATE UNIQUE INDEX _users_email_idx ON {{_users}} ([[email]]) WHERE [[email]] != "";
- CREATE UNIQUE INDEX _users_tokenKey_idx ON {{_users}} ([[tokenKey]]);
- `).Execute()
- if alterErr != nil {
- return alterErr
- }
-
- return nil
- }, func(db dbx.Builder) error {
- if _, err := db.DropTable("_externalAuths").Execute(); err != nil {
- return err
- }
-
- // drop the partial email unique index and replace it with normal unique index
- _, indexErr := db.NewQuery(`
- DROP INDEX IF EXISTS _users_email_idx;
- CREATE UNIQUE INDEX _users_email_idx on {{_users}} ([[email]]);
- `).Execute()
- if indexErr != nil {
- return indexErr
- }
-
- return nil
- })
-}
diff --git a/models/admin.go b/models/admin.go
index ff0b1289..c85e133c 100644
--- a/models/admin.go
+++ b/models/admin.go
@@ -1,13 +1,67 @@
package models
-var _ Model = (*Admin)(nil)
+import (
+ "errors"
+
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "golang.org/x/crypto/bcrypt"
+)
+
+var (
+ _ Model = (*Admin)(nil)
+)
type Admin struct {
- BaseAccount
+ BaseModel
- Avatar int `db:"avatar" json:"avatar"`
+ Avatar int `db:"avatar" json:"avatar"`
+ Email string `db:"email" json:"email"`
+ TokenKey string `db:"tokenKey" json:"-"`
+ PasswordHash string `db:"passwordHash" json:"-"`
+ LastResetSentAt types.DateTime `db:"lastResetSentAt" json:"-"`
}
+// TableName returns the Admin model SQL table name.
func (m *Admin) TableName() string {
return "_admins"
}
+
+// ValidatePassword validates a plain password against the model's password.
+func (m *Admin) ValidatePassword(password string) bool {
+ bytePassword := []byte(password)
+ bytePasswordHash := []byte(m.PasswordHash)
+
+ // comparing the password with the hash
+ err := bcrypt.CompareHashAndPassword(bytePasswordHash, bytePassword)
+
+ // nil means it is a match
+ return err == nil
+}
+
+// SetPassword sets cryptographically secure string to `model.Password`.
+//
+// Additionally this method also resets the LastResetSentAt and the TokenKey fields.
+func (m *Admin) SetPassword(password string) error {
+ if password == "" {
+ return errors.New("The provided plain password is empty")
+ }
+
+ // hash the password
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
+ if err != nil {
+ return err
+ }
+
+ m.PasswordHash = string(hashedPassword)
+ m.LastResetSentAt = types.DateTime{} // reset
+
+ // invalidate previously issued tokens
+ return m.RefreshTokenKey()
+}
+
+// RefreshTokenKey generates and sets new random token key.
+func (m *Admin) RefreshTokenKey() error {
+ m.TokenKey = security.RandomString(50)
+ return nil
+}
diff --git a/models/admin_test.go b/models/admin_test.go
index d2fc911c..261135dd 100644
--- a/models/admin_test.go
+++ b/models/admin_test.go
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/tools/types"
)
func TestAdminTableName(t *testing.T) {
@@ -12,3 +13,92 @@ func TestAdminTableName(t *testing.T) {
t.Fatalf("Unexpected table name, got %q", m.TableName())
}
}
+
+func TestAdminValidatePassword(t *testing.T) {
+ scenarios := []struct {
+ admin models.Admin
+ password string
+ expected bool
+ }{
+ {
+ // empty passwordHash + empty pass
+ models.Admin{},
+ "",
+ false,
+ },
+ {
+ // empty passwordHash + nonempty pass
+ models.Admin{},
+ "123456",
+ false,
+ },
+ {
+ // nonempty passwordHash + empty pass
+ models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
+ "",
+ false,
+ },
+ {
+ // nonempty passwordHash + wrong pass
+ models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
+ "654321",
+ false,
+ },
+ {
+ // nonempty passwordHash + correct pass
+ models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
+ "123456",
+ true,
+ },
+ }
+
+ for i, s := range scenarios {
+ result := s.admin.ValidatePassword(s.password)
+ if result != s.expected {
+ t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
+ }
+ }
+}
+
+func TestAdminSetPassword(t *testing.T) {
+ m := models.Admin{
+ // 123456
+ PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv.",
+ LastResetSentAt: types.NowDateTime(),
+ TokenKey: "test",
+ }
+
+ // empty pass
+ err1 := m.SetPassword("")
+ if err1 == nil {
+ t.Fatal("Expected empty password error")
+ }
+
+ err2 := m.SetPassword("654321")
+ if err2 != nil {
+ t.Fatalf("Expected nil, got error %v", err2)
+ }
+
+ if !m.ValidatePassword("654321") {
+ t.Fatalf("Password is invalid")
+ }
+
+ if m.TokenKey == "test" {
+ t.Fatalf("Expected TokenKey to change, got %v", m.TokenKey)
+ }
+
+ if !m.LastResetSentAt.IsZero() {
+ t.Fatalf("Expected LastResetSentAt to be zero datetime, got %v", m.LastResetSentAt)
+ }
+}
+
+func TestAdminRefreshTokenKey(t *testing.T) {
+ m := models.Admin{TokenKey: "test"}
+
+ m.RefreshTokenKey()
+
+ // empty pass
+ if m.TokenKey == "" || m.TokenKey == "test" {
+ t.Fatalf("Expected TokenKey to change, got %q", m.TokenKey)
+ }
+}
diff --git a/models/base.go b/models/base.go
index 034e81e4..b4fb8788 100644
--- a/models/base.go
+++ b/models/base.go
@@ -2,11 +2,8 @@
package models
import (
- "errors"
-
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
- "golang.org/x/crypto/bcrypt"
)
const (
@@ -103,6 +100,9 @@ func (m *BaseModel) GetUpdated() types.DateTime {
//
// The generated id is a cryptographically random 15 characters length string.
func (m *BaseModel) RefreshId() {
+ if m.Id == "" { // no previous id
+ m.MarkAsNew()
+ }
m.Id = security.RandomStringWithAlphabet(DefaultIdLength, DefaultIdAlphabet)
}
@@ -115,57 +115,3 @@ func (m *BaseModel) RefreshCreated() {
func (m *BaseModel) RefreshUpdated() {
m.Updated = types.NowDateTime()
}
-
-// -------------------------------------------------------------------
-// BaseAccount
-// -------------------------------------------------------------------
-
-// BaseAccount defines common fields and methods used by auth models (aka. users and admins).
-type BaseAccount struct {
- BaseModel
-
- Email string `db:"email" json:"email"`
- TokenKey string `db:"tokenKey" json:"-"`
- PasswordHash string `db:"passwordHash" json:"-"`
- LastResetSentAt types.DateTime `db:"lastResetSentAt" json:"lastResetSentAt"`
-}
-
-// ValidatePassword validates a plain password against the model's password.
-func (m *BaseAccount) ValidatePassword(password string) bool {
- bytePassword := []byte(password)
- bytePasswordHash := []byte(m.PasswordHash)
-
- // comparing the password with the hash
- err := bcrypt.CompareHashAndPassword(bytePasswordHash, bytePassword)
-
- // nil means it is a match
- return err == nil
-}
-
-// SetPassword sets cryptographically secure string to `model.Password`.
-//
-// Additionally this method also resets the LastResetSentAt and the TokenKey fields.
-func (m *BaseAccount) SetPassword(password string) error {
- if password == "" {
- return errors.New("The provided plain password is empty")
- }
-
- // hash the password
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
- if err != nil {
- return err
- }
-
- m.PasswordHash = string(hashedPassword)
- m.LastResetSentAt = types.DateTime{} // reset
-
- // invalidate previously issued tokens
- m.RefreshTokenKey()
-
- return nil
-}
-
-// RefreshTokenKey generates and sets new random token key.
-func (m *BaseAccount) RefreshTokenKey() {
- m.TokenKey = security.RandomString(50)
-}
diff --git a/models/base_test.go b/models/base_test.go
index c56e5284..c4091c74 100644
--- a/models/base_test.go
+++ b/models/base_test.go
@@ -4,7 +4,6 @@ import (
"testing"
"github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/types"
)
func TestBaseModelHasId(t *testing.T) {
@@ -65,6 +64,9 @@ func TestBaseModelIsNew(t *testing.T) {
m5 := models.BaseModel{Id: "test"}
m5.MarkAsNew()
m5.UnmarkAsNew()
+ // check if MarkAsNew will be called on initial RefreshId()
+ m6 := models.BaseModel{}
+ m6.RefreshId()
scenarios := []struct {
model models.BaseModel
@@ -76,6 +78,7 @@ func TestBaseModelIsNew(t *testing.T) {
{m3, true},
{m4, true},
{m5, false},
+ {m6, true},
}
for i, s := range scenarios {
@@ -113,96 +116,3 @@ func TestBaseModelUpdated(t *testing.T) {
t.Fatalf("Expected non-zero datetime, got %v", m.GetUpdated())
}
}
-
-// -------------------------------------------------------------------
-// BaseAccount tests
-// -------------------------------------------------------------------
-
-func TestBaseAccountValidatePassword(t *testing.T) {
- scenarios := []struct {
- account models.BaseAccount
- password string
- expected bool
- }{
- {
- // empty passwordHash + empty pass
- models.BaseAccount{},
- "",
- false,
- },
- {
- // empty passwordHash + nonempty pass
- models.BaseAccount{},
- "123456",
- false,
- },
- {
- // nonempty passwordHash + empty pass
- models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
- "",
- false,
- },
- {
- // nonempty passwordHash + wrong pass
- models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
- "654321",
- false,
- },
- {
- // nonempty passwordHash + correct pass
- models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
- "123456",
- true,
- },
- }
-
- for i, s := range scenarios {
- result := s.account.ValidatePassword(s.password)
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestBaseAccountSetPassword(t *testing.T) {
- m := models.BaseAccount{
- // 123456
- PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv.",
- LastResetSentAt: types.NowDateTime(),
- TokenKey: "test",
- }
-
- // empty pass
- err1 := m.SetPassword("")
- if err1 == nil {
- t.Fatal("Expected empty password error")
- }
-
- err2 := m.SetPassword("654321")
- if err2 != nil {
- t.Fatalf("Expected nil, got error %v", err2)
- }
-
- if !m.ValidatePassword("654321") {
- t.Fatalf("Password is invalid")
- }
-
- if m.TokenKey == "test" {
- t.Fatalf("Expected TokenKey to change, got %v", m.TokenKey)
- }
-
- if !m.LastResetSentAt.IsZero() {
- t.Fatalf("Expected LastResetSentAt to be zero datetime, got %v", m.LastResetSentAt)
- }
-}
-
-func TestBaseAccountRefreshTokenKey(t *testing.T) {
- m := models.BaseAccount{TokenKey: "test"}
-
- m.RefreshTokenKey()
-
- // empty pass
- if m.TokenKey == "" || m.TokenKey == "test" {
- t.Fatalf("Expected TokenKey to change, got %q", m.TokenKey)
- }
-}
diff --git a/models/collection.go b/models/collection.go
index b90cb4b6..47ecf4a1 100644
--- a/models/collection.go
+++ b/models/collection.go
@@ -1,23 +1,43 @@
package models
-import "github.com/pocketbase/pocketbase/models/schema"
+import (
+ "encoding/json"
-var _ Model = (*Collection)(nil)
-var _ FilesManager = (*Collection)(nil)
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/go-ozzo/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+var (
+ _ Model = (*Collection)(nil)
+ _ FilesManager = (*Collection)(nil)
+)
+
+const (
+ CollectionTypeBase = "base"
+ CollectionTypeAuth = "auth"
+)
type Collection struct {
BaseModel
- Name string `db:"name" json:"name"`
- System bool `db:"system" json:"system"`
- Schema schema.Schema `db:"schema" json:"schema"`
- ListRule *string `db:"listRule" json:"listRule"`
- ViewRule *string `db:"viewRule" json:"viewRule"`
- CreateRule *string `db:"createRule" json:"createRule"`
- UpdateRule *string `db:"updateRule" json:"updateRule"`
- DeleteRule *string `db:"deleteRule" json:"deleteRule"`
+ Name string `db:"name" json:"name"`
+ Type string `db:"type" json:"type"`
+ System bool `db:"system" json:"system"`
+ Schema schema.Schema `db:"schema" json:"schema"`
+
+ // rules
+ ListRule *string `db:"listRule" json:"listRule"`
+ ViewRule *string `db:"viewRule" json:"viewRule"`
+ CreateRule *string `db:"createRule" json:"createRule"`
+ UpdateRule *string `db:"updateRule" json:"updateRule"`
+ DeleteRule *string `db:"deleteRule" json:"deleteRule"`
+
+ Options types.JsonMap `db:"options" json:"options"`
}
+// TableName returns the Collection model SQL table name.
func (m *Collection) TableName() string {
return "_collections"
}
@@ -26,3 +46,141 @@ func (m *Collection) TableName() string {
func (m *Collection) BaseFilesPath() string {
return m.Id
}
+
+// IsBase checks if the current collection has "base" type.
+func (m *Collection) IsBase() bool {
+ return m.Type == CollectionTypeBase
+}
+
+// IsBase checks if the current collection has "auth" type.
+func (m *Collection) IsAuth() bool {
+ return m.Type == CollectionTypeAuth
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (m Collection) MarshalJSON() ([]byte, error) {
+ type alias Collection // prevent recursion
+
+ m.NormalizeOptions()
+
+ return json.Marshal(alias(m))
+}
+
+// BaseOptions decodes the current collection options and returns them
+// as new [CollectionBaseOptions] instance.
+func (m *Collection) BaseOptions() CollectionBaseOptions {
+ result := CollectionBaseOptions{}
+ m.DecodeOptions(&result)
+ return result
+}
+
+// AuthOptions decodes the current collection options and returns them
+// as new [CollectionAuthOptions] instance.
+func (m *Collection) AuthOptions() CollectionAuthOptions {
+ result := CollectionAuthOptions{}
+ m.DecodeOptions(&result)
+ return result
+}
+
+// NormalizeOptions updates the current collection options with a
+// new normalized state based on the collection type.
+func (m *Collection) NormalizeOptions() error {
+ var typedOptions any
+ switch m.Type {
+ case CollectionTypeAuth:
+ typedOptions = m.AuthOptions()
+ default:
+ typedOptions = m.BaseOptions()
+ }
+
+ // serialize
+ raw, err := json.Marshal(typedOptions)
+ if err != nil {
+ return err
+ }
+
+ // load into a new JsonMap
+ m.Options = types.JsonMap{}
+ if err := json.Unmarshal(raw, &m.Options); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// DecodeOptions decodes the current collection options into the
+// provided "result" (must be a pointer).
+func (m *Collection) DecodeOptions(result any) error {
+ // raw serialize
+ raw, err := json.Marshal(m.Options)
+ if err != nil {
+ return err
+ }
+
+ // decode into the provided result
+ if err := json.Unmarshal(raw, result); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// SetOptions normalizes and unmarshals the specified options into m.Options.
+func (m *Collection) SetOptions(typedOptions any) error {
+ // serialize
+ raw, err := json.Marshal(typedOptions)
+ if err != nil {
+ return err
+ }
+
+ m.Options = types.JsonMap{}
+ if err := json.Unmarshal(raw, &m.Options); err != nil {
+ return err
+ }
+
+ return m.NormalizeOptions()
+}
+
+// -------------------------------------------------------------------
+
+// CollectionAuthOptions defines the "base" Collection.Options fields.
+type CollectionBaseOptions struct {
+}
+
+// Validate implements [validation.Validatable] interface.
+func (o CollectionBaseOptions) Validate() error {
+ return nil
+}
+
+// CollectionAuthOptions defines the "auth" Collection.Options fields.
+type CollectionAuthOptions struct {
+ ManageRule *string `form:"manageRule" json:"manageRule"`
+ AllowOAuth2Auth bool `form:"allowOAuth2Auth" json:"allowOAuth2Auth"`
+ AllowUsernameAuth bool `form:"allowUsernameAuth" json:"allowUsernameAuth"`
+ AllowEmailAuth bool `form:"allowEmailAuth" json:"allowEmailAuth"`
+ RequireEmail bool `form:"requireEmail" json:"requireEmail"`
+ ExceptEmailDomains []string `form:"exceptEmailDomains" json:"exceptEmailDomains"`
+ OnlyEmailDomains []string `form:"onlyEmailDomains" json:"onlyEmailDomains"`
+ MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
+}
+
+// Validate implements [validation.Validatable] interface.
+func (o CollectionAuthOptions) Validate() error {
+ return validation.ValidateStruct(&o,
+ validation.Field(&o.ManageRule, validation.NilOrNotEmpty),
+ validation.Field(
+ &o.ExceptEmailDomains,
+ validation.When(len(o.OnlyEmailDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+ ),
+ validation.Field(
+ &o.OnlyEmailDomains,
+ validation.When(len(o.ExceptEmailDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+ ),
+ validation.Field(
+ &o.MinPasswordLength,
+ validation.When(o.AllowUsernameAuth || o.AllowEmailAuth, validation.Required),
+ validation.Min(5),
+ validation.Max(72),
+ ),
+ )
+}
diff --git a/models/collection_test.go b/models/collection_test.go
index 16473d01..57a1b9be 100644
--- a/models/collection_test.go
+++ b/models/collection_test.go
@@ -1,9 +1,13 @@
package models_test
import (
+ "encoding/json"
"testing"
+ validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/types"
)
func TestCollectionTableName(t *testing.T) {
@@ -23,3 +27,370 @@ func TestCollectionBaseFilesPath(t *testing.T) {
t.Fatalf("Expected path %s, got %s", expected, m.BaseFilesPath())
}
}
+
+func TestCollectionIsBase(t *testing.T) {
+ scenarios := []struct {
+ collection models.Collection
+ expected bool
+ }{
+ {models.Collection{}, false},
+ {models.Collection{Type: "unknown"}, false},
+ {models.Collection{Type: models.CollectionTypeBase}, true},
+ {models.Collection{Type: models.CollectionTypeAuth}, false},
+ }
+
+ for i, s := range scenarios {
+ result := s.collection.IsBase()
+ if result != s.expected {
+ t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
+ }
+ }
+}
+
+func TestCollectionIsAuth(t *testing.T) {
+ scenarios := []struct {
+ collection models.Collection
+ expected bool
+ }{
+ {models.Collection{}, false},
+ {models.Collection{Type: "unknown"}, false},
+ {models.Collection{Type: models.CollectionTypeBase}, false},
+ {models.Collection{Type: models.CollectionTypeAuth}, true},
+ }
+
+ for i, s := range scenarios {
+ result := s.collection.IsAuth()
+ if result != s.expected {
+ t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
+ }
+ }
+}
+
+func TestCollectionMarshalJSON(t *testing.T) {
+ scenarios := []struct {
+ name string
+ collection models.Collection
+ expected string
+ }{
+ {
+ "no type",
+ models.Collection{Name: "test"},
+ `{"id":"","created":"","updated":"","name":"test","type":"","system":false,"schema":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
+ },
+ {
+ "unknown type + non empty options",
+ models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
+ `{"id":"","created":"","updated":"","name":"test","type":"unknown","system":false,"schema":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
+ },
+ {
+ "base type + non empty options",
+ models.Collection{Name: "test", Type: models.CollectionTypeBase, ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
+ `{"id":"","created":"","updated":"","name":"test","type":"base","system":false,"schema":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
+ },
+ {
+ "auth type + non empty options",
+ models.Collection{BaseModel: models.BaseModel{Id: "test"}, Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "allowOAuth2Auth": true, "minPasswordLength": 4}},
+ `{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}}`,
+ },
+ }
+
+ for _, s := range scenarios {
+ result, err := s.collection.MarshalJSON()
+ if err != nil {
+ t.Errorf("(%s) Unexpected error %v", s.name, err)
+ continue
+ }
+ if string(result) != s.expected {
+ t.Errorf("(%s) Expected\n%v \ngot \n%v", s.name, s.expected, string(result))
+ }
+ }
+}
+
+func TestCollectionBaseOptions(t *testing.T) {
+ scenarios := []struct {
+ name string
+ collection models.Collection
+ expected string
+ }{
+ {
+ "no type",
+ models.Collection{Options: types.JsonMap{"test": 123}},
+ "{}",
+ },
+ {
+ "unknown type",
+ models.Collection{Type: "anything", Options: types.JsonMap{"test": 123}},
+ "{}",
+ },
+ {
+ "different type",
+ models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
+ "{}",
+ },
+ {
+ "base type",
+ models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123}},
+ "{}",
+ },
+ }
+
+ for _, s := range scenarios {
+ result := s.collection.BaseOptions()
+
+ encoded, err := json.Marshal(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if strEncoded := string(encoded); strEncoded != s.expected {
+ t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
+ }
+ }
+}
+
+func TestCollectionAuthOptions(t *testing.T) {
+ options := types.JsonMap{"test": 123, "minPasswordLength": 4}
+ expectedSerialization := `{"manageRule":null,"allowOAuth2Auth":false,"allowUsernameAuth":false,"allowEmailAuth":false,"requireEmail":false,"exceptEmailDomains":null,"onlyEmailDomains":null,"minPasswordLength":4}`
+
+ scenarios := []struct {
+ name string
+ collection models.Collection
+ expected string
+ }{
+ {
+ "no type",
+ models.Collection{Options: options},
+ expectedSerialization,
+ },
+ {
+ "unknown type",
+ models.Collection{Type: "anything", Options: options},
+ expectedSerialization,
+ },
+ {
+ "different type",
+ models.Collection{Type: models.CollectionTypeBase, Options: options},
+ expectedSerialization,
+ },
+ {
+ "auth type",
+ models.Collection{Type: models.CollectionTypeAuth, Options: options},
+ expectedSerialization,
+ },
+ }
+
+ for _, s := range scenarios {
+ result := s.collection.AuthOptions()
+
+ encoded, err := json.Marshal(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if strEncoded := string(encoded); strEncoded != s.expected {
+ t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
+ }
+ }
+}
+
+func TestNormalizeOptions(t *testing.T) {
+ scenarios := []struct {
+ name string
+ collection models.Collection
+ expected string // serialized options
+ }{
+ {
+ "unknown type",
+ models.Collection{Type: "unknown", Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
+ "{}",
+ },
+ {
+ "base type",
+ models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
+ "{}",
+ },
+ {
+ "auth type",
+ models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
+ `{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}`,
+ },
+ }
+
+ for _, s := range scenarios {
+ if err := s.collection.NormalizeOptions(); err != nil {
+ t.Errorf("(%s) Unexpected error %v", s.name, err)
+ continue
+ }
+
+ encoded, err := json.Marshal(s.collection.Options)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if strEncoded := string(encoded); strEncoded != s.expected {
+ t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
+ }
+ }
+}
+
+func TestDecodeOptions(t *testing.T) {
+ m := models.Collection{
+ Options: types.JsonMap{"test": 123},
+ }
+
+ result := struct {
+ Test int
+ }{}
+
+ if err := m.DecodeOptions(&result); err != nil {
+ t.Fatal(err)
+ }
+
+ if result.Test != 123 {
+ t.Fatalf("Expected %v, got %v", 123, result.Test)
+ }
+}
+
+func TestSetOptions(t *testing.T) {
+ scenarios := []struct {
+ name string
+ collection models.Collection
+ options any
+ expected string // serialized options
+ }{
+ {
+ "no type",
+ models.Collection{},
+ map[string]any{},
+ "{}",
+ },
+ {
+ "unknown type + non empty options",
+ models.Collection{Type: "unknown", Options: types.JsonMap{"test": 123}},
+ map[string]any{"test": 456, "minPasswordLength": 4},
+ "{}",
+ },
+ {
+ "base type",
+ models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123}},
+ map[string]any{"test": 456, "minPasswordLength": 4},
+ "{}",
+ },
+ {
+ "auth type",
+ models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123}},
+ map[string]any{"test": 456, "minPasswordLength": 4},
+ `{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}`,
+ },
+ }
+
+ for _, s := range scenarios {
+ if err := s.collection.SetOptions(s.options); err != nil {
+ t.Errorf("(%s) Unexpected error %v", s.name, err)
+ continue
+ }
+
+ encoded, err := json.Marshal(s.collection.Options)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if strEncoded := string(encoded); strEncoded != s.expected {
+ t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
+ }
+ }
+}
+
+func TestCollectionBaseOptionsValidate(t *testing.T) {
+ opt := models.CollectionBaseOptions{}
+ if err := opt.Validate(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCollectionAuthOptionsValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ options models.CollectionAuthOptions
+ expectedErrors []string
+ }{
+ {
+ "empty",
+ models.CollectionAuthOptions{},
+ nil,
+ },
+ {
+ "empty string ManageRule",
+ models.CollectionAuthOptions{ManageRule: types.Pointer("")},
+ []string{"manageRule"},
+ },
+ {
+ "minPasswordLength < 5",
+ models.CollectionAuthOptions{MinPasswordLength: 3},
+ []string{"minPasswordLength"},
+ },
+ {
+ "minPasswordLength > 72",
+ models.CollectionAuthOptions{MinPasswordLength: 73},
+ []string{"minPasswordLength"},
+ },
+ {
+ "both OnlyDomains and ExceptDomains set",
+ models.CollectionAuthOptions{
+ OnlyEmailDomains: []string{"example.com", "test.com"},
+ ExceptEmailDomains: []string{"example.com", "test.com"},
+ },
+ []string{"onlyEmailDomains", "exceptEmailDomains"},
+ },
+ {
+ "only OnlyDomains set",
+ models.CollectionAuthOptions{
+ OnlyEmailDomains: []string{"example.com", "test.com"},
+ },
+ []string{},
+ },
+ {
+ "only ExceptEmailDomains set",
+ models.CollectionAuthOptions{
+ ExceptEmailDomains: []string{"example.com", "test.com"},
+ },
+ []string{},
+ },
+ {
+ "all fields with valid data",
+ models.CollectionAuthOptions{
+ ManageRule: types.Pointer("test"),
+ AllowOAuth2Auth: true,
+ AllowUsernameAuth: true,
+ AllowEmailAuth: true,
+ RequireEmail: true,
+ ExceptEmailDomains: []string{"example.com", "test.com"},
+ OnlyEmailDomains: nil,
+ MinPasswordLength: 5,
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ result := s.options.Validate()
+
+ // parse errors
+ errs, ok := result.(validation.Errors)
+ if !ok && result != nil {
+ t.Errorf("(%s) Failed to parse errors %v", s.name, result)
+ continue
+ }
+
+ if len(errs) != len(s.expectedErrors) {
+ t.Errorf("(%s) Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
+ continue
+ }
+
+ for key := range errs {
+ if !list.ExistInSlice(key, s.expectedErrors) {
+ t.Errorf("(%s) Unexpected error key %q in \n%v", s.name, key, errs)
+ }
+ }
+ }
+}
diff --git a/models/external_auth.go b/models/external_auth.go
index 74399faf..bf9e0314 100644
--- a/models/external_auth.go
+++ b/models/external_auth.go
@@ -5,9 +5,10 @@ var _ Model = (*ExternalAuth)(nil)
type ExternalAuth struct {
BaseModel
- UserId string `db:"userId" json:"userId"`
- Provider string `db:"provider" json:"provider"`
- ProviderId string `db:"providerId" json:"providerId"`
+ CollectionId string `db:"collectionId" json:"collectionId"`
+ RecordId string `db:"recordId" json:"recordId"`
+ Provider string `db:"provider" json:"provider"`
+ ProviderId string `db:"providerId" json:"providerId"`
}
func (m *ExternalAuth) TableName() string {
diff --git a/models/record.go b/models/record.go
index 787fde9e..f646d9f3 100644
--- a/models/record.go
+++ b/models/record.go
@@ -2,28 +2,34 @@ package models
import (
"encoding/json"
+ "errors"
"fmt"
- "log"
- "strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
+ "golang.org/x/crypto/bcrypt"
)
-var _ Model = (*Record)(nil)
-var _ ColumnValueMapper = (*Record)(nil)
-var _ FilesManager = (*Record)(nil)
+var (
+ _ Model = (*Record)(nil)
+ _ ColumnValueMapper = (*Record)(nil)
+ _ FilesManager = (*Record)(nil)
+)
type Record struct {
BaseModel
collection *Collection
- data map[string]any
- expand map[string]any
+
+ exportUnknown bool // whether to export unknown fields
+ ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
+ data map[string]any // any custom data in addition to the base model fields
+ expand map[string]any // expanded relations
}
// NewRecord initializes a new empty Record model.
@@ -34,34 +40,43 @@ func NewRecord(collection *Collection) *Record {
}
}
+// nullStringMapValue returns the raw string value if it exist and
+// its not NULL, otherwise - nil.
+func nullStringMapValue(data dbx.NullStringMap, key string) any {
+ nullString, ok := data[key]
+
+ if ok && nullString.Valid {
+ return nullString.String
+ }
+
+ return nil
+}
+
// NewRecordFromNullStringMap initializes a single new Record model
// with data loaded from the provided NullStringMap.
func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) *Record {
resultMap := map[string]any{}
+ // load schema fields
for _, field := range collection.Schema.Fields() {
- var rawValue any
+ resultMap[field.Name] = nullStringMapValue(data, field.Name)
+ }
- nullString, ok := data[field.Name]
- if !ok || !nullString.Valid {
- rawValue = nil
- } else {
- rawValue = nullString.String
+ // load base model fields
+ for _, name := range schema.BaseModelFieldNames() {
+ resultMap[name] = nullStringMapValue(data, name)
+ }
+
+ // load auth fields
+ if collection.IsAuth() {
+ for _, name := range schema.AuthFieldNames() {
+ resultMap[name] = nullStringMapValue(data, name)
}
-
- resultMap[field.Name] = rawValue
}
record := NewRecord(collection)
- // load base mode fields
- resultMap[schema.ReservedFieldNameId] = data[schema.ReservedFieldNameId].String
- resultMap[schema.ReservedFieldNameCreated] = data[schema.ReservedFieldNameCreated].String
- resultMap[schema.ReservedFieldNameUpdated] = data[schema.ReservedFieldNameUpdated].String
-
- if err := record.Load(resultMap); err != nil {
- log.Println("Failed to unmarshal record:", err)
- }
+ record.Load(resultMap)
return record
}
@@ -88,77 +103,150 @@ func (m *Record) Collection() *Collection {
return m.collection
}
-// GetExpand returns a shallow copy of the optional `expand` data
+// Expand returns a shallow copy of the record.expand data
// attached to the current Record model.
-func (m *Record) GetExpand() map[string]any {
+func (m *Record) Expand() map[string]any {
return shallowCopy(m.expand)
}
-// SetExpand assigns the provided data to `record.expand`.
-func (m *Record) SetExpand(data map[string]any) {
- m.expand = shallowCopy(data)
+// SetExpand assigns the provided data to record.expand.
+func (m *Record) SetExpand(expand map[string]any) {
+ m.expand = shallowCopy(expand)
}
-// Data returns a shallow copy of the currently loaded record's data.
-func (m *Record) Data() map[string]any {
- return shallowCopy(m.data)
-}
+// SchemaData returns a shallow copy ONLY of the defined record schema fields data.
+func (m *Record) SchemaData() map[string]any {
+ result := map[string]any{}
-// SetDataValue sets the provided key-value data pair for the current Record model.
-//
-// This method does nothing if the record doesn't have a `key` field.
-func (m *Record) SetDataValue(key string, value any) {
- if m.data == nil {
- m.data = map[string]any{}
+ for _, field := range m.collection.Schema.Fields() {
+ if v, ok := m.data[field.Name]; ok {
+ result[field.Name] = v
+ }
}
- field := m.Collection().Schema.GetFieldByName(key)
- if field != nil {
- m.data[key] = field.PrepareValue(value)
+ return result
+}
+
+// UnknownData returns a shallow copy ONLY of the unknown record fields data,
+// aka. fields that are neither one of the base and special system ones,
+// nor defined by the collection schema.
+func (m *Record) UnknownData() map[string]any {
+ return m.extractUnknownData(m.data)
+}
+
+// IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check.
+func (m *Record) IgnoreEmailVisibility(state bool) {
+ m.ignoreEmailVisibility = state
+}
+
+// WithUnkownData toggles the export/serialization of unknown data fields
+// (false by default).
+func (m *Record) WithUnkownData(state bool) {
+ m.exportUnknown = state
+}
+
+// Set sets the provided key-value data pair for the current Record model.
+//
+// If the record collection has field with name matching the provided "key",
+// the value will be further normalized according to the field rules.
+func (m *Record) Set(key string, value any) {
+ switch key {
+ case schema.FieldNameId:
+ m.Id = cast.ToString(value)
+ case schema.FieldNameCreated:
+ m.Created, _ = types.ParseDateTime(value)
+ case schema.FieldNameUpdated:
+ m.Updated, _ = types.ParseDateTime(value)
+ case schema.FieldNameExpand:
+ m.SetExpand(cast.ToStringMap(value))
+ default:
+ var v = value
+
+ if field := m.Collection().Schema.GetFieldByName(key); field != nil {
+ v = field.PrepareValue(value)
+ } else if m.collection.IsAuth() {
+ // normalize auth fields
+ switch key {
+ case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
+ v = cast.ToBool(value)
+ case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
+ v, _ = types.ParseDateTime(value)
+ case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
+ v = cast.ToString(value)
+ }
+ }
+
+ if m.data == nil {
+ m.data = map[string]any{}
+ }
+
+ m.data[key] = v
}
}
-// GetDataValue returns the current record's data value for `key`.
-//
-// Returns nil if data value with `key` is not found or set.
-func (m *Record) GetDataValue(key string) any {
- return m.data[key]
+// Get returns a single record model data value for "key".
+func (m *Record) Get(key string) any {
+ switch key {
+ case schema.FieldNameId:
+ return m.Id
+ case schema.FieldNameCreated:
+ return m.Created
+ case schema.FieldNameUpdated:
+ return m.Updated
+ default:
+ if v, ok := m.data[key]; ok {
+ return v
+ }
+
+ return nil
+ }
}
-// GetBoolDataValue returns the data value for `key` as a bool.
-func (m *Record) GetBoolDataValue(key string) bool {
- return cast.ToBool(m.GetDataValue(key))
+// GetBool returns the data value for "key" as a bool.
+func (m *Record) GetBool(key string) bool {
+ return cast.ToBool(m.Get(key))
}
-// GetStringDataValue returns the data value for `key` as a string.
-func (m *Record) GetStringDataValue(key string) string {
- return cast.ToString(m.GetDataValue(key))
+// GetString returns the data value for "key" as a string.
+func (m *Record) GetString(key string) string {
+ return cast.ToString(m.Get(key))
}
-// GetIntDataValue returns the data value for `key` as an int.
-func (m *Record) GetIntDataValue(key string) int {
- return cast.ToInt(m.GetDataValue(key))
+// GetInt returns the data value for "key" as an int.
+func (m *Record) GetInt(key string) int {
+ return cast.ToInt(m.Get(key))
}
-// GetFloatDataValue returns the data value for `key` as a float64.
-func (m *Record) GetFloatDataValue(key string) float64 {
- return cast.ToFloat64(m.GetDataValue(key))
+// GetFloat returns the data value for "key" as a float64.
+func (m *Record) GetFloat(key string) float64 {
+ return cast.ToFloat64(m.Get(key))
}
-// GetTimeDataValue returns the data value for `key` as a [time.Time] instance.
-func (m *Record) GetTimeDataValue(key string) time.Time {
- return cast.ToTime(m.GetDataValue(key))
+// GetTime returns the data value for "key" as a [time.Time] instance.
+func (m *Record) GetTime(key string) time.Time {
+ return cast.ToTime(m.Get(key))
}
-// GetDateTimeDataValue returns the data value for `key` as a DateTime instance.
-func (m *Record) GetDateTimeDataValue(key string) types.DateTime {
- d, _ := types.ParseDateTime(m.GetDataValue(key))
+// GetDateTime returns the data value for "key" as a DateTime instance.
+func (m *Record) GetDateTime(key string) types.DateTime {
+ d, _ := types.ParseDateTime(m.Get(key))
return d
}
-// GetStringSliceDataValue returns the data value for `key` as a slice of unique strings.
-func (m *Record) GetStringSliceDataValue(key string) []string {
- return list.ToUniqueStringSlice(m.GetDataValue(key))
+// GetStringSlice returns the data value for "key" as a slice of unique strings.
+func (m *Record) GetStringSlice(key string) []string {
+ return list.ToUniqueStringSlice(m.Get(key))
+}
+
+// Retrieves the "key" json field value and unmarshals it into "result".
+//
+// Example
+// result := struct {
+// FirstName string `json:"first_name"`
+// }{}
+// err := m.UnmarshalJSONField("my_field_name", &result)
+func (m *Record) UnmarshalJSONField(key string, result any) error {
+ return json.Unmarshal([]byte(m.GetString(key)), &result)
}
// BaseFilesPath returns the storage dir path used by the record.
@@ -171,7 +259,7 @@ func (m *Record) BaseFilesPath() string {
func (m *Record) FindFileFieldByFile(filename string) *schema.SchemaField {
for _, field := range m.Collection().Schema.Fields() {
if field.Type == schema.FieldTypeFile {
- names := m.GetStringSliceDataValue(field.Name)
+ names := m.GetStringSlice(field.Name)
if list.ExistInSlice(filename, names) {
return field
}
@@ -181,63 +269,76 @@ func (m *Record) FindFileFieldByFile(filename string) *schema.SchemaField {
}
// Load bulk loads the provided data into the current Record model.
-func (m *Record) Load(data map[string]any) error {
- if data[schema.ReservedFieldNameId] != nil {
- id, err := cast.ToStringE(data[schema.ReservedFieldNameId])
- if err != nil {
- return err
- }
- m.Id = id
- }
-
- if data[schema.ReservedFieldNameCreated] != nil {
- m.Created, _ = types.ParseDateTime(data[schema.ReservedFieldNameCreated])
- }
-
- if data[schema.ReservedFieldNameUpdated] != nil {
- m.Updated, _ = types.ParseDateTime(data[schema.ReservedFieldNameUpdated])
- }
-
+func (m *Record) Load(data map[string]any) {
for k, v := range data {
- m.SetDataValue(k, v)
+ m.Set(k, v)
}
-
- return nil
}
// ColumnValueMap implements [ColumnValueMapper] interface.
func (m *Record) ColumnValueMap() map[string]any {
result := map[string]any{}
- for key := range m.data {
- result[key] = m.normalizeDataValueForDB(key)
+
+ // export schema field values
+ for _, field := range m.collection.Schema.Fields() {
+ result[field.Name] = m.getNormalizeDataValueForDB(field.Name)
}
- // set base model fields
- result[schema.ReservedFieldNameId] = m.Id
- result[schema.ReservedFieldNameCreated] = m.Created
- result[schema.ReservedFieldNameUpdated] = m.Updated
+ // export auth collection fields
+ if m.collection.IsAuth() {
+ for _, name := range schema.AuthFieldNames() {
+ result[name] = m.getNormalizeDataValueForDB(name)
+ }
+ }
+
+ // export base model fields
+ result[schema.FieldNameId] = m.getNormalizeDataValueForDB(schema.FieldNameId)
+ result[schema.FieldNameCreated] = m.getNormalizeDataValueForDB(schema.FieldNameCreated)
+ result[schema.FieldNameUpdated] = m.getNormalizeDataValueForDB(schema.FieldNameUpdated)
return result
}
// PublicExport exports only the record fields that are safe to be public.
//
-// This method also skips the "hidden" fields, aka. fields prefixed with `#`.
+// Fields marked as hidden will be exported only if `m.IgnoreEmailVisibility(true)` is set.
func (m *Record) PublicExport() map[string]any {
- result := skipHiddenFields(m.data)
+ result := map[string]any{}
- // set base model fields
- result[schema.ReservedFieldNameId] = m.Id
- result[schema.ReservedFieldNameCreated] = m.Created
- result[schema.ReservedFieldNameUpdated] = m.Updated
+ // export unknown data fields if allowed
+ if m.exportUnknown {
+ for k, v := range m.UnknownData() {
+ result[k] = v
+ }
+ }
- // add helper collection fields
- result["@collectionId"] = m.collection.Id
- result["@collectionName"] = m.collection.Name
+ // export schema field values
+ for _, field := range m.collection.Schema.Fields() {
+ result[field.Name] = m.Get(field.Name)
+ }
+
+ // export some of the safe auth collection fields
+ if m.collection.IsAuth() {
+ result[schema.FieldNameVerified] = m.Verified()
+ result[schema.FieldNameUsername] = m.Username()
+ result[schema.FieldNameEmailVisibility] = m.EmailVisibility()
+ if m.ignoreEmailVisibility || m.EmailVisibility() {
+ result[schema.FieldNameEmail] = m.Email()
+ }
+ }
+
+ // export base model fields
+ result[schema.FieldNameId] = m.GetId()
+ result[schema.FieldNameCreated] = m.GetCreated()
+ result[schema.FieldNameUpdated] = m.GetUpdated()
+
+ // add helper collection reference fields
+ result[schema.FieldNameCollectionId] = m.collection.Id
+ result[schema.FieldNameCollectionName] = m.collection.Name
// add expand (if set)
if m.expand != nil {
- result["@expand"] = m.expand
+ result[schema.FieldNameExpand] = m.expand
}
return result
@@ -258,19 +359,41 @@ func (m *Record) UnmarshalJSON(data []byte) error {
return err
}
- return m.Load(result)
+ m.Load(result)
+
+ return nil
}
-// normalizeDataValueForDB returns the `key` data value formatted for db storage.
-func (m *Record) normalizeDataValueForDB(key string) any {
- val := m.GetDataValue(key)
+// getNormalizeDataValueForDB returns the "key" data value formatted for db storage.
+func (m *Record) getNormalizeDataValueForDB(key string) any {
+ var val any
+
+ // normalize auth fields
+ if m.collection.IsAuth() {
+ switch key {
+ case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
+ return m.GetBool(key)
+ case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
+ return m.GetDateTime(key)
+ case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
+ return m.GetString(key)
+ }
+ }
+
+ val = m.Get(key)
switch ids := val.(type) {
case []string:
- // encode strings slice
+ // encode string slice
+ return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
+ case []int:
+ // encode int slice
+ return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
+ case []float64:
+ // encode float64 slice
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
case []any:
- // encode interfaces slice
+ // encode interface slice
return append(types.JsonArray{}, ids...)
default:
// no changes
@@ -289,17 +412,218 @@ func shallowCopy(data map[string]any) map[string]any {
return result
}
-// skipHiddenFields returns a new data map without the "#" prefixed fields.
-func skipHiddenFields(data map[string]any) map[string]any {
+func (m *Record) extractUnknownData(data map[string]any) map[string]any {
+ knownFields := map[string]struct{}{}
+
+ for _, name := range schema.SystemFieldNames() {
+ knownFields[name] = struct{}{}
+ }
+ for _, name := range schema.BaseModelFieldNames() {
+ knownFields[name] = struct{}{}
+ }
+
+ for _, f := range m.collection.Schema.Fields() {
+ knownFields[f.Name] = struct{}{}
+ }
+
+ if m.collection.IsAuth() {
+ for _, name := range schema.AuthFieldNames() {
+ knownFields[name] = struct{}{}
+ }
+ }
+
result := map[string]any{}
- for key, val := range data {
- // ignore "#" prefixed fields
- if strings.HasPrefix(key, "#") {
- continue
+ for k, v := range m.data {
+ if _, ok := knownFields[k]; !ok {
+ result[k] = v
}
- result[key] = val
}
return result
}
+
+// -------------------------------------------------------------------
+// Auth helpers
+// -------------------------------------------------------------------
+
+var notAuthRecordErr = errors.New("Not an auth collection record.")
+
+// Username returns the "username" auth record data value.
+func (m *Record) Username() string {
+ return m.GetString(schema.FieldNameUsername)
+}
+
+// SetUsername sets the "username" auth record data value.
+//
+// This method doesn't check whether the provided value is a valid username.
+//
+// Returns an error if the record is not from an auth collection.
+func (m *Record) SetUsername(username string) error {
+ if !m.collection.IsAuth() {
+ return notAuthRecordErr
+ }
+
+ m.Set(schema.FieldNameUsername, username)
+
+ return nil
+}
+
+// Email returns the "email" auth record data value.
+func (m *Record) Email() string {
+ return m.GetString(schema.FieldNameEmail)
+}
+
+// SetEmail sets the "email" auth record data value.
+//
+// This method doesn't check whether the provided value is a valid email.
+//
+// Returns an error if the record is not from an auth collection.
+func (m *Record) SetEmail(email string) error {
+ if !m.collection.IsAuth() {
+ return notAuthRecordErr
+ }
+
+ m.Set(schema.FieldNameEmail, email)
+
+ return nil
+}
+
+// Verified returns the "emailVisibility" auth record data value.
+func (m *Record) EmailVisibility() bool {
+ return m.GetBool(schema.FieldNameEmailVisibility)
+}
+
+// SetEmailVisibility sets the "emailVisibility" auth record data value.
+//
+// Returns an error if the record is not from an auth collection.
+func (m *Record) SetEmailVisibility(visible bool) error {
+ if !m.collection.IsAuth() {
+ return notAuthRecordErr
+ }
+
+ m.Set(schema.FieldNameEmailVisibility, visible)
+
+ return nil
+}
+
+// Verified returns the "verified" auth record data value.
+func (m *Record) Verified() bool {
+ return m.GetBool(schema.FieldNameVerified)
+}
+
+// SetVerified sets the "verified" auth record data value.
+//
+// Returns an error if the record is not from an auth collection.
+func (m *Record) SetVerified(verified bool) error {
+ if !m.collection.IsAuth() {
+ return notAuthRecordErr
+ }
+
+ m.Set(schema.FieldNameVerified, verified)
+
+ return nil
+}
+
+// TokenKey returns the "tokenKey" auth record data value.
+func (m *Record) TokenKey() string {
+ return m.GetString(schema.FieldNameTokenKey)
+}
+
+// SetTokenKey sets the "tokenKey" auth record data value.
+//
+// Returns an error if the record is not from an auth collection.
+func (m *Record) SetTokenKey(key string) error {
+ if !m.collection.IsAuth() {
+ return notAuthRecordErr
+ }
+
+ m.Set(schema.FieldNameTokenKey, key)
+
+ return nil
+}
+
+// RefreshTokenKey generates and sets new random auth record "tokenKey".
+//
+// Returns an error if the record is not from an auth collection.
+func (m *Record) RefreshTokenKey() error {
+ return m.SetTokenKey(security.RandomString(50))
+}
+
+// LastResetSentAt returns the "lastResentSentAt" auth record data value.
+func (m *Record) LastResetSentAt() types.DateTime {
+ return m.GetDateTime(schema.FieldNameLastResetSentAt)
+}
+
+// SetLastResetSentAt sets the "lastResentSentAt" auth record data value.
+//
+// Returns an error if the record is not from an auth collection.
+func (m *Record) SetLastResetSentAt(dateTime types.DateTime) error {
+ if !m.collection.IsAuth() {
+ return notAuthRecordErr
+ }
+
+ m.Set(schema.FieldNameLastResetSentAt, dateTime)
+
+ return nil
+}
+
+// LastVerificationSentAt returns the "lastVerificationSentAt" auth record data value.
+func (m *Record) LastVerificationSentAt() types.DateTime {
+ return m.GetDateTime(schema.FieldNameLastVerificationSentAt)
+}
+
+// SetLastVerificationSentAt sets an "lastVerificationSentAt" auth record data value.
+//
+// Returns an error if the record is not from an auth collection.
+func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error {
+ if !m.collection.IsAuth() {
+ return notAuthRecordErr
+ }
+
+ m.Set(schema.FieldNameLastVerificationSentAt, dateTime)
+
+ return nil
+}
+
+// ValidatePassword validates a plain password against the auth record password.
+//
+// Returns false if the password is incorrect or record is not from an auth collection.
+func (m *Record) ValidatePassword(password string) bool {
+ if !m.collection.IsAuth() {
+ return false
+ }
+
+ err := bcrypt.CompareHashAndPassword(
+ []byte(m.GetString(schema.FieldNamePasswordHash)),
+ []byte(password),
+ )
+ return err == nil
+}
+
+// SetPassword sets cryptographically secure string to the auth record "password" field.
+// This method also resets the "lastResetSentAt" and the "tokenKey" fields.
+//
+// Returns an error if the record is not from an auth collection or
+// an empty password is provided.
+func (m *Record) SetPassword(password string) error {
+ if !m.collection.IsAuth() {
+ return notAuthRecordErr
+ }
+
+ if password == "" {
+ return errors.New("The provided plain password is empty")
+ }
+
+ // hash the password
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
+ if err != nil {
+ return err
+ }
+
+ m.Set(schema.FieldNamePasswordHash, string(hashedPassword))
+ m.Set(schema.FieldNameLastResetSentAt, types.DateTime{})
+
+ // invalidate previously issued tokens
+ return m.RefreshTokenKey()
+}
diff --git a/models/record_test.go b/models/record_test.go
index 68d57279..79059481 100644
--- a/models/record_test.go
+++ b/models/record_test.go
@@ -15,6 +15,7 @@ import (
func TestNewRecord(t *testing.T) {
collection := &models.Collection{
+ Name: "test_collection",
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "test",
@@ -25,12 +26,12 @@ func TestNewRecord(t *testing.T) {
m := models.NewRecord(collection)
- if m.Collection().Id != collection.Id {
- t.Fatalf("Expected collection with id %v, got %v", collection.Id, m.Collection().Id)
+ if m.Collection().Name != collection.Name {
+ t.Fatalf("Expected collection with name %q, got %q", collection.Id, m.Collection().Id)
}
- if len(m.Data()) != 0 {
- t.Fatalf("Expected empty data, got %v", m.Data())
+ if len(m.SchemaData()) != 0 {
+ t.Fatalf("Expected empty schema data, got %v", m.SchemaData())
}
}
@@ -75,17 +76,51 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
data := dbx.NullStringMap{
"id": sql.NullString{
- String: "c23eb053-d07e-4fbe-86b3-b8ac31982e9a",
+ String: "test_id",
Valid: true,
},
"created": sql.NullString{
- String: "2022-01-01 10:00:00.123",
+ String: "2022-01-01 10:00:00.123Z",
Valid: true,
},
"updated": sql.NullString{
- String: "2022-01-01 10:00:00.456",
+ String: "2022-01-01 10:00:00.456Z",
Valid: true,
},
+ // auth collection specific fields
+ "username": sql.NullString{
+ String: "test_username",
+ Valid: true,
+ },
+ "email": sql.NullString{
+ String: "test_email",
+ Valid: true,
+ },
+ "emailVisibility": sql.NullString{
+ String: "true",
+ Valid: true,
+ },
+ "verified": sql.NullString{
+ String: "",
+ Valid: false,
+ },
+ "tokenKey": sql.NullString{
+ String: "test_tokenKey",
+ Valid: true,
+ },
+ "passwordHash": sql.NullString{
+ String: "test_passwordHash",
+ Valid: true,
+ },
+ "lastResetSentAt": sql.NullString{
+ String: "2022-01-02 10:00:00.123Z",
+ Valid: true,
+ },
+ "lastVerificationSentAt": sql.NullString{
+ String: "2022-02-03 10:00:00.456Z",
+ Valid: true,
+ },
+ // custom schema fields
"field1": sql.NullString{
String: "test",
Valid: true,
@@ -110,18 +145,56 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
String: "test", // will be converted to slice
Valid: true,
},
+ "unknown": sql.NullString{
+ String: "test",
+ Valid: true,
+ },
}
- m := models.NewRecordFromNullStringMap(collection, data)
- encoded, err := m.MarshalJSON()
- if err != nil {
- t.Fatal(err)
+ scenarios := []struct {
+ collectionType string
+ expectedJson string
+ }{
+ {
+ models.CollectionTypeBase,
+ `{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z"}`,
+ },
+ {
+ models.CollectionTypeAuth,
+ `{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z","username":"test_username","verified":false}`,
+ },
}
- expected := `{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"c23eb053-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
+ for i, s := range scenarios {
+ collection.Type = s.collectionType
+ m := models.NewRecordFromNullStringMap(collection, data)
+ m.IgnoreEmailVisibility(true)
- if string(encoded) != expected {
- t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
+ encoded, err := m.MarshalJSON()
+ if err != nil {
+ t.Errorf("(%d) Unexpected error: %v", i, err)
+ continue
+ }
+
+ if string(encoded) != s.expectedJson {
+ t.Errorf("(%d) Expected \n%v \ngot \n%v", i, s.expectedJson, string(encoded))
+ }
+
+ // additional data checks
+ if collection.IsAuth() {
+ if v := m.GetString(schema.FieldNamePasswordHash); v != "test_passwordHash" {
+ t.Errorf("(%d) Expected %q, got %q", i, "test_passwordHash", v)
+ }
+ if v := m.GetString(schema.FieldNameTokenKey); v != "test_tokenKey" {
+ t.Errorf("(%d) Expected %q, got %q", i, "test_tokenKey", v)
+ }
+ if v := m.GetString(schema.FieldNameLastResetSentAt); v != "2022-01-02 10:00:00.123Z" {
+ t.Errorf("(%d) Expected %q, got %q", i, "2022-01-02 10:00:00.123Z", v)
+ }
+ if v := m.GetString(schema.FieldNameLastVerificationSentAt); v != "2022-02-03 10:00:00.456Z" {
+ t.Errorf("(%d) Expected %q, got %q", i, "2022-01-02 10:00:00.123Z", v)
+ }
+ }
}
}
@@ -137,69 +210,101 @@ func TestNewRecordsFromNullStringMaps(t *testing.T) {
Name: "field2",
Type: schema.FieldTypeNumber,
},
+ &schema.SchemaField{
+ Name: "field3",
+ Type: schema.FieldTypeUrl,
+ },
),
}
data := []dbx.NullStringMap{
{
"id": sql.NullString{
- String: "11111111-d07e-4fbe-86b3-b8ac31982e9a",
+ String: "test_id1",
Valid: true,
},
"created": sql.NullString{
- String: "2022-01-01 10:00:00.123",
+ String: "2022-01-01 10:00:00.123Z",
Valid: true,
},
"updated": sql.NullString{
- String: "2022-01-01 10:00:00.456",
+ String: "2022-01-01 10:00:00.456Z",
Valid: true,
},
+ // partial auth fields
+ "email": sql.NullString{
+ String: "test_email",
+ Valid: true,
+ },
+ "tokenKey": sql.NullString{
+ String: "test_tokenKey",
+ Valid: true,
+ },
+ "emailVisibility": sql.NullString{
+ String: "true",
+ Valid: true,
+ },
+ // custom schema fields
"field1": sql.NullString{
- String: "test1",
+ String: "test",
Valid: true,
},
"field2": sql.NullString{
- String: "123",
- Valid: false, // test invalid db serialization
+ String: "123.123",
+ Valid: true,
+ },
+ "field3": sql.NullString{
+ String: "test",
+ Valid: false, // should force resolving to empty string
+ },
+ "unknown": sql.NullString{
+ String: "test",
+ Valid: true,
},
},
{
- "id": sql.NullString{
- String: "22222222-d07e-4fbe-86b3-b8ac31982e9a",
+ "field3": sql.NullString{
+ String: "test",
Valid: true,
},
- "field1": sql.NullString{
- String: "test2",
+ "email": sql.NullString{
+ String: "test_email",
Valid: true,
},
- "field2": sql.NullString{
- String: "123",
+ "emailVisibility": sql.NullString{
+ String: "false",
Valid: true,
},
},
}
- result := models.NewRecordsFromNullStringMaps(collection, data)
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
+ scenarios := []struct {
+ collectionType string
+ expectedJson string
+ }{
+ {
+ models.CollectionTypeBase,
+ `[{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":123.123,"field3":"","id":"test_id1","updated":"2022-01-01 10:00:00.456Z"},{"collectionId":"","collectionName":"test","created":"","field1":"","field2":0,"field3":"test","id":"","updated":""}]`,
+ },
+ {
+ models.CollectionTypeAuth,
+ `[{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":123.123,"field3":"","id":"test_id1","updated":"2022-01-01 10:00:00.456Z","username":"","verified":false},{"collectionId":"","collectionName":"test","created":"","emailVisibility":false,"field1":"","field2":0,"field3":"test","id":"","updated":"","username":"","verified":false}]`,
+ },
}
- expected := `[{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test1","field2":0,"id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"},{"@collectionId":"","@collectionName":"test","created":"","field1":"test2","field2":123,"id":"22222222-d07e-4fbe-86b3-b8ac31982e9a","updated":""}]`
+ for i, s := range scenarios {
+ collection.Type = s.collectionType
+ result := models.NewRecordsFromNullStringMaps(collection, data)
- if string(encoded) != expected {
- t.Fatalf("Expected \n%v, got \n%v", expected, string(encoded))
- }
-}
+ encoded, err := json.Marshal(result)
+ if err != nil {
+ t.Errorf("(%d) Unexpected error: %v", i, err)
+ continue
+ }
-func TestRecordCollection(t *testing.T) {
- collection := &models.Collection{}
- collection.RefreshId()
-
- m := models.NewRecord(collection)
-
- if m.Collection().Id != collection.Id {
- t.Fatalf("Expected collection with id %v, got %v", collection.Id, m.Collection().Id)
+ if string(encoded) != s.expectedJson {
+ t.Errorf("(%d) Expected \n%v \ngot \n%v", i, s.expectedJson, string(encoded))
+ }
}
}
@@ -215,6 +320,17 @@ func TestRecordTableName(t *testing.T) {
}
}
+func TestRecordCollection(t *testing.T) {
+ collection := &models.Collection{}
+ collection.RefreshId()
+
+ m := models.NewRecord(collection)
+
+ if m.Collection().Id != collection.Id {
+ t.Fatalf("Expected collection with id %v, got %v", collection.Id, m.Collection().Id)
+ }
+}
+
func TestRecordExpand(t *testing.T) {
collection := &models.Collection{}
m := models.NewRecord(collection)
@@ -226,80 +342,19 @@ func TestRecordExpand(t *testing.T) {
// change the original data to check if it was shallow copied
data["test"] = 456
- expand := m.GetExpand()
+ expand := m.Expand()
if v, ok := expand["test"]; !ok || v != 123 {
t.Fatalf("Expected expand.test to be %v, got %v", 123, v)
}
}
-func TestRecordLoadAndData(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field",
- Type: schema.FieldTypeText,
- },
- ),
- }
- m := models.NewRecord(collection)
-
- data := map[string]any{
- "id": "11111111-d07e-4fbe-86b3-b8ac31982e9a",
- "created": "2022-01-01 10:00:00.123",
- "updated": "2022-01-01 10:00:00.456",
- "field": "test",
- "unknown": "test",
- }
-
- m.Load(data)
-
- // change some of original data fields to check if they were shallow copied
- data["id"] = "22222222-d07e-4fbe-86b3-b8ac31982e9a"
- data["field"] = "new_test"
-
- expectedData := `{"field":"test"}`
- encodedData, _ := json.Marshal(m.Data())
- if string(encodedData) != expectedData {
- t.Fatalf("Expected data %v, got \n%v", expectedData, string(encodedData))
- }
-
- expectedModel := `{"@collectionId":"","@collectionName":"","created":"2022-01-01 10:00:00.123","field":"test","id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
- encodedModel, _ := json.Marshal(m)
- if string(encodedModel) != expectedModel {
- t.Fatalf("Expected model %v, got \n%v", expectedModel, string(encodedModel))
- }
-}
-
-func TestRecordSetDataValue(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field",
- Type: schema.FieldTypeText,
- },
- ),
- }
- m := models.NewRecord(collection)
-
- m.SetDataValue("unknown", 123)
- m.SetDataValue("field", 123) // test whether PrepareValue will be called and casted to string
-
- data := m.Data()
- if len(data) != 1 {
- t.Fatalf("Expected only 1 data field to be set, got %v", data)
- }
-
- if v, ok := data["field"]; !ok || v != "123" {
- t.Fatalf("Expected field to be %v, got %v", "123", v)
- }
-}
-
-func TestRecordGetDataValue(t *testing.T) {
+func TestRecordSchemaData(t *testing.T) {
collection := &models.Collection{
+ Type: models.CollectionTypeAuth,
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "field1",
- Type: schema.FieldTypeNumber,
+ Type: schema.FieldTypeText,
},
&schema.SchemaField{
Name: "field2",
@@ -307,30 +362,158 @@ func TestRecordGetDataValue(t *testing.T) {
},
),
}
+
m := models.NewRecord(collection)
+ m.Set("email", "test@example.com")
+ m.Set("field1", 123)
+ m.Set("field2", 456)
+ m.Set("unknown", 789)
- m.SetDataValue("field2", 123)
-
- // missing
- v0 := m.GetDataValue("missing")
- if v0 != nil {
- t.Fatalf("Unexpected value for key 'missing'")
+ encoded, err := json.Marshal(m.SchemaData())
+ if err != nil {
+ t.Fatal(err)
}
- // existing - not set
- v1 := m.GetDataValue("field1")
- if v1 != nil {
- t.Fatalf("Unexpected value for key 'field1'")
- }
+ expected := `{"field1":"123","field2":456}`
- // existing - set
- v2 := m.GetDataValue("field2")
- if v2 != 123.0 {
- t.Fatalf("Expected 123.0, got %v", v2)
+ if v := string(encoded); v != expected {
+ t.Fatalf("Expected \n%v \ngot \n%v", v, expected)
}
}
-func TestRecordGetBoolDataValue(t *testing.T) {
+func TestRecordUnknownData(t *testing.T) {
+ collection := &models.Collection{
+ Schema: schema.NewSchema(
+ &schema.SchemaField{
+ Name: "field1",
+ Type: schema.FieldTypeText,
+ },
+ &schema.SchemaField{
+ Name: "field2",
+ Type: schema.FieldTypeNumber,
+ },
+ ),
+ }
+
+ data := map[string]any{
+ "id": "test_id",
+ "created": "2022-01-01 00:00:00.000",
+ "updated": "2022-01-01 00:00:00.000",
+ "collectionId": "test_collectionId",
+ "collectionName": "test_collectionName",
+ "expand": "test_expand",
+ "field1": "test_field1",
+ "field2": "test_field1",
+ "unknown1": "test_unknown1",
+ "unknown2": "test_unknown2",
+ "passwordHash": "test_passwordHash",
+ "username": "test_username",
+ "emailVisibility": true,
+ "email": "test_email",
+ "verified": true,
+ "tokenKey": "test_tokenKey",
+ "lastResetSentAt": "2022-01-01 00:00:00.000",
+ "lastVerificationSentAt": "2022-01-01 00:00:00.000",
+ }
+
+ scenarios := []struct {
+ collectionType string
+ expectedKeys []string
+ }{
+ {
+ models.CollectionTypeBase,
+ []string{
+ "unknown1",
+ "unknown2",
+ "passwordHash",
+ "username",
+ "emailVisibility",
+ "email",
+ "verified",
+ "tokenKey",
+ "lastResetSentAt",
+ "lastVerificationSentAt",
+ },
+ },
+ {
+ models.CollectionTypeAuth,
+ []string{"unknown1", "unknown2"},
+ },
+ }
+
+ for i, s := range scenarios {
+ collection.Type = s.collectionType
+ m := models.NewRecord(collection)
+ m.Load(data)
+
+ result := m.UnknownData()
+
+ if len(result) != len(s.expectedKeys) {
+ t.Errorf("(%d) Expected data \n%v \ngot \n%v", i, s.expectedKeys, result)
+ continue
+ }
+
+ for _, key := range s.expectedKeys {
+ if _, ok := result[key]; !ok {
+ t.Errorf("(%d) Missing expected key %q in \n%v", i, key, result)
+ }
+ }
+ }
+}
+
+func TestRecordSetAndGet(t *testing.T) {
+ collection := &models.Collection{
+ Schema: schema.NewSchema(
+ &schema.SchemaField{
+ Name: "field1",
+ Type: schema.FieldTypeText,
+ },
+ &schema.SchemaField{
+ Name: "field2",
+ Type: schema.FieldTypeNumber,
+ },
+ ),
+ }
+
+ m := models.NewRecord(collection)
+ m.Set("id", "test_id")
+ m.Set("created", "2022-09-15 00:00:00.123Z")
+ m.Set("updated", "invalid")
+ m.Set("field1", 123) // should be casted to string
+ m.Set("field2", "invlaid") // should be casted to zero-number
+ m.Set("unknown", 456) // undefined fields are allowed but not exported by default
+ m.Set("expand", map[string]any{"test": 123}) // should store the value in m.expand
+
+ if m.Get("id") != "test_id" {
+ t.Fatalf("Expected id %q, got %q", "test_id", m.Get("id"))
+ }
+
+ if m.GetString("created") != "2022-09-15 00:00:00.123Z" {
+ t.Fatalf("Expected created %q, got %q", "2022-09-15 00:00:00.123Z", m.GetString("created"))
+ }
+
+ if m.GetString("updated") != "" {
+ t.Fatalf("Expected updated to be empty, got %q", m.GetString("updated"))
+ }
+
+ if m.Get("field1") != "123" {
+ t.Fatalf("Expected field1 %q, got %v", "123", m.Get("field1"))
+ }
+
+ if m.Get("field2") != 0.0 {
+ t.Fatalf("Expected field2 %v, got %v", 0.0, m.Get("field2"))
+ }
+
+ if m.Get("unknown") != 456 {
+ t.Fatalf("Expected unknown %v, got %v", 456, m.Get("unknown"))
+ }
+
+ if m.Expand()["test"] != 123 {
+ t.Fatalf("Expected expand to be %v, got %v", map[string]any{"test": 123}, m.Expand())
+ }
+}
+
+func TestRecordGetBool(t *testing.T) {
scenarios := []struct {
value any
expected bool
@@ -348,24 +531,20 @@ func TestRecordGetBoolDataValue(t *testing.T) {
{true, true},
}
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{Name: "test"},
- ),
- }
+ collection := &models.Collection{}
for i, s := range scenarios {
m := models.NewRecord(collection)
- m.SetDataValue("test", s.value)
+ m.Set("test", s.value)
- result := m.GetBoolDataValue("test")
+ result := m.GetBool("test")
if result != s.expected {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
-func TestRecordGetStringDataValue(t *testing.T) {
+func TestRecordGetString(t *testing.T) {
scenarios := []struct {
value any
expected string
@@ -382,24 +561,20 @@ func TestRecordGetStringDataValue(t *testing.T) {
{true, "true"},
}
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{Name: "test"},
- ),
- }
+ collection := &models.Collection{}
for i, s := range scenarios {
m := models.NewRecord(collection)
- m.SetDataValue("test", s.value)
+ m.Set("test", s.value)
- result := m.GetStringDataValue("test")
+ result := m.GetString("test")
if result != s.expected {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
-func TestRecordGetIntDataValue(t *testing.T) {
+func TestRecordGetInt(t *testing.T) {
scenarios := []struct {
value any
expected int
@@ -418,24 +593,20 @@ func TestRecordGetIntDataValue(t *testing.T) {
{true, 1},
}
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{Name: "test"},
- ),
- }
+ collection := &models.Collection{}
for i, s := range scenarios {
m := models.NewRecord(collection)
- m.SetDataValue("test", s.value)
+ m.Set("test", s.value)
- result := m.GetIntDataValue("test")
+ result := m.GetInt("test")
if result != s.expected {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
-func TestRecordGetFloatDataValue(t *testing.T) {
+func TestRecordGetFloat(t *testing.T) {
scenarios := []struct {
value any
expected float64
@@ -454,26 +625,22 @@ func TestRecordGetFloatDataValue(t *testing.T) {
{true, 1},
}
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{Name: "test"},
- ),
- }
+ collection := &models.Collection{}
for i, s := range scenarios {
m := models.NewRecord(collection)
- m.SetDataValue("test", s.value)
+ m.Set("test", s.value)
- result := m.GetFloatDataValue("test")
+ result := m.GetFloat("test")
if result != s.expected {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
-func TestRecordGetTimeDataValue(t *testing.T) {
+func TestRecordGetTime(t *testing.T) {
nowTime := time.Now()
- testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000")
+ testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000Z")
scenarios := []struct {
value any
@@ -491,26 +658,22 @@ func TestRecordGetTimeDataValue(t *testing.T) {
{nowTime, nowTime},
}
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{Name: "test"},
- ),
- }
+ collection := &models.Collection{}
for i, s := range scenarios {
m := models.NewRecord(collection)
- m.SetDataValue("test", s.value)
+ m.Set("test", s.value)
- result := m.GetTimeDataValue("test")
+ result := m.GetTime("test")
if !result.Equal(s.expected) {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
-func TestRecordGetDateTimeDataValue(t *testing.T) {
+func TestRecordGetDateTime(t *testing.T) {
nowTime := time.Now()
- testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000")
+ testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000Z")
scenarios := []struct {
value any
@@ -528,24 +691,20 @@ func TestRecordGetDateTimeDataValue(t *testing.T) {
{nowTime, nowTime},
}
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{Name: "test"},
- ),
- }
+ collection := &models.Collection{}
for i, s := range scenarios {
m := models.NewRecord(collection)
- m.SetDataValue("test", s.value)
+ m.Set("test", s.value)
- result := m.GetDateTimeDataValue("test")
+ result := m.GetDateTime("test")
if !result.Time().Equal(s.expected) {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
-func TestRecordGetStringSliceDataValue(t *testing.T) {
+func TestRecordGetStringSlice(t *testing.T) {
nowTime := time.Now()
scenarios := []struct {
@@ -565,17 +724,13 @@ func TestRecordGetStringSliceDataValue(t *testing.T) {
{[]string{"test", "test", "123"}, []string{"test", "123"}},
}
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{Name: "test"},
- ),
- }
+ collection := &models.Collection{}
for i, s := range scenarios {
m := models.NewRecord(collection)
- m.SetDataValue("test", s.value)
+ m.Set("test", s.value)
- result := m.GetStringSliceDataValue("test")
+ result := m.GetStringSlice("test")
if len(result) != len(s.expected) {
t.Errorf("(%d) Expected %d elements, got %d: %v", i, len(s.expected), len(result), result)
@@ -590,6 +745,61 @@ func TestRecordGetStringSliceDataValue(t *testing.T) {
}
}
+func TestRecordUnmarshalJSONField(t *testing.T) {
+ collection := &models.Collection{
+ Schema: schema.NewSchema(&schema.SchemaField{
+ Name: "field",
+ Type: schema.FieldTypeJson,
+ }),
+ }
+ m := models.NewRecord(collection)
+
+ var testPointer *string
+ var testStr string
+ var testInt int
+ var testBool bool
+ var testSlice []int
+ var testMap map[string]any
+
+ scenarios := []struct {
+ value any
+ destination any
+ expectError bool
+ expectedJson string
+ }{
+ {nil, testStr, true, `""`},
+ {"", testStr, true, `""`},
+ {1, testInt, false, `1`},
+ {true, testBool, false, `true`},
+ {[]int{1, 2, 3}, testSlice, false, `[1,2,3]`},
+ {map[string]any{"test": 123}, testMap, false, `{"test":123}`},
+ // json encoded values
+ {`null`, testPointer, false, `null`},
+ {`true`, testBool, false, `true`},
+ {`456`, testInt, false, `456`},
+ {`"test"`, testStr, false, `"test"`},
+ {`[4,5,6]`, testSlice, false, `[4,5,6]`},
+ {`{"test":456}`, testMap, false, `{"test":456}`},
+ }
+
+ for i, s := range scenarios {
+ m.Set("field", s.value)
+
+ err := m.UnmarshalJSONField("field", &s.destination)
+ hasErr := err != nil
+
+ if hasErr != s.expectError {
+ t.Errorf("(%d) Expected hasErr %v, got %v", i, s.expectError, hasErr)
+ continue
+ }
+
+ raw, _ := json.Marshal(s.destination)
+ if v := string(raw); v != s.expectedJson {
+ t.Errorf("(%d) Expected %q, got %q", i, s.expectedJson, v)
+ }
+ }
+}
+
func TestRecordBaseFilesPath(t *testing.T) {
collection := &models.Collection{}
collection.RefreshId()
@@ -633,9 +843,9 @@ func TestRecordFindFileFieldByFile(t *testing.T) {
}
m := models.NewRecord(collection)
- m.SetDataValue("field1", "test")
- m.SetDataValue("field2", "test.png")
- m.SetDataValue("field3", []string{"test1.png", "test2.png"})
+ m.Set("field1", "test")
+ m.Set("field2", "test.png")
+ m.Set("field3", []string{"test1.png", "test2.png"})
scenarios := []struct {
filename string
@@ -663,6 +873,79 @@ func TestRecordFindFileFieldByFile(t *testing.T) {
}
}
+func TestRecordLoadAndData(t *testing.T) {
+ collection := &models.Collection{
+ Schema: schema.NewSchema(
+ &schema.SchemaField{
+ Name: "field1",
+ Type: schema.FieldTypeText,
+ },
+ &schema.SchemaField{
+ Name: "field2",
+ Type: schema.FieldTypeNumber,
+ },
+ ),
+ }
+
+ data := map[string]any{
+ "id": "test_id",
+ "created": "2022-01-01 10:00:00.123Z",
+ "updated": "2022-01-01 10:00:00.456Z",
+ "field1": "test_field",
+ "field2": "123", // should be casted to float
+ "unknown": "test_unknown",
+ // auth collection sepcific casting test
+ "passwordHash": "test_passwordHash",
+ "emailVisibility": "12345", // should be casted to bool only for auth collections
+ "username": 123, // should be casted to string only for auth collections
+ "email": "test_email",
+ "verified": true,
+ "tokenKey": "test_tokenKey",
+ "lastResetSentAt": "2022-01-01 11:00:00.000", // should be casted to DateTime only for auth collections
+ "lastVerificationSentAt": "2022-01-01 12:00:00.000", // should be casted to DateTime only for auth collections
+ }
+
+ scenarios := []struct {
+ collectionType string
+ }{
+ {models.CollectionTypeBase},
+ {models.CollectionTypeAuth},
+ }
+
+ for i, s := range scenarios {
+ collection.Type = s.collectionType
+ m := models.NewRecord(collection)
+
+ m.Load(data)
+
+ expectations := map[string]any{}
+ for k, v := range data {
+ expectations[k] = v
+ }
+
+ expectations["created"], _ = types.ParseDateTime("2022-01-01 10:00:00.123Z")
+ expectations["updated"], _ = types.ParseDateTime("2022-01-01 10:00:00.456Z")
+ expectations["field2"] = 123.0
+
+ // extra casting test
+ if collection.IsAuth() {
+ lastResetSentAt, _ := types.ParseDateTime(expectations["lastResetSentAt"])
+ lastVerificationSentAt, _ := types.ParseDateTime(expectations["lastVerificationSentAt"])
+ expectations["emailVisibility"] = false
+ expectations["username"] = "123"
+ expectations["verified"] = true
+ expectations["lastResetSentAt"] = lastResetSentAt
+ expectations["lastVerificationSentAt"] = lastVerificationSentAt
+ }
+
+ for k, v := range expectations {
+ if m.Get(k) != v {
+ t.Errorf("(%d) Expected field %s to be %v, got %v", i, k, v, m.Get(k))
+ }
+ }
+ }
+}
+
func TestRecordColumnValueMap(t *testing.T) {
collection := &models.Collection{
Schema: schema.NewSchema(
@@ -679,7 +962,7 @@ func TestRecordColumnValueMap(t *testing.T) {
},
},
&schema.SchemaField{
- Name: "#field3",
+ Name: "field3",
Type: schema.FieldTypeSelect,
Options: &schema.SelectOptions{
MaxSelect: 2,
@@ -690,41 +973,69 @@ func TestRecordColumnValueMap(t *testing.T) {
Name: "field4",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
- MaxSelect: 2,
+ MaxSelect: types.Pointer(2),
},
},
),
}
- id1 := "11111111-1e32-4c94-ae06-90c25fcf6791"
- id2 := "22222222-1e32-4c94-ae06-90c25fcf6791"
- created, _ := types.ParseDateTime("2022-01-01 10:00:30.123")
-
- m := models.NewRecord(collection)
- m.Id = id1
- m.Created = created
- m.SetDataValue("field1", "test")
- m.SetDataValue("field2", "test.png")
- m.SetDataValue("#field3", []string{"test1", "test2"})
- m.SetDataValue("field4", []string{id1, id2, id1})
-
- result := m.ColumnValueMap()
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
+ scenarios := []struct {
+ collectionType string
+ expectedJson string
+ }{
+ {
+ models.CollectionTypeBase,
+ `{"created":"2022-01-01 10:00:30.123Z","field1":"test","field2":"test.png","field3":["test1","test2"],"field4":["test11","test12"],"id":"test_id","updated":""}`,
+ },
+ {
+ models.CollectionTypeAuth,
+ `{"created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"test.png","field3":["test1","test2"],"field4":["test11","test12"],"id":"test_id","lastResetSentAt":"2022-01-02 10:00:30.123Z","lastVerificationSentAt":"","passwordHash":"test_passwordHash","tokenKey":"test_tokenKey","updated":"","username":"test_username","verified":false}`,
+ },
}
- expected := `{"#field3":["test1","test2"],"created":"2022-01-01 10:00:30.123","field1":"test","field2":"test.png","field4":["11111111-1e32-4c94-ae06-90c25fcf6791","22222222-1e32-4c94-ae06-90c25fcf6791"],"id":"11111111-1e32-4c94-ae06-90c25fcf6791","updated":""}`
+ created, _ := types.ParseDateTime("2022-01-01 10:00:30.123Z")
+ lastResetSentAt, _ := types.ParseDateTime("2022-01-02 10:00:30.123Z")
+ data := map[string]any{
+ "id": "test_id",
+ "created": created,
+ "field1": "test",
+ "field2": "test.png",
+ "field3": []string{"test1", "test2"},
+ "field4": []string{"test11", "test12", "test11"}, // strip duplicate,
+ "unknown": "test_unknown",
+ "passwordHash": "test_passwordHash",
+ "username": "test_username",
+ "emailVisibility": true,
+ "email": "test_email",
+ "verified": "invalid", // should be casted
+ "tokenKey": "test_tokenKey",
+ "lastResetSentAt": lastResetSentAt,
+ }
- if string(encoded) != expected {
- t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
+ m := models.NewRecord(collection)
+
+ for i, s := range scenarios {
+ collection.Type = s.collectionType
+
+ m.Load(data)
+
+ result := m.ColumnValueMap()
+
+ encoded, err := json.Marshal(result)
+ if err != nil {
+ t.Errorf("(%d) Unexpected error %v", i, err)
+ continue
+ }
+
+ if str := string(encoded); str != s.expectedJson {
+ t.Errorf("(%d) Expected \n%v \ngot \n%v", i, s.expectedJson, str)
+ }
}
}
-func TestRecordPublicExport(t *testing.T) {
+func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
collection := &models.Collection{
- Name: "test",
+ Name: "c_name",
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "field1",
@@ -739,7 +1050,7 @@ func TestRecordPublicExport(t *testing.T) {
},
},
&schema.SchemaField{
- Name: "#field3",
+ Name: "field3",
Type: schema.FieldTypeSelect,
Options: &schema.SelectOptions{
MaxSelect: 2,
@@ -748,77 +1059,121 @@ func TestRecordPublicExport(t *testing.T) {
},
),
}
+ collection.Id = "c_id"
- created, _ := types.ParseDateTime("2022-01-01 10:00:30.123")
+ scenarios := []struct {
+ collectionType string
+ exportHidden bool
+ exportUnknown bool
+ expectedJson string
+ }{
+ // base
+ {
+ models.CollectionTypeBase,
+ false,
+ false,
+ `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","updated":""}`,
+ },
+ {
+ models.CollectionTypeBase,
+ true,
+ false,
+ `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","updated":""}`,
+ },
+ {
+ models.CollectionTypeBase,
+ false,
+ true,
+ `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","lastResetSentAt":"2022-01-02 10:00:30.123Z","lastVerificationSentAt":"test_lastVerificationSentAt","passwordHash":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","updated":"","username":123,"verified":true}`,
+ },
+ {
+ models.CollectionTypeBase,
+ true,
+ true,
+ `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","lastResetSentAt":"2022-01-02 10:00:30.123Z","lastVerificationSentAt":"test_lastVerificationSentAt","passwordHash":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","updated":"","username":123,"verified":true}`,
+ },
+
+ // auth
+ {
+ models.CollectionTypeAuth,
+ false,
+ false,
+ `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","emailVisibility":false,"expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","updated":"","username":"123","verified":true}`,
+ },
+ {
+ models.CollectionTypeAuth,
+ true,
+ false,
+ `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","updated":"","username":"123","verified":true}`,
+ },
+ {
+ models.CollectionTypeAuth,
+ false,
+ true,
+ `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","emailVisibility":false,"expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","unknown":"test_unknown","updated":"","username":"123","verified":true}`,
+ },
+ {
+ models.CollectionTypeAuth,
+ true,
+ true,
+ `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","unknown":"test_unknown","updated":"","username":"123","verified":true}`,
+ },
+ }
+
+ created, _ := types.ParseDateTime("2022-01-01 10:00:30.123Z")
+ lastResetSentAt, _ := types.ParseDateTime("2022-01-02 10:00:30.123Z")
+
+ data := map[string]any{
+ "id": "test_id",
+ "created": created,
+ "field1": "test",
+ "field2": "test.png",
+ "field3": []string{"test1", "test2"},
+ "expand": map[string]any{"test": 123},
+ "collectionId": "m_id", // should be always ignored
+ "collectionName": "m_name", // should be always ignored
+ "unknown": "test_unknown",
+ "passwordHash": "test_passwordHash",
+ "username": 123, // for auth collections should be casted to string
+ "emailVisibility": "test_invalid", // for auth collections should be casted to bool
+ "email": "test_email",
+ "verified": true,
+ "tokenKey": "test_tokenKey",
+ "lastResetSentAt": lastResetSentAt,
+ "lastVerificationSentAt": "test_lastVerificationSentAt",
+ }
m := models.NewRecord(collection)
- m.Id = "210a896c-1e32-4c94-ae06-90c25fcf6791"
- m.Created = created
- m.SetDataValue("field1", "test")
- m.SetDataValue("field2", "test.png")
- m.SetDataValue("#field3", []string{"test1", "test2"})
- m.SetExpand(map[string]any{"test": 123})
- result := m.PublicExport()
+ for i, s := range scenarios {
+ collection.Type = s.collectionType
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
+ m.Load(data)
+ m.IgnoreEmailVisibility(s.exportHidden)
+ m.WithUnkownData(s.exportUnknown)
- expected := `{"@collectionId":"","@collectionName":"test","@expand":{"test":123},"created":"2022-01-01 10:00:30.123","field1":"test","field2":"test.png","id":"210a896c-1e32-4c94-ae06-90c25fcf6791","updated":""}`
+ exportResult, err := json.Marshal(m.PublicExport())
+ if err != nil {
+ t.Errorf("(%d) Unexpected error %v", i, err)
+ continue
+ }
+ exportResultStr := string(exportResult)
- if string(encoded) != expected {
- t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
- }
-}
+ // MarshalJSON and PublicExport should return the same
+ marshalResult, err := m.MarshalJSON()
+ if err != nil {
+ t.Errorf("(%d) Unexpected error %v", i, err)
+ continue
+ }
+ marshalResultStr := string(marshalResult)
-func TestRecordMarshalJSON(t *testing.T) {
- collection := &models.Collection{
- Name: "test",
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 1,
- MaxSize: 1,
- },
- },
- &schema.SchemaField{
- Name: "#field3",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- MaxSelect: 2,
- Values: []string{"test1", "test2", "test3"},
- },
- },
- ),
- }
+ if exportResultStr != marshalResultStr {
+ t.Errorf("(%d) Expected the PublicExport to be the same as MarshalJSON, but got \n%v \nvs \n%v", i, exportResultStr, marshalResultStr)
+ }
- created, _ := types.ParseDateTime("2022-01-01 10:00:30.123")
-
- m := models.NewRecord(collection)
- m.Id = "210a896c-1e32-4c94-ae06-90c25fcf6791"
- m.Created = created
- m.SetDataValue("field1", "test")
- m.SetDataValue("field2", "test.png")
- m.SetDataValue("#field3", []string{"test1", "test2"})
- m.SetExpand(map[string]any{"test": 123})
-
- encoded, err := m.MarshalJSON()
- if err != nil {
- t.Fatal(err)
- }
-
- expected := `{"@collectionId":"","@collectionName":"test","@expand":{"test":123},"created":"2022-01-01 10:00:30.123","field1":"test","field2":"test.png","id":"210a896c-1e32-4c94-ae06-90c25fcf6791","updated":""}`
-
- if string(encoded) != expected {
- t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
+ if exportResultStr != s.expectedJson {
+ t.Errorf("(%d) Expected json \n%v \ngot \n%v", i, s.expectedJson, exportResultStr)
+ }
}
}
@@ -826,24 +1181,489 @@ func TestRecordUnmarshalJSON(t *testing.T) {
collection := &models.Collection{
Schema: schema.NewSchema(
&schema.SchemaField{
- Name: "field",
+ Name: "field1",
Type: schema.FieldTypeText,
},
+ &schema.SchemaField{
+ Name: "field2",
+ Type: schema.FieldTypeNumber,
+ },
),
}
- m := models.NewRecord(collection)
- m.UnmarshalJSON([]byte(`{
- "id": "11111111-d07e-4fbe-86b3-b8ac31982e9a",
- "created": "2022-01-01 10:00:00.123",
- "updated": "2022-01-01 10:00:00.456",
- "field": "test",
- "unknown": "test"
- }`))
+ data := map[string]any{
+ "id": "test_id",
+ "created": "2022-01-01 10:00:00.123Z",
+ "updated": "2022-01-01 10:00:00.456Z",
+ "field1": "test_field",
+ "field2": "123", // should be casted to float
+ "unknown": "test_unknown",
+ // auth collection sepcific casting test
+ "passwordHash": "test_passwordHash",
+ "emailVisibility": "12345", // should be casted to bool only for auth collections
+ "username": 123.123, // should be casted to string only for auth collections
+ "email": "test_email",
+ "verified": true,
+ "tokenKey": "test_tokenKey",
+ "lastResetSentAt": "2022-01-01 11:00:00.000", // should be casted to DateTime only for auth collections
+ "lastVerificationSentAt": "2022-01-01 12:00:00.000", // should be casted to DateTime only for auth collections
+ }
+ dataRaw, err := json.Marshal(data)
+ if err != nil {
+ t.Fatalf("Unexpected data marshal error %v", err)
+ }
- expected := `{"@collectionId":"","@collectionName":"","created":"2022-01-01 10:00:00.123","field":"test","id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
- encoded, _ := json.Marshal(m)
- if string(encoded) != expected {
- t.Fatalf("Expected model %v, got \n%v", expected, string(encoded))
+ scenarios := []struct {
+ collectionType string
+ }{
+ {models.CollectionTypeBase},
+ {models.CollectionTypeAuth},
+ }
+
+ // with invalid data
+ m0 := models.NewRecord(collection)
+ if err := m0.UnmarshalJSON([]byte("test")); err == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ // with valid data (it should be pretty much the same as load)
+ for i, s := range scenarios {
+ collection.Type = s.collectionType
+ m := models.NewRecord(collection)
+
+ err := m.UnmarshalJSON(dataRaw)
+ if err != nil {
+ t.Errorf("(%d) Unexpected error %v", i, err)
+ continue
+ }
+
+ expectations := map[string]any{}
+ for k, v := range data {
+ expectations[k] = v
+ }
+
+ expectations["created"], _ = types.ParseDateTime("2022-01-01 10:00:00.123Z")
+ expectations["updated"], _ = types.ParseDateTime("2022-01-01 10:00:00.456Z")
+ expectations["field2"] = 123.0
+
+ // extra casting test
+ if collection.IsAuth() {
+ lastResetSentAt, _ := types.ParseDateTime(expectations["lastResetSentAt"])
+ lastVerificationSentAt, _ := types.ParseDateTime(expectations["lastVerificationSentAt"])
+ expectations["emailVisibility"] = false
+ expectations["username"] = "123.123"
+ expectations["verified"] = true
+ expectations["lastResetSentAt"] = lastResetSentAt
+ expectations["lastVerificationSentAt"] = lastVerificationSentAt
+ }
+
+ for k, v := range expectations {
+ if m.Get(k) != v {
+ t.Errorf("(%d) Expected field %s to be %v, got %v", i, k, v, m.Get(k))
+ }
+ }
+ }
+}
+
+// -------------------------------------------------------------------
+// Auth helpers:
+// -------------------------------------------------------------------
+
+func TestRecordUsername(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ expectError bool
+ }{
+ {models.CollectionTypeBase, true},
+ {models.CollectionTypeAuth, false},
+ }
+
+ testValue := "test 1232 !@#%" // formatting isn't checked
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.SetUsername(testValue); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.Username(); v != "" {
+ t.Fatalf("(%d) Expected empty string, got %q", i, v)
+ }
+ // verify that nothing is stored in the record data slice
+ if v := m.Get(schema.FieldNameUsername); v != nil {
+ t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameUsername, v)
+ }
+ } else {
+ if err := m.SetUsername(testValue); err != nil {
+ t.Fatalf("(%d) Expected nil, got error %v", i, err)
+ }
+ if v := m.Username(); v != testValue {
+ t.Fatalf("(%d) Expected %q, got %q", i, testValue, v)
+ }
+ // verify that the field is stored in the record data slice
+ if v := m.Get(schema.FieldNameUsername); v != testValue {
+ t.Fatalf("(%d) Expected data field value %q, got %q", i, testValue, v)
+ }
+ }
+ }
+}
+
+func TestRecordEmail(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ expectError bool
+ }{
+ {models.CollectionTypeBase, true},
+ {models.CollectionTypeAuth, false},
+ }
+
+ testValue := "test 1232 !@#%" // formatting isn't checked
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.SetEmail(testValue); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.Email(); v != "" {
+ t.Fatalf("(%d) Expected empty string, got %q", i, v)
+ }
+ // verify that nothing is stored in the record data slice
+ if v := m.Get(schema.FieldNameEmail); v != nil {
+ t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameEmail, v)
+ }
+ } else {
+ if err := m.SetEmail(testValue); err != nil {
+ t.Fatalf("(%d) Expected nil, got error %v", i, err)
+ }
+ if v := m.Email(); v != testValue {
+ t.Fatalf("(%d) Expected %q, got %q", i, testValue, v)
+ }
+ // verify that the field is stored in the record data slice
+ if v := m.Get(schema.FieldNameEmail); v != testValue {
+ t.Fatalf("(%d) Expected data field value %q, got %q", i, testValue, v)
+ }
+ }
+ }
+}
+
+func TestRecordEmailVisibility(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ value bool
+ expectError bool
+ }{
+ {models.CollectionTypeBase, true, true},
+ {models.CollectionTypeBase, true, true},
+ {models.CollectionTypeAuth, false, false},
+ {models.CollectionTypeAuth, true, false},
+ }
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.SetEmailVisibility(s.value); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.EmailVisibility(); v != false {
+ t.Fatalf("(%d) Expected empty string, got %v", i, v)
+ }
+ // verify that nothing is stored in the record data slice
+ if v := m.Get(schema.FieldNameEmailVisibility); v != nil {
+ t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameEmailVisibility, v)
+ }
+ } else {
+ if err := m.SetEmailVisibility(s.value); err != nil {
+ t.Fatalf("(%d) Expected nil, got error %v", i, err)
+ }
+ if v := m.EmailVisibility(); v != s.value {
+ t.Fatalf("(%d) Expected %v, got %v", i, s.value, v)
+ }
+ // verify that the field is stored in the record data slice
+ if v := m.Get(schema.FieldNameEmailVisibility); v != s.value {
+ t.Fatalf("(%d) Expected data field value %v, got %v", i, s.value, v)
+ }
+ }
+ }
+}
+
+func TestRecordEmailVerified(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ value bool
+ expectError bool
+ }{
+ {models.CollectionTypeBase, true, true},
+ {models.CollectionTypeBase, true, true},
+ {models.CollectionTypeAuth, false, false},
+ {models.CollectionTypeAuth, true, false},
+ }
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.SetVerified(s.value); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.Verified(); v != false {
+ t.Fatalf("(%d) Expected empty string, got %v", i, v)
+ }
+ // verify that nothing is stored in the record data slice
+ if v := m.Get(schema.FieldNameVerified); v != nil {
+ t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameVerified, v)
+ }
+ } else {
+ if err := m.SetVerified(s.value); err != nil {
+ t.Fatalf("(%d) Expected nil, got error %v", i, err)
+ }
+ if v := m.Verified(); v != s.value {
+ t.Fatalf("(%d) Expected %v, got %v", i, s.value, v)
+ }
+ // verify that the field is stored in the record data slice
+ if v := m.Get(schema.FieldNameVerified); v != s.value {
+ t.Fatalf("(%d) Expected data field value %v, got %v", i, s.value, v)
+ }
+ }
+ }
+}
+
+func TestRecordTokenKey(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ expectError bool
+ }{
+ {models.CollectionTypeBase, true},
+ {models.CollectionTypeAuth, false},
+ }
+
+ testValue := "test 1232 !@#%" // formatting isn't checked
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.SetTokenKey(testValue); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.TokenKey(); v != "" {
+ t.Fatalf("(%d) Expected empty string, got %q", i, v)
+ }
+ // verify that nothing is stored in the record data slice
+ if v := m.Get(schema.FieldNameTokenKey); v != nil {
+ t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameTokenKey, v)
+ }
+ } else {
+ if err := m.SetTokenKey(testValue); err != nil {
+ t.Fatalf("(%d) Expected nil, got error %v", i, err)
+ }
+ if v := m.TokenKey(); v != testValue {
+ t.Fatalf("(%d) Expected %q, got %q", i, testValue, v)
+ }
+ // verify that the field is stored in the record data slice
+ if v := m.Get(schema.FieldNameTokenKey); v != testValue {
+ t.Fatalf("(%d) Expected data field value %q, got %q", i, testValue, v)
+ }
+ }
+ }
+}
+
+func TestRecordRefreshTokenKey(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ expectError bool
+ }{
+ {models.CollectionTypeBase, true},
+ {models.CollectionTypeAuth, false},
+ }
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.RefreshTokenKey(); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.TokenKey(); v != "" {
+ t.Fatalf("(%d) Expected empty string, got %q", i, v)
+ }
+ // verify that nothing is stored in the record data slice
+ if v := m.Get(schema.FieldNameTokenKey); v != nil {
+ t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameTokenKey, v)
+ }
+ } else {
+ if err := m.RefreshTokenKey(); err != nil {
+ t.Fatalf("(%d) Expected nil, got error %v", i, err)
+ }
+ if v := m.TokenKey(); len(v) != 50 {
+ t.Fatalf("(%d) Expected 50 chars, got %d", i, len(v))
+ }
+ // verify that the field is stored in the record data slice
+ if v := m.Get(schema.FieldNameTokenKey); v != m.TokenKey() {
+ t.Fatalf("(%d) Expected data field value %q, got %q", i, m.TokenKey(), v)
+ }
+ }
+ }
+}
+
+func TestRecordLastResetSentAt(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ expectError bool
+ }{
+ {models.CollectionTypeBase, true},
+ {models.CollectionTypeAuth, false},
+ }
+
+ testValue, err := types.ParseDateTime("2022-01-01 00:00:00.123Z")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.SetLastResetSentAt(testValue); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.LastResetSentAt(); !v.IsZero() {
+ t.Fatalf("(%d) Expected empty value, got %v", i, v)
+ }
+ // verify that nothing is stored in the record data slice
+ if v := m.Get(schema.FieldNameLastResetSentAt); v != nil {
+ t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameLastResetSentAt, v)
+ }
+ } else {
+ if err := m.SetLastResetSentAt(testValue); err != nil {
+ t.Fatalf("(%d) Expected nil, got error %v", i, err)
+ }
+ if v := m.LastResetSentAt(); v != testValue {
+ t.Fatalf("(%d) Expected %v, got %v", i, testValue, v)
+ }
+ // verify that the field is stored in the record data slice
+ if v := m.Get(schema.FieldNameLastResetSentAt); v != testValue {
+ t.Fatalf("(%d) Expected data field value %v, got %v", i, testValue, v)
+ }
+ }
+ }
+}
+
+func TestRecordLastVerificationSentAt(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ expectError bool
+ }{
+ {models.CollectionTypeBase, true},
+ {models.CollectionTypeAuth, false},
+ }
+
+ testValue, err := types.ParseDateTime("2022-01-01 00:00:00.123Z")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.SetLastVerificationSentAt(testValue); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.LastVerificationSentAt(); !v.IsZero() {
+ t.Fatalf("(%d) Expected empty value, got %v", i, v)
+ }
+ // verify that nothing is stored in the record data slice
+ if v := m.Get(schema.FieldNameLastVerificationSentAt); v != nil {
+ t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameLastVerificationSentAt, v)
+ }
+ } else {
+ if err := m.SetLastVerificationSentAt(testValue); err != nil {
+ t.Fatalf("(%d) Expected nil, got error %v", i, err)
+ }
+ if v := m.LastVerificationSentAt(); v != testValue {
+ t.Fatalf("(%d) Expected %v, got %v", i, testValue, v)
+ }
+ // verify that the field is stored in the record data slice
+ if v := m.Get(schema.FieldNameLastVerificationSentAt); v != testValue {
+ t.Fatalf("(%d) Expected data field value %v, got %v", i, testValue, v)
+ }
+ }
+ }
+}
+
+func TestRecordValidatePassword(t *testing.T) {
+ // 123456
+ hash := "$2a$10$YKU8mPP8sTE3xZrpuM.xQuq27KJ7aIJB2oUeKPsDDqZshbl5g5cDK"
+
+ scenarios := []struct {
+ collectionType string
+ password string
+ hash string
+ expected bool
+ }{
+ {models.CollectionTypeBase, "123456", hash, false},
+ {models.CollectionTypeAuth, "", "", false},
+ {models.CollectionTypeAuth, "", hash, false},
+ {models.CollectionTypeAuth, "123456", hash, true},
+ {models.CollectionTypeAuth, "654321", hash, false},
+ }
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+ m.Set(schema.FieldNamePasswordHash, hash)
+
+ if v := m.ValidatePassword(s.password); v != s.expected {
+ t.Errorf("(%d) Expected %v, got %v", i, s.expected, v)
+ }
+ }
+}
+
+func TestRecordSetPassword(t *testing.T) {
+ scenarios := []struct {
+ collectionType string
+ password string
+ expectError bool
+ }{
+ {models.CollectionTypeBase, "", true},
+ {models.CollectionTypeBase, "123456", true},
+ {models.CollectionTypeAuth, "", true},
+ {models.CollectionTypeAuth, "123456", false},
+ }
+
+ for i, s := range scenarios {
+ collection := &models.Collection{Type: s.collectionType}
+ m := models.NewRecord(collection)
+
+ if s.expectError {
+ if err := m.SetPassword(s.password); err == nil {
+ t.Errorf("(%d) Expected error, got nil", i)
+ }
+ if v := m.GetString(schema.FieldNamePasswordHash); v != "" {
+ t.Errorf("(%d) Expected empty hash, got %q", i, v)
+ }
+ } else {
+ if err := m.SetPassword(s.password); err != nil {
+ t.Errorf("(%d) Expected nil, got err", i)
+ }
+ if v := m.GetString(schema.FieldNamePasswordHash); v == "" {
+ t.Errorf("(%d) Expected non empty hash", i)
+ }
+ if !m.ValidatePassword(s.password) {
+ t.Errorf("(%d) Expected true, got false", i)
+ }
+ }
}
}
diff --git a/models/request.go b/models/request.go
index 662b9789..dbefc91c 100644
--- a/models/request.go
+++ b/models/request.go
@@ -6,9 +6,9 @@ var _ Model = (*Request)(nil)
// list with the supported values for `Request.Auth`
const (
- RequestAuthGuest = "guest"
- RequestAuthUser = "user"
- RequestAuthAdmin = "admin"
+ RequestAuthGuest = "guest"
+ RequestAuthAdmin = "admin"
+ RequestAuthRecord = "auth_record"
)
type Request struct {
diff --git a/models/schema/schema_field.go b/models/schema/schema_field.go
index dd8e772d..64ab04de 100644
--- a/models/schema/schema_field.go
+++ b/models/schema/schema_field.go
@@ -13,21 +13,55 @@ import (
"github.com/spf13/cast"
)
-var schemaFieldNameRegex = regexp.MustCompile(`^\#?\w+$`)
+var schemaFieldNameRegex = regexp.MustCompile(`^\w+$`)
-// reserved internal field names
+// commonly used field names
const (
- ReservedFieldNameId = "id"
- ReservedFieldNameCreated = "created"
- ReservedFieldNameUpdated = "updated"
+ FieldNameId = "id"
+ FieldNameCreated = "created"
+ FieldNameUpdated = "updated"
+ FieldNameCollectionId = "collectionId"
+ FieldNameCollectionName = "collectionName"
+ FieldNameExpand = "expand"
+ FieldNameUsername = "username"
+ FieldNameEmail = "email"
+ FieldNameEmailVisibility = "emailVisibility"
+ FieldNameVerified = "verified"
+ FieldNameTokenKey = "tokenKey"
+ FieldNamePasswordHash = "passwordHash"
+ FieldNameLastResetSentAt = "lastResetSentAt"
+ FieldNameLastVerificationSentAt = "lastVerificationSentAt"
)
-// ReservedFieldNames returns slice with reserved/system field names.
-func ReservedFieldNames() []string {
+// BaseModelFieldNames returns the field names that all models have (id, created, updated).
+func BaseModelFieldNames() []string {
return []string{
- ReservedFieldNameId,
- ReservedFieldNameCreated,
- ReservedFieldNameUpdated,
+ FieldNameId,
+ FieldNameCreated,
+ FieldNameUpdated,
+ }
+}
+
+// SystemFields returns special internal field names that are usually readonly.
+func SystemFieldNames() []string {
+ return []string{
+ FieldNameCollectionId,
+ FieldNameCollectionName,
+ FieldNameExpand,
+ }
+}
+
+// AuthFieldNames returns the reserved "auth" collection auth field names.
+func AuthFieldNames() []string {
+ return []string{
+ FieldNameUsername,
+ FieldNameEmail,
+ FieldNameEmailVisibility,
+ FieldNameVerified,
+ FieldNameTokenKey,
+ FieldNamePasswordHash,
+ FieldNameLastResetSentAt,
+ FieldNameLastVerificationSentAt,
}
}
@@ -43,7 +77,9 @@ const (
FieldTypeJson string = "json"
FieldTypeFile string = "file"
FieldTypeRelation string = "relation"
- FieldTypeUser string = "user"
+
+ // Deprecated: Will be removed in v0.9!
+ FieldTypeUser string = "user"
)
// FieldTypes returns slice with all supported field types.
@@ -59,7 +95,6 @@ func FieldTypes() []string {
FieldTypeJson,
FieldTypeFile,
FieldTypeRelation,
- FieldTypeUser,
}
}
@@ -69,7 +104,6 @@ func ArraybleFieldTypes() []string {
FieldTypeSelect,
FieldTypeFile,
FieldTypeRelation,
- FieldTypeUser,
}
}
@@ -90,7 +124,7 @@ func (f *SchemaField) ColDefinition() string {
case FieldTypeNumber:
return "REAL DEFAULT 0"
case FieldTypeBool:
- return "Boolean DEFAULT FALSE"
+ return "BOOLEAN DEFAULT FALSE"
case FieldTypeJson:
return "JSON DEFAULT NULL"
default:
@@ -133,9 +167,11 @@ func (f SchemaField) Validate() error {
// init field options (if not already)
f.InitOptions()
- // add commonly used filter literals to the exclude names list
- excludeNames := ReservedFieldNames()
+ excludeNames := BaseModelFieldNames()
+ // exclude filter literals
excludeNames = append(excludeNames, "null", "true", "false")
+ // exclude system literals
+ excludeNames = append(excludeNames, SystemFieldNames()...)
return validation.ValidateStruct(&f,
validation.Field(&f.Options, validation.Required, validation.By(f.checkOptions)),
@@ -198,8 +234,11 @@ func (f *SchemaField) InitOptions() error {
options = &FileOptions{}
case FieldTypeRelation:
options = &RelationOptions{}
+
+ // Deprecated: Will be removed in v0.9!
case FieldTypeUser:
options = &UserOptions{}
+
default:
return errors.New("Missing or unknown field field type.")
}
@@ -259,19 +298,7 @@ func (f *SchemaField) PrepareValue(value any) any {
ids := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*RelationOptions)
- if options.MaxSelect <= 1 {
- if len(ids) > 0 {
- return ids[0]
- }
- return ""
- }
-
- return ids
- case FieldTypeUser:
- ids := list.ToUniqueStringSlice(value)
-
- options, _ := f.Options.(*UserOptions)
- if options.MaxSelect <= 1 {
+ if options.MaxSelect != nil && *options.MaxSelect <= 1 {
if len(ids) > 0 {
return ids[0]
}
@@ -426,13 +453,18 @@ type SelectOptions struct {
}
func (o SelectOptions) Validate() error {
+ max := len(o.Values)
+ if max == 0 {
+ max = 1
+ }
+
return validation.ValidateStruct(&o,
validation.Field(&o.Values, validation.Required),
validation.Field(
&o.MaxSelect,
validation.Required,
validation.Min(1),
- validation.Max(len(o.Values)),
+ validation.Max(max),
),
)
}
@@ -469,27 +501,27 @@ func (o FileOptions) Validate() error {
// -------------------------------------------------------------------
type RelationOptions struct {
- MaxSelect int `form:"maxSelect" json:"maxSelect"`
+ MaxSelect *int `form:"maxSelect" json:"maxSelect"`
CollectionId string `form:"collectionId" json:"collectionId"`
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
}
func (o RelationOptions) Validate() error {
return validation.ValidateStruct(&o,
- validation.Field(&o.MaxSelect, validation.Required, validation.Min(1)),
validation.Field(&o.CollectionId, validation.Required),
+ validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(1)),
)
}
// -------------------------------------------------------------------
+// Deprecated: Will be removed in v0.9!
type UserOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
}
+// Deprecated: Will be removed in v0.9!
func (o UserOptions) Validate() error {
- return validation.ValidateStruct(&o,
- validation.Field(&o.MaxSelect, validation.Required, validation.Min(1)),
- )
+ return nil
}
diff --git a/models/schema/schema_field_test.go b/models/schema/schema_field_test.go
index fb752deb..3a9db74b 100644
--- a/models/schema/schema_field_test.go
+++ b/models/schema/schema_field_test.go
@@ -11,27 +11,48 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
-func TestReservedFieldNames(t *testing.T) {
- result := schema.ReservedFieldNames()
+func TestBaseModelFieldNames(t *testing.T) {
+ result := schema.BaseModelFieldNames()
+ expected := 3
- if len(result) != 3 {
- t.Fatalf("Expected %d names, got %d (%v)", 3, len(result), result)
+ if len(result) != expected {
+ t.Fatalf("Expected %d field names, got %d (%v)", expected, len(result), result)
+ }
+}
+
+func TestSystemFieldNames(t *testing.T) {
+ result := schema.SystemFieldNames()
+ expected := 3
+
+ if len(result) != expected {
+ t.Fatalf("Expected %d field names, got %d (%v)", expected, len(result), result)
+ }
+}
+
+func TestAuthFieldNames(t *testing.T) {
+ result := schema.AuthFieldNames()
+ expected := 8
+
+ if len(result) != expected {
+ t.Fatalf("Expected %d auth field names, got %d (%v)", expected, len(result), result)
}
}
func TestFieldTypes(t *testing.T) {
result := schema.FieldTypes()
+ expected := 10
- if len(result) != 11 {
- t.Fatalf("Expected %d types, got %d (%v)", 3, len(result), result)
+ if len(result) != expected {
+ t.Fatalf("Expected %d types, got %d (%v)", expected, len(result), result)
}
}
func TestArraybleFieldTypes(t *testing.T) {
result := schema.ArraybleFieldTypes()
+ expected := 3
- if len(result) != 4 {
- t.Fatalf("Expected %d types, got %d (%v)", 3, len(result), result)
+ if len(result) != expected {
+ t.Fatalf("Expected %d arrayble types, got %d (%v)", expected, len(result), result)
}
}
@@ -50,7 +71,7 @@ func TestSchemaFieldColDefinition(t *testing.T) {
},
{
schema.SchemaField{Type: schema.FieldTypeBool, Name: "test"},
- "Boolean DEFAULT FALSE",
+ "BOOLEAN DEFAULT FALSE",
},
{
schema.SchemaField{Type: schema.FieldTypeEmail, Name: "test"},
@@ -80,10 +101,6 @@ func TestSchemaFieldColDefinition(t *testing.T) {
schema.SchemaField{Type: schema.FieldTypeRelation, Name: "test"},
"TEXT DEFAULT ''",
},
- {
- schema.SchemaField{Type: schema.FieldTypeUser, Name: "test"},
- "TEXT DEFAULT ''",
- },
}
for i, s := range scenarios {
@@ -297,7 +314,7 @@ func TestSchemaFieldValidate(t *testing.T) {
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
- Name: schema.ReservedFieldNameId,
+ Name: schema.FieldNameId,
},
[]string{"name"},
},
@@ -306,7 +323,7 @@ func TestSchemaFieldValidate(t *testing.T) {
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
- Name: schema.ReservedFieldNameCreated,
+ Name: schema.FieldNameCreated,
},
[]string{"name"},
},
@@ -315,7 +332,34 @@ func TestSchemaFieldValidate(t *testing.T) {
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
- Name: schema.ReservedFieldNameUpdated,
+ Name: schema.FieldNameUpdated,
+ },
+ []string{"name"},
+ },
+ {
+ "reserved name (collectionId)",
+ schema.SchemaField{
+ Type: schema.FieldTypeText,
+ Id: "1234567890",
+ Name: schema.FieldNameCollectionId,
+ },
+ []string{"name"},
+ },
+ {
+ "reserved name (collectionName)",
+ schema.SchemaField{
+ Type: schema.FieldTypeText,
+ Id: "1234567890",
+ Name: schema.FieldNameCollectionName,
+ },
+ []string{"name"},
+ },
+ {
+ "reserved name (expand)",
+ schema.SchemaField{
+ Type: schema.FieldTypeText,
+ Id: "1234567890",
+ Name: schema.FieldNameExpand,
},
[]string{"name"},
},
@@ -456,7 +500,7 @@ func TestSchemaFieldInitOptions(t *testing.T) {
{
schema.SchemaField{Type: schema.FieldTypeRelation},
false,
- `{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"maxSelect":0,"collectionId":"","cascadeDelete":false}}`,
+ `{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"maxSelect":null,"collectionId":"","cascadeDelete":false}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeUser},
@@ -548,8 +592,9 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeDate}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "test", `""`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, 1641024040, `"2022-01-01 08:00:40.000"`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123", `"2022-01-01 11:27:10.123"`},
+ {schema.SchemaField{Type: schema.FieldTypeDate}, 1641024040, `"2022-01-01 08:00:40.000Z"`},
+ {schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123", `"2022-01-01 11:27:10.123Z"`},
+ {schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123Z", `"2022-01-01 11:27:10.123Z"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, types.DateTime{}, `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, time.Time{}, `""`},
@@ -697,13 +742,48 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
},
// relation (single)
- {schema.SchemaField{Type: schema.FieldTypeRelation}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeRelation}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeRelation}, 123, `"123"`},
- {schema.SchemaField{Type: schema.FieldTypeRelation}, "abc", `"abc"`},
- {schema.SchemaField{Type: schema.FieldTypeRelation}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
{
- schema.SchemaField{Type: schema.FieldTypeRelation},
+ schema.SchemaField{
+ Type: schema.FieldTypeRelation,
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
+ },
+ nil,
+ `""`,
+ },
+ {
+ schema.SchemaField{
+ Type: schema.FieldTypeRelation,
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
+ },
+ "",
+ `""`,
+ },
+ {
+ schema.SchemaField{
+ Type: schema.FieldTypeRelation,
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
+ },
+ 123,
+ `"123"`,
+ },
+ {
+ schema.SchemaField{
+ Type: schema.FieldTypeRelation,
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
+ },
+ "abc",
+ `"abc"`,
+ },
+ {
+ schema.SchemaField{
+ Type: schema.FieldTypeRelation,
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
+ },
+ "1ba88b4f-e9da-42f0-9764-9a55c953e724",
+ `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
+ },
+ {
+ schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
`"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
},
@@ -711,7 +791,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: 2},
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
nil,
`[]`,
@@ -719,7 +799,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: 2},
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
"",
`[]`,
@@ -727,7 +807,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: 2},
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
[]string{},
`[]`,
@@ -735,7 +815,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: 2},
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
123,
`["123"]`,
@@ -743,7 +823,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: 2},
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
[]string{"", "abc"},
`["abc"]`,
@@ -752,7 +832,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
// no values validation
schema.SchemaField{
Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: 2},
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
`["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
@@ -761,77 +841,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
// duplicated values
schema.SchemaField{
Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: 2},
- },
- []string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724", "1ba88b4f-e9da-42f0-9764-9a55c953e724"},
- `["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
- },
-
- // user (single)
- {schema.SchemaField{Type: schema.FieldTypeUser}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeUser}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeUser}, 123, `"123"`},
- {schema.SchemaField{Type: schema.FieldTypeUser}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
- {
- schema.SchemaField{Type: schema.FieldTypeUser},
- []string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
- `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
- },
- // user (multiple)
- {
- schema.SchemaField{
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{MaxSelect: 2},
- },
- nil,
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{MaxSelect: 2},
- },
- "",
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{MaxSelect: 2},
- },
- []string{},
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{MaxSelect: 2},
- },
- 123,
- `["123"]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{MaxSelect: 2},
- },
- []string{"", "abc"},
- `["abc"]`,
- },
- {
- // no values validation
- schema.SchemaField{
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{MaxSelect: 2},
- },
- []string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
- `["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
- },
- {
- // duplicated values
- schema.SchemaField{
- Type: schema.FieldTypeUser,
- Options: &schema.UserOptions{MaxSelect: 2},
+ Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724", "1ba88b4f-e9da-42f0-9764-9a55c953e724"},
`["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
@@ -1277,13 +1287,13 @@ func TestRelationOptionsValidate(t *testing.T) {
{
"empty",
schema.RelationOptions{},
- []string{"maxSelect", "collectionId"},
+ []string{"collectionId"},
},
{
"empty CollectionId",
schema.RelationOptions{
CollectionId: "",
- MaxSelect: 1,
+ MaxSelect: types.Pointer(1),
},
[]string{"collectionId"},
},
@@ -1291,7 +1301,7 @@ func TestRelationOptionsValidate(t *testing.T) {
"MaxSelect <= 0",
schema.RelationOptions{
CollectionId: "abc",
- MaxSelect: 0,
+ MaxSelect: types.Pointer(0),
},
[]string{"maxSelect"},
},
@@ -1299,33 +1309,7 @@ func TestRelationOptionsValidate(t *testing.T) {
"MaxSelect > 0 && non-empty CollectionId",
schema.RelationOptions{
CollectionId: "abc",
- MaxSelect: 1,
- },
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestUserOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.UserOptions{},
- []string{"maxSelect"},
- },
- {
- "MaxSelect <= 0",
- schema.UserOptions{
- MaxSelect: 0,
- },
- []string{"maxSelect"},
- },
- {
- "MaxSelect > 0",
- schema.UserOptions{
- MaxSelect: 1,
+ MaxSelect: types.Pointer(1),
},
[]string{},
},
diff --git a/models/user.go b/models/user.go
deleted file mode 100644
index 6982c0e3..00000000
--- a/models/user.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package models
-
-import (
- "encoding/json"
-
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-var _ Model = (*User)(nil)
-
-const (
- // ProfileCollectionName is the name of the system user profiles collection.
- ProfileCollectionName = "profiles"
-
- // ProfileCollectionUserFieldName is the name of the user field from the system user profiles collection.
- ProfileCollectionUserFieldName = "userId"
-)
-
-type User struct {
- BaseAccount
-
- Verified bool `db:"verified" json:"verified"`
- LastVerificationSentAt types.DateTime `db:"lastVerificationSentAt" json:"lastVerificationSentAt"`
-
- // profile rel
- Profile *Record `db:"-" json:"profile"`
-}
-
-func (m *User) TableName() string {
- return "_users"
-}
-
-// AsMap returns the current user data as a plain map
-// (including the profile relation, if loaded).
-func (m *User) AsMap() (map[string]any, error) {
- userBytes, err := json.Marshal(m)
- if err != nil {
- return nil, err
- }
-
- result := map[string]any{}
- if err := json.Unmarshal(userBytes, &result); err != nil {
- return nil, err
- }
-
- return result, nil
-}
diff --git a/models/user_test.go b/models/user_test.go
deleted file mode 100644
index 19550213..00000000
--- a/models/user_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package models_test
-
-import (
- "encoding/json"
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestUserTableName(t *testing.T) {
- m := models.User{}
- if m.TableName() != "_users" {
- t.Fatalf("Unexpected table name, got %q", m.TableName())
- }
-}
-
-func TestUserAsMap(t *testing.T) {
- date, _ := types.ParseDateTime("2022-01-01 01:12:23.456")
-
- m := models.User{}
- m.Id = "210a896c-1e32-4c94-ae06-90c25fcf6791"
- m.Email = "test@example.com"
- m.PasswordHash = "test"
- m.LastResetSentAt = date
- m.Updated = date
- m.RefreshTokenKey()
-
- result, err := m.AsMap()
- if err != nil {
- t.Fatal(err)
- }
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
-
- expected := `{"created":"","email":"test@example.com","id":"210a896c-1e32-4c94-ae06-90c25fcf6791","lastResetSentAt":"2022-01-01 01:12:23.456","lastVerificationSentAt":"","profile":null,"updated":"2022-01-01 01:12:23.456","verified":false}`
- if string(encoded) != expected {
- t.Errorf("Expected %s, got %s", expected, string(encoded))
- }
-}
diff --git a/pocketbase.go b/pocketbase.go
index 67c57b2b..6043ec0f 100644
--- a/pocketbase.go
+++ b/pocketbase.go
@@ -128,6 +128,7 @@ func (pb *PocketBase) Start() error {
// register system commands
pb.RootCmd.AddCommand(cmd.NewServeCommand(pb, !pb.hideStartBanner))
pb.RootCmd.AddCommand(cmd.NewMigrateCommand(pb))
+ pb.RootCmd.AddCommand(cmd.NewTempUpgradeCommand(pb))
return pb.Execute()
}
diff --git a/resolvers/record_field_resolver.go b/resolvers/record_field_resolver.go
index 09bbb9a7..10dd5b36 100644
--- a/resolvers/record_field_resolver.go
+++ b/resolvers/record_field_resolver.go
@@ -3,6 +3,7 @@ package resolvers
import (
"encoding/json"
"fmt"
+ "strconv"
"strings"
"github.com/pocketbase/dbx"
@@ -19,6 +20,20 @@ import (
// ensure that `search.FieldResolver` interface is implemented
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
+// list of auth filter fields that don't require join with the auth
+// collection or any other extra checks to be resolved
+var plainRequestAuthFields = []string{
+ "@request.auth." + schema.FieldNameId,
+ "@request.auth." + schema.FieldNameCollectionId,
+ "@request.auth." + schema.FieldNameCollectionName,
+ "@request.auth." + schema.FieldNameUsername,
+ "@request.auth." + schema.FieldNameEmail,
+ "@request.auth." + schema.FieldNameEmailVisibility,
+ "@request.auth." + schema.FieldNameVerified,
+ "@request.auth." + schema.FieldNameCreated,
+ "@request.auth." + schema.FieldNameUpdated,
+}
+
type join struct {
id string
table string
@@ -35,28 +50,37 @@ type join struct {
type RecordFieldResolver struct {
dao *daos.Dao
baseCollection *models.Collection
+ allowHiddenFields bool
allowedFields []string
requestData map[string]any
- joins []join // we cannot use a map because the insertion order is not preserved
loadedCollections []*models.Collection
+ joins []join // we cannot use a map because the insertion order is not preserved
+ exprs []dbx.Expression
}
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
+//
+// @todo consider changing in v0.8+:
+// - requestData to a typed struct when introducing the "IN" operator
+// - allowHiddenFields -> isSystemAdmin
func NewRecordFieldResolver(
dao *daos.Dao,
baseCollection *models.Collection,
requestData map[string]any,
+ allowHiddenFields bool,
) *RecordFieldResolver {
return &RecordFieldResolver{
dao: dao,
baseCollection: baseCollection,
requestData: requestData,
+ allowHiddenFields: allowHiddenFields,
joins: []join{},
+ exprs: []dbx.Expression{},
loadedCollections: []*models.Collection{baseCollection},
allowedFields: []string{
`^\w+[\w\.]*$`,
`^\@request\.method$`,
- `^\@request\.user\.\w+[\w\.]*$`,
+ `^\@request\.auth\.\w+[\w\.]*$`,
`^\@request\.data\.\w+[\w\.]*$`,
`^\@request\.query\.\w+[\w\.]*$`,
`^\@collection\.\w+\.\w+[\w\.]*$`,
@@ -77,6 +101,12 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
}
}
+ for _, expr := range r.exprs {
+ if expr != nil {
+ query.AndWhere(expr)
+ }
+ }
+
return nil
}
@@ -86,7 +116,7 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
// id
// project.screen.status
// @request.status
-// @request.user.profile.someRelation.name
+// @request.auth.someRelation.name
// @collection.product.name
func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, placeholderParams dbx.Params, err error) {
if len(r.allowedFields) > 0 && !list.ExistInSliceWithRegex(fieldName, r.allowedFields) {
@@ -98,6 +128,11 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
currentCollectionName := r.baseCollection.Name
currentTableAlias := inflector.Columnify(currentCollectionName)
+ // flag indicating whether to return null on missing field or return on an error
+ nullifyMisingField := false
+
+ allowHiddenFields := r.allowHiddenFields
+
// check for @collection field (aka. non-relational join)
// must be in the format "@collection.COLLECTION_NAME.FIELD[.FIELD2....]"
if props[0] == "@collection" {
@@ -113,55 +148,70 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
}
- r.addJoin(inflector.Columnify(collection.Name), currentTableAlias, nil)
+ // always allow hidden fields since the @collection.* filter is a system one
+ allowHiddenFields = true
+
+ r.registerJoin(inflector.Columnify(collection.Name), currentTableAlias, nil)
props = props[2:] // leave only the collection fields
} else if props[0] == "@request" {
- // check for @request field
if len(props) == 1 {
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
}
- // not a profile relational field
- if !strings.HasPrefix(fieldName, "@request.user.profile.") {
+ // plain @request.* field
+ if !strings.HasPrefix(fieldName, "@request.auth.") || list.ExistInSlice(fieldName, plainRequestAuthFields) {
return r.resolveStaticRequestField(props[1:]...)
}
- // resolve the profile collection fields
- currentCollectionName = models.ProfileCollectionName
- currentTableAlias = inflector.Columnify("__user_" + currentCollectionName)
+ // always allow hidden fields since the @request.* filter is a system one
+ allowHiddenFields = true
- collection, err := r.loadCollection(currentCollectionName)
+ // enable the ignore flag for missing @request.auth.* fields
+ // for consistency with @request.data.* and @request.query.*
+ nullifyMisingField = true
+
+ // resolve the auth collection fields
+ // ---
+ rawAuthRecordId, _ := extractNestedMapVal(r.requestData, "auth", "id")
+ authRecordId := cast.ToString(rawAuthRecordId)
+ if authRecordId == "" {
+ return "NULL", nil, nil
+ }
+
+ rawAuthCollectionId, _ := extractNestedMapVal(r.requestData, "auth", schema.FieldNameCollectionId)
+ authCollectionId := cast.ToString(rawAuthCollectionId)
+ if authCollectionId == "" {
+ return "NULL", nil, nil
+ }
+
+ collection, err := r.loadCollection(authCollectionId)
if err != nil {
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
}
- profileIdPlaceholder, profileIdPlaceholderParam, err := r.resolveStaticRequestField("user", "profile", "id")
- if err != nil {
- return "", nil, fmt.Errorf("Failed to resolve @request.user.profile.id path in %q.", fieldName)
- }
- if strings.ToLower(profileIdPlaceholder) == "null" {
- // the user doesn't have an associated profile
- return "NULL", nil, nil
- }
+ currentCollectionName = collection.Name
+ currentTableAlias = "__auth_" + inflector.Columnify(currentCollectionName)
- // join the profile collection
- r.addJoin(
+ authIdParamKey := "auth" + security.RandomString(5)
+ authIdParams := dbx.Params{authIdParamKey: authRecordId}
+ // ---
+
+ // join the auth collection
+ r.registerJoin(
inflector.Columnify(collection.Name),
currentTableAlias,
dbx.NewExp(fmt.Sprintf(
- // aka. profiles.id = profileId
- "[[%s.id]] = %s",
- currentTableAlias,
- profileIdPlaceholder,
- ), profileIdPlaceholderParam),
+ // aka. __auth_users.id = :userId
+ "[[%s.id]] = {:%s}",
+ inflector.Columnify(currentTableAlias),
+ authIdParamKey,
+ ), authIdParams),
)
- props = props[3:] // leave only the profile fields
+ props = props[2:] // leave only the auth relation fields
}
- baseModelFields := schema.ReservedFieldNames()
-
totalProps := len(props)
for i, prop := range props {
@@ -170,13 +220,37 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
return "", nil, fmt.Errorf("Failed to resolve field %q.", prop)
}
- // base model prop (always available but not part of the collection schema)
- if list.ExistInSlice(prop, baseModelFields) {
+ systemFieldNames := schema.BaseModelFieldNames()
+ if collection.IsAuth() {
+ systemFieldNames = append(
+ systemFieldNames,
+ schema.FieldNameUsername,
+ schema.FieldNameVerified,
+ schema.FieldNameEmailVisibility,
+ schema.FieldNameEmail,
+ )
+ }
+
+ // internal model prop (always available but not part of the collection schema)
+ if list.ExistInSlice(prop, systemFieldNames) {
+ // allow querying only auth records with emails marked as public
+ if prop == schema.FieldNameEmail && !allowHiddenFields {
+ r.registerExpr(dbx.NewExp(fmt.Sprintf(
+ "[[%s.%s]] = TRUE",
+ currentTableAlias,
+ inflector.Columnify(schema.FieldNameEmailVisibility),
+ )))
+ }
+
return fmt.Sprintf("[[%s.%s]]", currentTableAlias, inflector.Columnify(prop)), nil, nil
}
field := collection.Schema.GetFieldByName(prop)
if field == nil {
+ if nullifyMisingField {
+ return "NULL", nil, nil
+ }
+
return "", nil, fmt.Errorf("Unrecognized field %q.", prop)
}
@@ -185,6 +259,28 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
return fmt.Sprintf("[[%s.%s]]", currentTableAlias, inflector.Columnify(prop)), nil, nil
}
+ // check if it is a json field
+ if field.Type == schema.FieldTypeJson {
+ var jsonPath strings.Builder
+ jsonPath.WriteString("$")
+ for _, p := range props[i+1:] {
+ if _, err := strconv.Atoi(p); err == nil {
+ jsonPath.WriteString("[")
+ jsonPath.WriteString(inflector.Columnify(p))
+ jsonPath.WriteString("]")
+ } else {
+ jsonPath.WriteString(".")
+ jsonPath.WriteString(inflector.Columnify(p))
+ }
+ }
+ return fmt.Sprintf(
+ "JSON_EXTRACT([[%s.%s]], '%s')",
+ currentTableAlias,
+ inflector.Columnify(prop),
+ jsonPath.String(),
+ ), nil, nil
+ }
+
// check if it is a relation field
if field.Type != schema.FieldTypeRelation {
return "", nil, fmt.Errorf("Field %q is not a valid relation.", prop)
@@ -210,7 +306,7 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
jeTable := currentTableAlias + "_" + cleanFieldName + "_je"
jePair := currentTableAlias + "." + cleanFieldName
- r.addJoin(
+ r.registerJoin(
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)`,
@@ -219,7 +315,7 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
jeTable,
nil,
)
- r.addJoin(
+ r.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeTable)),
@@ -306,7 +402,7 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models
return collection, nil
}
-func (r *RecordFieldResolver) addJoin(tableName string, tableAlias string, on dbx.Expression) {
+func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) {
tableExpr := fmt.Sprintf("%s %s", tableName, tableAlias)
join := join{
@@ -326,3 +422,7 @@ func (r *RecordFieldResolver) addJoin(tableName string, tableAlias string, on db
// register new join
r.joins = append(r.joins, join)
}
+
+func (r *RecordFieldResolver) registerExpr(expr dbx.Expression) {
+ r.exprs = append(r.exprs, expr)
+}
diff --git a/resolvers/record_field_resolver_test.go b/resolvers/record_field_resolver_test.go
index 6393ef92..30467cde 100644
--- a/resolvers/record_field_resolver_test.go
+++ b/resolvers/record_field_resolver_test.go
@@ -14,111 +14,155 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, err := app.Dao().FindCollectionByNameOrId("demo4")
+ authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
}
requestData := map[string]any{
- "user": map[string]any{
- "id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- "profile": map[string]any{
- "id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
- "name": "test",
- },
- },
+ "auth": authRecord.PublicExport(),
}
scenarios := []struct {
- name string
- fields []string
- expectQuery string
+ name string
+ collectionIdOrName string
+ fields []string
+ allowHiddenFields bool
+ expectQuery string
}{
{
"missing field",
+ "demo4",
[]string{""},
+ false,
"SELECT `demo4`.* FROM `demo4`",
},
{
"non relation field",
+ "demo4",
[]string{"title"},
+ false,
"SELECT `demo4`.* FROM `demo4`",
},
{
"incomplete rel",
- []string{"onerel"},
+ "demo4",
+ []string{"self_rel_one"},
+ false,
"SELECT `demo4`.* FROM `demo4`",
},
{
- "single rel",
- []string{"onerel.title"},
- "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.onerel]]) THEN [[demo4.onerel]] ELSE json_array([[demo4.onerel]]) END) `demo4_onerel_je` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4_onerel.id]] = [[demo4_onerel_je.value]]",
+ "single rel (self rel)",
+ "demo4",
+ []string{"self_rel_one.title"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_one]]) THEN [[demo4.self_rel_one]] ELSE json_array([[demo4.self_rel_one]]) END) `demo4_self_rel_one_je` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4_self_rel_one_je.value]]",
+ },
+ {
+ "single rel (other collection)",
+ "demo4",
+ []string{"rel_one_cascade.title"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.rel_one_cascade]]) THEN [[demo4.rel_one_cascade]] ELSE json_array([[demo4.rel_one_cascade]]) END) `demo4_rel_one_cascade_je` LEFT JOIN `demo3` `demo4_rel_one_cascade` ON [[demo4_rel_one_cascade.id]] = [[demo4_rel_one_cascade_je.value]]",
},
{
"non-relation field + single rel",
- []string{"title", "onerel.title"},
- "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.onerel]]) THEN [[demo4.onerel]] ELSE json_array([[demo4.onerel]]) END) `demo4_onerel_je` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4_onerel.id]] = [[demo4_onerel_je.value]]",
+ "demo4",
+ []string{"title", "self_rel_one.title"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_one]]) THEN [[demo4.self_rel_one]] ELSE json_array([[demo4.self_rel_one]]) END) `demo4_self_rel_one_je` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4_self_rel_one_je.value]]",
},
{
"nested incomplete rels",
- []string{"manyrels.onerel"},
- "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.manyrels]]) THEN [[demo4.manyrels]] ELSE json_array([[demo4.manyrels]]) END) `demo4_manyrels_je` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4_manyrels.id]] = [[demo4_manyrels_je.value]]",
+ "demo4",
+ []string{"self_rel_many.self_rel_one"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]]",
},
{
"nested complete rels",
- []string{"manyrels.onerel.title"},
- "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.manyrels]]) THEN [[demo4.manyrels]] ELSE json_array([[demo4.manyrels]]) END) `demo4_manyrels_je` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4_manyrels.id]] = [[demo4_manyrels_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_manyrels.onerel]]) THEN [[demo4_manyrels.onerel]] ELSE json_array([[demo4_manyrels.onerel]]) END) `demo4_manyrels_onerel_je` LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels_onerel.id]] = [[demo4_manyrels_onerel_je.value]]",
+ "demo4",
+ []string{"self_rel_many.self_rel_one.title"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_many.self_rel_one]]) THEN [[demo4_self_rel_many.self_rel_one]] ELSE json_array([[demo4_self_rel_many.self_rel_one]]) END) `demo4_self_rel_many_self_rel_one_je` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_je.value]]",
},
{
"repeated nested rels",
- []string{"manyrels.onerel.manyrels.onerel.title"},
- "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.manyrels]]) THEN [[demo4.manyrels]] ELSE json_array([[demo4.manyrels]]) END) `demo4_manyrels_je` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4_manyrels.id]] = [[demo4_manyrels_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_manyrels.onerel]]) THEN [[demo4_manyrels.onerel]] ELSE json_array([[demo4_manyrels.onerel]]) END) `demo4_manyrels_onerel_je` LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels_onerel.id]] = [[demo4_manyrels_onerel_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_manyrels_onerel.manyrels]]) THEN [[demo4_manyrels_onerel.manyrels]] ELSE json_array([[demo4_manyrels_onerel.manyrels]]) END) `demo4_manyrels_onerel_manyrels_je` LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels` ON [[demo4_manyrels_onerel_manyrels.id]] = [[demo4_manyrels_onerel_manyrels_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_manyrels_onerel_manyrels.onerel]]) THEN [[demo4_manyrels_onerel_manyrels.onerel]] ELSE json_array([[demo4_manyrels_onerel_manyrels.onerel]]) END) `demo4_manyrels_onerel_manyrels_onerel_je` LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels_onerel` ON [[demo4_manyrels_onerel_manyrels_onerel.id]] = [[demo4_manyrels_onerel_manyrels_onerel_je.value]]",
+ "demo4",
+ []string{"self_rel_many.self_rel_one.self_rel_many.self_rel_one.title"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_many.self_rel_one]]) THEN [[demo4_self_rel_many.self_rel_one]] ELSE json_array([[demo4_self_rel_many.self_rel_one]]) END) `demo4_self_rel_many_self_rel_one_je` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many` ON [[demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]]) THEN [[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] ELSE json_array([[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]]) END) `demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one_je` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one_je.value]]",
},
{
"multiple rels",
- []string{"manyrels.title", "onerel.onefile"},
- "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.manyrels]]) THEN [[demo4.manyrels]] ELSE json_array([[demo4.manyrels]]) END) `demo4_manyrels_je` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4_manyrels.id]] = [[demo4_manyrels_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4.onerel]]) THEN [[demo4.onerel]] ELSE json_array([[demo4.onerel]]) END) `demo4_onerel_je` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4_onerel.id]] = [[demo4_onerel_je.value]]",
+ "demo4",
+ []string{"self_rel_many.title", "self_rel_one.onefile"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_one]]) THEN [[demo4.self_rel_one]] ELSE json_array([[demo4.self_rel_one]]) END) `demo4_self_rel_one_je` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4_self_rel_one_je.value]]",
},
{
"@collection join",
- []string{"@collection.demo.title", "@collection.demo2.text", "@collection.demo.file"},
- "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo` `__collection_demo` LEFT JOIN `demo2` `__collection_demo2`",
+ "demo4",
+ []string{"@collection.demo1.text", "@collection.demo2.active", "@collection.demo1.file_one"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo1` `__collection_demo1` LEFT JOIN `demo2` `__collection_demo2`",
},
{
- "static @request.user.profile fields",
- []string{"@request.user.id", "@request.user.profile.id", "@request.data.demo"},
+ "@request.auth fields",
+ "demo4",
+ []string{"@request.auth.id", "@request.auth.username", "@request.auth.rel.title", "@request.data.demo"},
+ false,
"^" +
- regexp.QuoteMeta("SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `profiles` `__user_profiles` ON [[__user_profiles.id]] =") +
- " {:.*}$",
- },
- {
- "relational @request.user.profile fields",
- []string{"@request.user.profile.rel.id", "@request.user.profile.rel.name"},
- "^" +
- regexp.QuoteMeta("SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `profiles` `__user_profiles` ON [[__user_profiles.id]] =") +
+ regexp.QuoteMeta("SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__auth_users` ON [[__auth_users.id]] =") +
" {:.*} " +
- regexp.QuoteMeta("LEFT JOIN json_each(CASE WHEN json_valid([[__user_profiles.rel]]) THEN [[__user_profiles.rel]] ELSE json_array([[__user_profiles.rel]]) END) `__user_profiles_rel_je` LEFT JOIN `profiles` `__user_profiles_rel` ON [[__user_profiles_rel.id]] = [[__user_profiles_rel_je.value]]") +
+ regexp.QuoteMeta("LEFT JOIN json_each(CASE WHEN json_valid([[__auth_users.rel]]) THEN [[__auth_users.rel]] ELSE json_array([[__auth_users.rel]]) END) `__auth_users_rel_je` LEFT JOIN `demo2` `__auth_users_rel` ON [[__auth_users_rel.id]] = [[__auth_users_rel_je.value]]") +
"$",
},
+ {
+ "hidden field with system filters (ignore emailVisibility)",
+ "demo4",
+ []string{"@collection.users.email", "@request.auth.email"},
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__collection_users`",
+ },
+ {
+ "hidden field (add emailVisibility)",
+ "users",
+ []string{"email"},
+ false,
+ "SELECT `users`.* FROM `users` WHERE [[users.emailVisibility]] = TRUE",
+ },
+ {
+ "hidden field (force ignore emailVisibility)",
+ "users",
+ []string{"email"},
+ true,
+ "SELECT `users`.* FROM `users`",
+ },
}
for _, s := range scenarios {
+ collection, err := app.Dao().FindCollectionByNameOrId(s.collectionIdOrName)
+ if err != nil {
+ t.Errorf("[%s] Failed to load collection %s: %v", s.name, s.collectionIdOrName, err)
+ }
+
query := app.Dao().RecordQuery(collection)
- r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
+ r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, s.allowHiddenFields)
for _, field := range s.fields {
r.Resolve(field)
}
if err := r.UpdateQuery(query); err != nil {
- t.Errorf("(%s) UpdateQuery failed with error %v", s.name, err)
+ t.Errorf("[%s] UpdateQuery failed with error %v", s.name, err)
continue
}
rawQuery := query.Build().SQL()
if !list.ExistInSliceWithRegex(rawQuery, []string{s.expectQuery}) {
- t.Errorf("(%s) Expected query\n %v \ngot:\n %v", s.name, s.expectQuery, rawQuery)
+ t.Errorf("[%s] Expected query\n %v \ngot:\n %v", s.name, s.expectQuery, rawQuery)
}
}
}
@@ -132,16 +176,16 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
t.Fatal(err)
}
- requestData := map[string]any{
- "user": map[string]any{
- "id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- "profile": map[string]any{
- "id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
- },
- },
+ authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
+ if err != nil {
+ t.Fatal(err)
}
- r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
+ requestData := map[string]any{
+ "auth": authRecord.PublicExport(),
+ }
+
+ r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true)
scenarios := []struct {
fieldName string
@@ -157,28 +201,31 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
{"updated", false, "[[demo4.updated]]"},
{"title", false, "[[demo4.title]]"},
{"title.test", true, ""},
- {"manyrels", false, "[[demo4.manyrels]]"},
- {"manyrels.", true, ""},
- {"manyrels.unknown", true, ""},
- {"manyrels.title", false, "[[demo4_manyrels.title]]"},
- {"manyrels.onerel.manyrels.onefile", false, "[[demo4_manyrels_onerel_manyrels.onefile]]"},
- // @request.user.profile relation join:
- {"@request.user.profile.rel", false, "[[__user_profiles.rel]]"},
- {"@request.user.profile.rel.name", false, "[[__user_profiles_rel.name]]"},
+ {"self_rel_many", false, "[[demo4.self_rel_many]]"},
+ {"self_rel_many.", true, ""},
+ {"self_rel_many.unknown", true, ""},
+ {"self_rel_many.title", false, "[[demo4_self_rel_many.title]]"},
+ {"self_rel_many.self_rel_one.self_rel_many.title", false, "[[demo4_self_rel_many_self_rel_one_self_rel_many.title]]"},
+ // json_extract
+ {"json_array.0", false, "JSON_EXTRACT([[demo4.json_array]], '$[0]')"},
+ {"json_object.a.b.c", false, "JSON_EXTRACT([[demo4.json_object]], '$.a.b.c')"},
+ // @request.auth relation join:
+ {"@request.auth.rel", false, "[[__auth_users.rel]]"},
+ {"@request.auth.rel.title", false, "[[__auth_users_rel.title]]"},
// @collection fieds:
{"@collect", true, ""},
{"collection.demo4.title", true, ""},
{"@collection", true, ""},
{"@collection.unknown", true, ""},
- {"@collection.demo", true, ""},
- {"@collection.demo.", true, ""},
- {"@collection.demo.title", false, "[[__collection_demo.title]]"},
+ {"@collection.demo2", true, ""},
+ {"@collection.demo2.", true, ""},
+ {"@collection.demo2.title", false, "[[__collection_demo2.title]]"},
{"@collection.demo4.title", false, "[[__collection_demo4.title]]"},
{"@collection.demo4.id", false, "[[__collection_demo4.id]]"},
{"@collection.demo4.created", false, "[[__collection_demo4.created]]"},
{"@collection.demo4.updated", false, "[[__collection_demo4.updated]]"},
- {"@collection.demo4.manyrels.missing", true, ""},
- {"@collection.demo4.manyrels.onerel.manyrels.onerel.onefile", false, "[[__collection_demo4_manyrels_onerel_manyrels_onerel.onefile]]"},
+ {"@collection.demo4.self_rel_many.missing", true, ""},
+ {"@collection.demo4.self_rel_many.self_rel_one.self_rel_many.self_rel_one.title", false, "[[__collection_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]]"},
}
for _, s := range scenarios {
@@ -210,6 +257,11 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
t.Fatal(err)
}
+ authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
+ if err != nil {
+ t.Fatal(err)
+ }
+
requestData := map[string]any{
"method": "get",
"query": map[string]any{
@@ -219,16 +271,10 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
"b": 456,
"c": map[string]int{"sub": 1},
},
- "user": map[string]any{
- "id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- "profile": map[string]any{
- "id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
- "name": "test",
- },
- },
+ "user": authRecord.PublicExport(),
}
- r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
+ r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true)
scenarios := []struct {
fieldName string
@@ -247,9 +293,9 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
{"@request.data.b", false, `456`},
{"@request.data.b.missing", false, ``},
{"@request.data.c", false, `"{\"sub\":1}"`},
- {"@request.user", true, ""},
- {"@request.user.id", false, `"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`},
- {"@request.user.profile", false, `"{\"id\":\"d13f60a4-5765-48c7-9e1d-3e782340f833\",\"name\":\"test\"}"`},
+ {"@request.auth", true, ""},
+ {"@request.auth.id", false, `"4q1xlclmfloku33"`},
+ {"@request.auth.file", false, `"[]"`},
}
for i, s := range scenarios {
diff --git a/tests/app.go b/tests/app.go
index 60410e2c..4a93d0ea 100644
--- a/tests/app.go
+++ b/tests/app.go
@@ -157,63 +157,23 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return nil
})
- t.OnUsersListRequest().Add(func(e *core.UsersListEvent) error {
- t.EventCalls["OnUsersListRequest"]++
+ t.OnRecordAuthRequest().Add(func(e *core.RecordAuthEvent) error {
+ t.EventCalls["OnRecordAuthRequest"]++
return nil
})
- t.OnUserViewRequest().Add(func(e *core.UserViewEvent) error {
- t.EventCalls["OnUserViewRequest"]++
+ t.OnRecordListExternalAuths().Add(func(e *core.RecordListExternalAuthsEvent) error {
+ t.EventCalls["OnRecordListExternalAuths"]++
return nil
})
- t.OnUserBeforeCreateRequest().Add(func(e *core.UserCreateEvent) error {
- t.EventCalls["OnUserBeforeCreateRequest"]++
+ t.OnRecordBeforeUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error {
+ t.EventCalls["OnRecordBeforeUnlinkExternalAuthRequest"]++
return nil
})
- t.OnUserAfterCreateRequest().Add(func(e *core.UserCreateEvent) error {
- t.EventCalls["OnUserAfterCreateRequest"]++
- return nil
- })
-
- t.OnUserBeforeUpdateRequest().Add(func(e *core.UserUpdateEvent) error {
- t.EventCalls["OnUserBeforeUpdateRequest"]++
- return nil
- })
-
- t.OnUserAfterUpdateRequest().Add(func(e *core.UserUpdateEvent) error {
- t.EventCalls["OnUserAfterUpdateRequest"]++
- return nil
- })
-
- t.OnUserBeforeDeleteRequest().Add(func(e *core.UserDeleteEvent) error {
- t.EventCalls["OnUserBeforeDeleteRequest"]++
- return nil
- })
-
- t.OnUserAfterDeleteRequest().Add(func(e *core.UserDeleteEvent) error {
- t.EventCalls["OnUserAfterDeleteRequest"]++
- return nil
- })
-
- t.OnUserAuthRequest().Add(func(e *core.UserAuthEvent) error {
- t.EventCalls["OnUserAuthRequest"]++
- return nil
- })
-
- t.OnUserListExternalAuths().Add(func(e *core.UserListExternalAuthsEvent) error {
- t.EventCalls["OnUserListExternalAuths"]++
- return nil
- })
-
- t.OnUserBeforeUnlinkExternalAuthRequest().Add(func(e *core.UserUnlinkExternalAuthEvent) error {
- t.EventCalls["OnUserBeforeUnlinkExternalAuthRequest"]++
- return nil
- })
-
- t.OnUserAfterUnlinkExternalAuthRequest().Add(func(e *core.UserUnlinkExternalAuthEvent) error {
- t.EventCalls["OnUserAfterUnlinkExternalAuthRequest"]++
+ t.OnRecordAfterUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error {
+ t.EventCalls["OnRecordAfterUnlinkExternalAuthRequest"]++
return nil
})
@@ -227,33 +187,33 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return nil
})
- t.OnMailerBeforeUserResetPasswordSend().Add(func(e *core.MailerUserEvent) error {
- t.EventCalls["OnMailerBeforeUserResetPasswordSend"]++
+ t.OnMailerBeforeRecordResetPasswordSend().Add(func(e *core.MailerRecordEvent) error {
+ t.EventCalls["OnMailerBeforeRecordResetPasswordSend"]++
return nil
})
- t.OnMailerAfterUserResetPasswordSend().Add(func(e *core.MailerUserEvent) error {
- t.EventCalls["OnMailerAfterUserResetPasswordSend"]++
+ t.OnMailerAfterRecordResetPasswordSend().Add(func(e *core.MailerRecordEvent) error {
+ t.EventCalls["OnMailerAfterRecordResetPasswordSend"]++
return nil
})
- t.OnMailerBeforeUserVerificationSend().Add(func(e *core.MailerUserEvent) error {
- t.EventCalls["OnMailerBeforeUserVerificationSend"]++
+ t.OnMailerBeforeRecordVerificationSend().Add(func(e *core.MailerRecordEvent) error {
+ t.EventCalls["OnMailerBeforeRecordVerificationSend"]++
return nil
})
- t.OnMailerAfterUserVerificationSend().Add(func(e *core.MailerUserEvent) error {
- t.EventCalls["OnMailerAfterUserVerificationSend"]++
+ t.OnMailerAfterRecordVerificationSend().Add(func(e *core.MailerRecordEvent) error {
+ t.EventCalls["OnMailerAfterRecordVerificationSend"]++
return nil
})
- t.OnMailerBeforeUserChangeEmailSend().Add(func(e *core.MailerUserEvent) error {
- t.EventCalls["OnMailerBeforeUserChangeEmailSend"]++
+ t.OnMailerBeforeRecordChangeEmailSend().Add(func(e *core.MailerRecordEvent) error {
+ t.EventCalls["OnMailerBeforeRecordChangeEmailSend"]++
return nil
})
- t.OnMailerAfterUserChangeEmailSend().Add(func(e *core.MailerUserEvent) error {
- t.EventCalls["OnMailerAfterUserChangeEmailSend"]++
+ t.OnMailerAfterRecordChangeEmailSend().Add(func(e *core.MailerRecordEvent) error {
+ t.EventCalls["OnMailerAfterRecordChangeEmailSend"]++
return nil
})
diff --git a/tests/data/data.db b/tests/data/data.db
index bf707cb4f408a54c05e56cb3cf3e83488552606c..87d713c8285113fe5c3e7cab3eeb07d54d89fc7a 100644
GIT binary patch
literal 237568
zcmeI5X^bP;eb~w7Odm7T&6(XDwN@i(x!hgNwz`{LCoj1pvAfy#-Rz4qV|I}&lGQ9$
zkyXX3*Ylxw6&;8T$Iyp3L97n}0wl5#L_rK80SqfJWCaRrAu%jNFk%~y{K1Bl7`EZa
z_J`!Xs$#KNO>(+-cJ}}HIFZg^e
zm&=R(N6r6(=vP4huc7~~kGply=hy2Bq&|5a1sL%DEe;m+{)6{#$NpsOn>Utj{lTrT
z5C8p*kB|KM;7^BtV=#XGmpzZKmpnHIere#Q`CW{@x8-&I>iE$3__+Jc`x=)P
z`7B?ON&V~N^Skj_BJNAX=GNoB6W#5^_xedj;W>@Zp7;{+ql9lEz8KqIPx$WN_ib(^
ze4G30>r=iHq2;~3yRs46J@&1|kCC$`Xd-_hBJ!w~v;EVYnE3eM`0TjbB}iGmp;knM
zJjGQtSwBzd!lewkUu=AAXnbkheg2Z+mnx@IRh3s%9KMiBX>yU5*7#;h$ToEMaZ}O#
z&6S<~xNl{1A%5gb^^4})+C(zrG%=7?f+rK_p1VWiF_iV^x@XOoI6<`X{n(}Qwc>PU
zsym-d_zsujyQp-GQ{T(8s300I^wl5BEpa8@lH=~B@ueoOn+OwwoKiCWb=7q4aT+Hw7iSy
z)^y=6a${P|~Z4~-vBxX)9DHS!G&8@?Drt$>;;D{@W9@``n^
zlErqJ>IikGYt85gV#T)(jD&o$$c!wCd~
z?DRxfZj7Lh-f8%(k6y>@-G>pVLpFyM8X+uc^`!J6V7oTC~$>
zb%lO^yKWj8T9xAxfv2aZ^+=0byYZE!P26+hPS;)}6TaQ}B5L+G=i__G$l88-cO)Ql
z!mZ{0_5$v{=VN>Gv4uExS%|O4+b%eliO-DQ*}oDn)jld(XuiL*w`Ex!;;G
zETGIOTnSYz{pY5m%njFzh25i^i#%#O@kjSJyJkl@);O_>8Zn=5bx+sM7>VdZ5QDo_
zNO~fdD4$;8bXL>12FGLfda^RLr%e4vr*B>+IB5pI^~J={(D-YwxxZ30VA@L6Uc$o;
zthQhm*|zXqtVU{6)%X%-=X*kvJ7u8Mnq?g|j?hUd&8hqai#NI#9Jp@@s+j`nFi-Sg
z9fjzB@aRICqJVnDDjq;OM6i{3P2lTK3S(G>5voHKTRKLK?H?w~i@e774T1Afdsx{&US?zJ_a8%f-kHQ@d!stx=pF27j$=CgMx+U2Bv@IGbQ|tP)YRsXS^t;o#EW;X;~gkD8#hVSfOT0(Sb?~HbAuy4a;
zmKSZ7*wFO#%LJ#);DgiGE)$$IgI_;)xrfGIdBuIcWLQf{$Se97R!y}J2OL(@_Cc1D
z6GYy35Zj$!j_tlqvFwD;W-o0s@Nwm`DDYX!R9a;;!43?Lue{Q;inKFB9iVlBE}uXB
z3i`TtXB2(Ay?^FH|KI}zfB+Bx0zd!=00AHX1b_e#00KY&2z-zUyy*6j*T^74M8aW$
zAkZTvK?~&N%=};c=Nm{bxA!kx=pTH501yBIKmZ5;0U!VbfB+Bx0zd!=0D%uYfx8}Z
zpi!R?e@F
zj*hD7Qd-TX8RE3r$c4%puWAhQ1-`+R$|4`g$fYetsU1cZ_x78K7~R;f?v##p>H6{_
zl}$&|%PJX>=9c-r{6V6@D_d$JCQ`3b%~whK)!pin7TFhxUFK+EH9(!_)&gRMKNaOB
zFH~x$?D1kgyC17k;?7(?A}KZXY@4Z^uXt`ux@WL#v$K&3r74wasU|iRI-MQD!qIlw
z__Os|yc$~AUDWo=k-3$i)Xdc|bH~+nVl6@NIX1JgphXr>OB6v-(7l`UYGf~XP)21%uK22$w7^sm4+D>t0Ilu+ZsKCL2vQMon
z){BP>nUIdDt$cGMa~fNy(M(g?Si2xI6$(Vc)J0urGC)Lv$@3-mjmhC@qg>QxJzd~4
zQh^C7g%W{Pv1@W=i(yPng4^3t2@i(WL&H>fw|2GE}i#Ev;_M
z%p{n~0kgZbpHBw`nf
z00e*l5C8%|00;m9AOHk_01$Zh2)xjF_oRI7dC#QVad*gr-8#b+2A=awjyUdC7}x*b
zc6oo`{kHeV-XFhvT0l-f00;m9AOHk_01yBIKmZ5;0U!VbfIv3_;}@r+T)XR;abK7S
z;%T{dOsN=n-ZMSooSk4?|37qjzvum$_o4S;w|v132mk>f00e*l5C8%|00;m9AOHk_
zz(<8Z=*rXhscS(`#eMZP86F(%>dPMnSWji-%0e=DZ$7v8A8;{Fd;eiF=>U%l@NjfO&RHfLuYyUO7m>Ga*I9=zcA#KWU7;H`?-8=cYt>KZYwx#5MNNUMd;QA0>66_JlJeI!iGW
zdfOs$5lValsjK><5Bx%j%kwj*q^hCD96)7jphbh+LtHmMG!0
zLPUclkhlJeG3p2D)WoG8lmINq!fZAJI}52QXXd-p7P6**z6YHw07OgH1Wrs%XXMZlnDk%
zhHWp~A$?>xN{8_xt5BFpzV(SgTsiN(d0(%rZ>qDiwJ=Lo7$p=TltNJwIizU@Z5bkI
zYLDtEa%f++IvHsfyIEDAW#xQy>GCn_zFuyf1!VekE1?I-no8W;Egu}avvjcSv!Nlk7l!6EwVJj<95hw9MZCk&rDg&v&%Q(J)0r4b(qRZA>4qOIaL)~
zM#jnI^YI(@VO3sSxVTE%9D0h9%`ja0j1k(RsCpBhk|o?bn5r9Y===&op-#)zTj+bh
z>5aM3#l=u!#?hR0G~cJ?mg|~W37y#r$CMM*f#y3VC_UwWT0xnrSj_S@+$Y((m`$akHG15RYKxtEiloTFGC}2wZISTS%o-YkwWY!?
z=4Q!`ilYxX`;0Mpfy(8X8rjbNRF$Q6*5=vev+k+r8OLW8K`Xa&Qh2emJ|PEv
zFESM5@)=?kZ4Fd=hrJa%qIdLTDCG>NX1FZBpzkKw8qM??f@_g#3zSOvWL&)nYcTJlbY11uFzp69RYz5SaL
zt=^u=l4((9Gu37$$7Q%yZ_o7T?RllxkhpZcO-0{n9GyPP?!KF*Oehcv5q3=t0;cf00e*l5C8%|00;m9AOHk_01)Ux;DsF7l=)&tt)DSx
zwbRB33iATdEO3mL<-?71SS6ancx`^MTniBqwMdDTbfp*`!fuX7c!Su@aWm;Uc5~cz
z(bxYwZcym!{~fpM^!5LaJ7LE4|F>M;-$K{_{|jCFf8YBp@Bc-Azt}}NZ~_8A00;m9
zAOHk_01yBIKmZ5;0U!Vb-XQ`nIIiyXI|z=u0K+#u_uP^!%6UN=!pG)J-@y^jxEs$b
zWv;i5=xePb%F{X`2ZlZ4Bl`4wy#JruQ*@z!@Bsoq;Mo!Q=g+x^CO`GfyR)-RJ|u(+
zN-ij#mQ>}Qx}oLKAH?2^%pd)9G#gFYu>r%ycfafXKd(|joTd`706FC(H=9J3GoX-#_Q_Z1XAhfdyz%_mm2HdFh%
z>yMGE#wmGTL++`xh%`bjXvQKsm1MchOT6;XAj2PEd`(jwPXFawNO~Z`qUe}Z6J=VN
zX+`weAo(<&D>>7`g$mFM&<^KHe3S>3dm(NL6!`I(!H^6GpU(ZX7H+!nT#yu
z1f?`>w}6>95?gEW&By-7cs41r*w%uVcfd6I7OS3_p-I_XWAok==q}dU*OK9C1*_K;
z!6?g>oL&pik|ycC=Ht{NHr_5;PU*oioa7VolC1E^=mpg%!WIBCMN^jU_3oWBDg%IC
zzic+u0&;n?YQ>n=m9`s}X?Xg|sCm?0PpC!&aY-<^v
ztf6v8L;1e5xE2Yg%~@nxP5kAJPgi)_K+rZl)Tp7cH0ldc7tA#lxF$Mek)v8dGwhKg
zkLG8Kw)LsF=fxc-@($QGheKlMU2K#Mho|r)uC>lD`tX>-DLU+p}UCk7E
zoC^xB@Oji=8^@?mFQeYm^x-lY)QPY0xJyO>RX(G%BB6dZ!*6q1VNYw~wTk+n0Av2Q$r;Y-LjkZK=d|DKBgvENnDN
z>BWS+SDvfwp**uy#aL$$B`LHy8bP%3*fv$x8;*;a<+k_Ai=~LtI6Y-bS^n@i*W6*Z
zk~_@)VY!JGTqd@NY(-h+>GkFKaeN_L#q-8(nT7S76dtW&%yw62`}AO+DZ~y!^U0ON
z^!!|Nb-B2Z+1MBN;)nC=E5QwxJKRfdmE#qHqm|>vTB0)tOC2MVC+IUbA3QzY=9$%_
z^3KxI=27o()m(z<8&uYG4W#}97}BX&PX3Av_^Rsa?0kH
zmX^h{v+VpTHP3FG9xchTloiGL-cfONxw*~~i`CkC?PzIvvou$zEv;>J;K{|CH3MDzdA7kq#K5C8%|00;m9AOHk_01yBIKmZ5;
zfe#h|yib7R)`kB5f5)u_=YfB+Bx0zd!=00AHX1b_e#00KbZ
zLrLJX*U^Lo_Y>v<=<^Q-CUDS^Cj`}}4H(z|f9Uf5q4$4(D5-%`KmZ5;0U!VbfB+Bx
z0zd!=00AHX1U}pZp1bzqJ$J`~gn>KAX~ftt!CWseI=buf+&p&QJihgC>{B0ZAfO!(
z00KY&2mk>f00e*l5O`(;-gLR|kh9anlcS^K=hrK|i6|Y6Qo%qt6iMRIxqUxg$1-Dm@ct^ry-L!rR`=I8X)3#%C?4*t#MW20
z$xW6)dwVS(&a6ddD#fEprljW1sKQo^tS|A^{e^UHNy{DcA!R;s#4J#2)x(`*y1aTi
zC*<;~vXSO9&7(O^i*KJQd%30f(jIkM2wIX-K9YbW{_dvtaNBN)X9CPqGIrP9)ujRY7vtm{kJ6_&YjkRdX%#x#{GrJB0#
z&If0zN9yw72AMn{YeZ&yb$z#bxP8=UZmw)Dl=c@^mJ?{vG@0E9tOXKt`NOltwb)KN
zP80K+>F{Qzt_1h;gqo`@tS+rq`LZG;RyWZ$dCSSl7Q3Lv=MqJ-7_+1#ePkHzloq9k
zK!{-sDN|TuyszT}KQ4w;F_)$4$Lo#aSwL%O{zu;1uK$L#9RMjdpn`y
zokKaXzP81MIz>H~J$E`)xii@r0f
z00e*l5C8&CPXMp=cicPjU^mB2G=2TQ<4&S+{r?S@_eb9EdcX1XqCg5j00;m9AOHk_
z01yBIKmZ5;0U!VbfWXrb_@rmn-D~oNr;BGWYR;`0xaXO5&Oxyz8X4FB-*bmvSu}_ZvCnz=`
z00e*l5C8%|00;m9AOHk_z`H=;&1v^@vy-!TN7#Gx`5n*nY-p5jzmA^4+ar*4l%xW5
zB$Ny@Qc2BJnqfv%1yXA*i>Ajn2&Q4X#uc`9MZ4m*lc0
z$kLqt`u=Q?Ow;sEc3~gCzTbSO|4wr8bnWygQz+{1^q=zMm%ty2>BYmnrP5&p@7&;@
z@{4j_PSrBG)zu|75i11LTHgQ2{ik1WK4Wc(N1*KlC?p#ek06NTgV7s4A324;DgB!s
zz47RQ-|^NqKXx(%MtK@X^ZrMi=MKX4$}rkn;STz8jo!J_%7@fx;mst-06|hoq0|fp
zNug2A7Fpp;xEPELu;{h;qO9;GpHNn-r3a7j-+5vH=P^D!dXKy_Ff;0!eB+hQ$Rrab
zm_RVhCOMHTM1<2UFQ01aX|ppJ%|;m_K(d6fgMz6IX1c1LRzkbkxqJ*WeelTrU-jwg
z_9xVivVP!qyy*@>alF$GW9r;K!p`uK%jF(zSsITF?WL<5nnAd2k67quW@uM3DRK(g
zl8?T$85z(2_4oh(5N!hhA0Pk(fB+Bx0zd!=00AHX1b_e#00KbZ{Yn78>)&ycM}Pmn
zf00e*l5C8%|00;m9AOHkDY6Lo7@$Z=7(fR&=
zW3r4jHKX(W|Bjh7qn^jEJMN!#dH<33zl`4YddL1`?3*{1ZvDZnuMhwIjgOD~`QT57
ze`7Fy{g>T8>v??rGuM9Y+Gjk!=K4){&b8tF>4g$NL4P$q?moXGNLjv-s;ec5V$#({
zs4kNvoysUYr}5d8kZol7l1zSLem5RV#Ck25-?zDy@NMp|uTS|-
zgzSmoy}i4#5!*fXt;LU#vnFUFZ~C+&B9Cf0i}mXC^8A~DSI58j$vZ<>yswPw;_0tl
z)W4qV6i;`u2kH@ueo
z%sg!cr^!WLTH}#gdgB8d@eiAN!P7ry&vy7&EeuWn=I8S(g
zI&g1x-0jkv$!L%!BX_jqe~~)o#Kndn(GqK%Hk}rg&yo
z(&>q?+!#S0z0>enAHDstcOOQe4%r-ylo7&`R!^$488bpyIy^B1(iw&6mWI`tw9{zy
zXnudY^*1uK+RjS^o}Ql8BQ0+2##fd$anFgnYkQGQ_;%xqsBUl0$M=wtwf$6VVgfQJ
zTnqQN7x2(vKDIX>TZm(qh4^~B?SgZe_}tjw_|`h$n
z0?M4il~C2ve{MR;+;F{E*geX*i2ES?(f!T#*XmFVtTj%oqCU>&Tiw&OGe#o%5X1;z
z6_TFFCCaB)IGxq>t-t2Xql^1!Tv2uhYq%&y3vM42FKT4>uIz$^GQ*5JG&7YzH~&=9&zNYPkZ!M
z;Y-gOhRkJ4f`mp``p-3o`5LZWEf*s*Ozooiwg&ln82q)enTRjNcdbD<;WS8d6l6u{
z*REKjwpM7JaQ8N-b2yD6=wThTq5n|dR%BC-cB?~xOSE543U9PygMAw&v%F}t#D=D?
zUnV$Z1|OWhcA4O$8T|UW%RMyy$}8^kCBs@uLSE4?anw}%aKK?TZ69Pg_UmyEDuYgYA0|bBo5C8%|00;m9AOHk_01yBIK;VNxV0B<{c5>Xdm_J1O3(q4
zW|CEzY6^M2l0D0_GDnoeu3U7KwF9F5ACs3
zkpLSBCRg0ov4)nnr63t#iAYiuYt;-FCMB^+Rz!5GRMp5{VXgK5-|_y?`xEasKNv*}
zQUU=W00e*l5C8%|00;m9AOHk_01)`_6L`UK*Wnso|L?eeV66Y|ywjnt|99LR7#;Yo
z>$!m;*YNKSKOFkgp@)NiI(B>X&qmc-zjo{VmUm$2=8tYF*M9rv(v9CA{<~{`b?vvV
zZFs)#iK9q-U(W9TfZnqB@5a&BzeQgtxIWon;JfaBfv3ZNX;hysfhX!S`qwL+QzUdZ
zG&$l@lityc!HW}Zt~y)4b1FEVt(}r3J|)Si45wzeY_B=pj!-FuuT%wv&t918ZA<~z
zgFhj~5+^k;k+CD#6LZ$X_sV+WeD+iNLWMVvt&*W}DbT))B}2cLC4=Y7s2Au?AVd3q
zOZVL8uNV`Mi{)B~h^R$MtfVW&aL3emNk;E&6(rhJtaEaFzleAes^5(5N5*MQw3;{{
znnu&(QTCtjp1nSO3(tEtV^f#P+luAP*q9C0b&CAYO`$3By*adIwA*AMFC~jTqEnof
z-^qkFiKfY;-0ybBV9pSJz@b4wbQ6!^FhP{R~eZ&!$NI=hgB{9m3Vm#7dJ_8CfaF&3xJ^9PM`q
z*C%{ixQ-#(#KfncBphuBN55SDWQTC-87s19!H|{@<%?CqDIE2ZL%2TS+rm+u7{3dvq$fW9B*vt{n0)cA(zwp}%MpvQZnxw!SWs;(EbHA;`Zcr&vD=^^
zjnMW0jK&;Q<>G2vV<>_K3HTzcTax(FqOV`C0g6;DrFq4V*0sgft+FOietI+CEi1fU
zToX!N#lXXvF5?)KT%SP{_Ua`b8en0qyLwM_zW>kr3w^KZ!4n7o0U!VbfB+Bx0zd!=
z00AHX1b_e#ct;3)%9^O}SZQ#rxWvXW01%Cf4VM}zGJ!j9k%9-#zxx+EBmvf)4o
zCCKC`s#d9x5jL046{sEwTEQPY^1kF==$ar&1PF#qa&o9%&($K05KHiRv1fv2aGap)
zue&4|iZVl1V3k82I|GZvQd@}gu_%)%hl;x
zwjfBgdQDXFJrcBnKR^%ARV_7_tYxbEi~B)3prPmKkJ8tXelK3gg^04D02LyVN;BK2
zP}xjF%QWeri0q@vi4Kvhz-AuFW+}W$)EDAuBM;74@ZQ!0En-K@@X09227=*8G9=aI
zuvQiDqwq$ybYa0edVhU5N(RxAeJW{Yu__($Yw>!%t{7RIT+`RoM=ZEW&|sD3mM}
z*<7hv(i%)%Eh|Nw1!E7ZXF&u53=>3I&;*fM&F1E|1pX*?7${45|07gKKW$%WZ)KqC
zP&3{%z}wR_RHOnd6Hba5F_I03>J(KHgQrcLKV=WA=TAifM1*arxVTYLn`O`|CUrlBtVDBB&n5(Qlm+QgW+sA7{S@LtG6R8e*S+AE&E4n
z|M43DzKQ;R#rswCyf00e*l5C8%|00;m9An@K2@K#-~J8qHP
zLT)cO?sDBkQf@nLRN-sspK;u48o_Jxt=mO4fhI`~uZ8~^$DN@e$nwj
z9lJU1Yv}9$9k)~T_5Y6h5BmCl$ISub`u`VP-XD3t>-~oJ7ra6IH}=Qq?_c9TBo=&t
z01yBIKmZ5;0U!VbfB+Bx0zd!=ygLNEK^L06LQ&HsF-I_Ljep2l2?&iVhAr!fnmbN+wJ)0`_XiliMFi@C;P-UD<9A0Pk(fB+Bx0zd!=
z00AHX1b_e#c)t>G|1-Q5+~njWeMnEXFG|s69)o7uM;SU03X#dm8CS_<>Po4`F)}H2
zP6k{xp(OUl-zk{21Q^8Swtlg?ImZznVD61q6Tq5C8%|
z00;m9AOHk_01yBIKmZ8zAb{5iIBqWJ^Zy;U2aN0g@9Ojad!z);KmZ5;0U!VbfB+Bx
z0zd!=00AHX1c1Q%gMf48fPIR=h57&Xi6O@P|2dcUGh=h_4^==4AOHk_01yBIKmZ5;
z0U!VbfB+D9e-b$72F7M*h9^fyUpl{DnSE{8n(9vah)9&7q7)m5gqfs1xBUxzgDaIq
zK9G@1GT9`uSwbymL*Za4i~Um)SK?o#xK~N~)rFbxDYX;YJlkaRGb@|IffTN6Z*kNX
zT`4w`r=k5YHGj0#tenj)9uRYpt?Xf;Hosey_7+04w3u6_)y=((!V72HndI?iQP>t$
z>iD#@c)YzShv>}Vnz|q*4@#D}#>#jmO49+7C6XCY;3Z9EXgWxpAG*h8r?HNsx{f1O
zq?V3oQv;Hubsg<0=8JTMsPK7}X)>X@WNKE`)OAhRJU|;~1b0J)!qHK4Ya>uxPBasl
zgZ16bN@7JxFw9~-yqZimHVP|iM764Hq{;k#S)xKJOKZVkerrk1>@DYxRgMZ#nfz8>
zVx&SMxgs*TmHF_|cDR?2ref00e*l5C8%|00;m9AOHkD(gZr@|F`BO
znDZ_==l{1n%?S&}_5UBcynpQd%a1fcP#y>X0U!VbfB+Bx0zd!=00AHX1c1N?m%we$
zYi|2Oh5@hVwULYS|9{}}{=oa=53ZUZIS>E>KmZ5;0U!VbfB+Bx0zd!=0D+GpfxDg=
z_l4Ij3_S0d9>K2m7Ze!J|G(z)e$D&6k0L4183+IYAOHk_01yBIKmZ5;0U!VbfWUi1
zAn2*Mul^dt0oGF)x%xW-N3SPb1MWeWdvN&qv8mBH&$m3^YU%CymC50|<6ph+3XO(B
zZ#HLVQ{{B3s`84O;;LFf|I$w%yx~`ys>YZ6(HtkL{FGnF`lEg$D1M`(f6A}_M2(**
zzt$}CD1^p0G~}o7m8zidD2^GuDhZV;3SgefWlfMJ)gOJsUlJsLRH};Nl)uC^T8Cv$
z(|85>`2CL`PxX_At8p5qwB<3*zq01OjdlI`kq^JFNOzNdP7ra)T!lW@*yBY$qxqwx
zUI2T-87d~0VyJMKKrTXwPoSbjMfJchl(;-Ub6U=$JX*(m`=l(jkJVa!vQZMPfO#S3
ze}v>LR7+_+tq{>*3FNK+Vw2Gi(yfCWI%tO={EvECK||v+RfR602&P4v6y6CY?30+-
zb?PmnSmcF#twy(HRCvtB6cL?pxIQ44w_S7@S;WPNQdz+@RXt-x_AD6E@}YdOia}>M
zHN$231ytHvt0F#r{7A_9W_@44*`oW10o)1%gzV)V-|%yyDA(g9P7q@#vOlURRn%PQ
zu3OlJy5PKz8XR0yb`QRh;mewy%z}&(!)-&WQIL?;?N*Uxw_F5in^V=gtYp`DDUUM^
zPx)m@Y<7!p64kxT_UEzn9#ukrp)Go+Z7@JGY|?V4d?XoV*eJ^eSSFlwxdwg{w}*{vNfeW=HbQk7y(TuzseG14@4xNUaE8>fDwGRt127v((*oYjX0u!^vNWUH
zpIKqjvTU~WHStx
zK4XNoD5~D*{fep2B@!=Cxja)N+u5J0`Z&`p67%fxS@%@*jN`M4
zpq1M>p;2vTeR>ANT*ojBW!`<5+v5sSi=@j%s){dL%vJyk-C4PwT57{&9OHAxh4)3l
z@h}<<&);y2Rar7hMgrkrxII=yyBvg~EFEQ;028K@c>llauB~gg{(M+*ZQWcSn|1$3
z*SGbK|LuJJQ{Q!C?BE$4Jw6^qLtiFKrbU^}RGXO`m*HANU#7>U4v`1_*}jLO%e(KtOL
z+DYK=5iDGK3YripV=SE1?T)8e(Bxind`V`-Owc6^pk>R7x`*-awXA|9JL$~+t>-X*N
zUoKRa5eE|rghGTh;-Cm08HrMKlne(bhED$D!)q_#+Y4W;o_kw=_!e1bSJxUR&^^Wp
zb@q&E5R&am=e-;!4;5V%c;s5N2u1vPm?SW}*ZdP6q>2B$#~b6NC6E(!DqD>qF-^)!Ervm?bNW
z5{eK?p(u$Qy2EX*zU^VxBA1bsIvHu3l0M?G>iaVp{yw_&eR=bNoPNK)^TxdC)9ubY
zzVnJ&__ujq9>+GvsODXHy??ZBz8fzzBcrHrLL}8L9Ebaxn;!kjP;%*L7XQKs=2t@W(pGS4xEvm$y{L*drN91L^lK~Bwf
zhv048lJ9oqNQDCo+Xjl;ab)cTGWbA*h$N{<@~zJg-JTr52Cp-HYj$?FSgwVLh+3q?
zO1e@EV}qAuQO*le53G&w*;!GmWAROO-W!9rTekg4l->PpTfCz|b+o%ymx~yndo`DO
zTJ@w_fgVW_Q5yBTBx;(NcCBD>XN<~`VNh+Mllt|)XSC?@zC5;we&7QHfB+Bx0zd!=
z00AHX1b_e#00K`>;QYulHapX86M1wWiHedzG>?Iz|L(->+Inixs>6n
z$6PMHNo5xDE2ZVNjYDQHmkerLQ;BcJRu2viw-O7D#UoDJQmNP$u@>Jrwr;dIq%<$<
z!%T<{pC1h%)kY@KZGiLZmD$&Ztw^owP&N`E*s!h|Wmm0SC{*aW5@a)6nWb4%H8ES9
z+dADzAFUj1(}6~9H5@xy<;&~}UEDY;9UWFf_4yE6+Gck*naWzIRNq-y3q>HC7AmZ4ooOsvKw3>Z-K1zDez!Z8N00I?o5U=VJVJC0G)e)tYn^uC6rp
zLaV}7c>Pc$St6DTm!#OzVexPmmFPNkJjWarwvKa=%!M+r393)y$2VH=`hSo2J1*}}
zyx;Nu@ac^lQUC%#00;m9AOHk_01yBIKmZ5;0U!Vbo)LlPJ(F(7Jt7ZwbKG1Rc+N99
z;pQ=<^7TOyWVd+Bl#d%AOHk_01yBIKmZ5;0U!VbfB+Bx0zlxB1U~7Rbz6`0
z9dj`}9iEOk7X$Y^vm;i_mZv!xz_|YZ9hdiay#L4hN0$@@f&l>_00e*l5C8%|00;m9
zAOHk_01yBI?;U~Lp4Z&=Wf>mqU|-BIaNBcV-vZDv|Nq-A?+?7+_Wt<26CH8}0zd!=
z00AHX1b_e#00KY&2mk>f00epyxa*m5<5vrGyf4Dja_xB4!oc&MY5n5g{+0sc`Ts+g
z_j}&2c^~%92Yi445C8%|00;m9AOHk_01yBIKmZ5;f$ju?o{IbG?_2cXXjgxi;Q;HY
zj9mRCfq4Hv*X{1|gDVgK0zd!=00AHX1b_e#00KY&2mk>f@ZlhUpZ~-4|A(V2K?@)N
z1b_e#00KY&2mk>f00e*l5C8(b2;k@cH%ETg<^Av82V?(pY;W}UZ~ooQ@7+w?_`!|!
zk>4Hu9R3^p_xP_@3g8X|fB+Bx0zlx|6Bzkz_dgkrPrA=v6QnHPNae_;%oj6i{fs%Q
zoi$NQ%1OT>>7zJ>T=Y=1rByMN!exs~v3
z?ys*;`A%{IFJ@1iA&`gAa^2ot*@*2P`_|&e$hlh1ViFf(SZPko(u3nWlWvzzQmu%B
z#;3TdCL3fON~B1KL&W8Bf6ZPLM6XCzzoRIS7`JRi;RxzH?JNq1uRUESXbao3DGH)rQ8>g*kq!qI)w^L__kJ!gG`@7t
zeg2A3gvD|#L`2jgC05dvVmPJAMP6Fto49tE?}?&KAp4suJNvE5k?I!_*A2fJ+mGr*
zE76mQ^P%aX@fgbf^WC%OOPnCu@}@48w-w8ovF>p);X7Q8@1kmDocdm#MfK+AruuTI
zs=R_XzqZLjUP=~wM5j0{zmo}Va%en`Fx>5q!PZ2X7zs1;R@3CfZ;PSV2RnYNNt*ub
zAco(gHk&!6Nb_Lkt>cNI@q72&Umnq0Fw@!m377&IEmpVe`c)6JB>J>vdWZu-719D~
zb($x>xvj1Bcx=;Z`W9pBdvSY}>(R)GoT}~eDzEMFk`~i?H+HDj;;;w2BIJY&rwOuj
zi741eE~JT)Ds!q@mzC@?rxr|qTT5-#2m_MM=ZS4HZnsV2yqIoH7alk1{c%nF-0L0Y
zUkkHjg;7EgLMap_v7;Hthyo&~PIfi_ei8L9(u|GUO-pIi4BYQ%2Arwrg^#PA8T+%B
z%G-+N%-FED?#;kw`f_NIw#hPaDOv0ho#M3oP&4paM>F8WV5`(7h}X@`Tg^b97)G~f
z!{2TOUhUTmm@!hMd2k<||3CXNE93wKfB+Bx0zd!=00AHX1b_e#00KbZy(R$b|KIBp
zfc${~5C8%|00;m9AOHk_01yBIKmZ6ldjjzM|JgH!9Do2200KY&2mk>f00e*l5C8%|
z00_KK2wX>Upap%dF!~=t|M7}42K|Q*5C8%|00;m9AOHk_01yBIKmZ5;0U+=pAn^YI
DA{{{o
literal 172032
zcmeI5d2Ae6df5BmAxffpH5v_9JEPg!p4riGrE1lEG%?=cr4e;d8j?LDiNiEsRlOpc
z?2FyiJTy?S)a=e|g23@+|A>RcK;Xnc5M;4H5@3`3Wg|e6wPPfJ4e#cU05NPQNE{n*
zU?X<+pM0;XPj)w1oRLP_-LE8)b$svoecyZa?pl2JR?efGTdEX@$H|c|L}IbX+Z-2(
zMBad(uffkL_=&;KL--khpT-ewoE-A^jYul{r9lvgCVs-iUQYZZarVp~p83Jazj^vk
zPygG~zkK5LlZz)`8Ty^!|2zDfgYwYEz=OzN#{Vq#V*DHNZ!iZ>>v(+O#o@6`Ci>BN
zk63vsyOLoTuE7hs>hOx`Qr>g~lcz+drYZ`8;Mieg&QCAQPA<)IOOsQ#X1UccPFA@$
zSM3TV9(7i^rP+H++|2B?$;_=KE}7)!?<{finOnE6aI3k->*3moCABTG=_$PdC@Y?fCwCFAa|sGSU5=T+yMsVG@z8ry!eiLdlQSInB+_%-##f
zsXd5Q?#_HTSM7Ai59
z?!(Bu&{=eZal)cSwuwh7y$XQOP^c71;Zv6>wJNR5HOi_+QFiIl_}UAH8D5y=X2md4
z=k~ob!($gNL_eDL^^+}=3MmA?6P=8Mn?Uj82poP1%dx{Rh1-kS;Q1!qTWvkh%+I}>
znQfKOfy_3^*8m;I-B^Sos*(I;v6j!bmF9u$(FzLWjx5QU*!bly42^yLLcfxvPC*L(
zp1qt1AuI;MU-+nYYIy9m*P@?@0Z_Y?&r{pWm5SNc>FEP#TTiQHM6|ZrI&oJ>MhjRo3S3xh8q1h
zab3?jx^t9Fpx;2MhHyqb9oCuH=4|wvr-p~e#>S$b)B-DG-$}DR7^pH&
zz*1?IdW*E^O?pjnU$$Yj-4?){z2}p#3CqR}*GD*Ox3N}I4&;&U(3-_pyBj#~7n*N-
z<0ps4-Wuy`d%khVzImJ)A30i7@?>Uz&YqcA@#w5Dvauy6n&QIi%qoBHLwgzU)z^<_q<=u`Xjd
zLcYsN`pNs|ae3&__gA1SIZ)T|<-ribQXo8Zc_4(a7zhvUN230lasQpbND8^Nil3vZ
z+16?7fVs4ufX%QL828@f!u0jYg*PQt9p~CCrDXsdDVOt5ZgCAGX=rC$jU8IrwgStF
zZE=bR?dmU$jl#)5;;%;%f0X#m#6N=*JdgkqKmter2_OL^fCP{L5YmyQPV`#&{UK4DmYoX>(NS)XhwF^=Iks_Fe0Yll@Trua>ADx604J7_{
zB=NrzzmxdaaDoRCKmter2_OL^fCP{L5I$xQU>*8bq=#Dwe`vZ>oPFIfuViKJP);s_$INRDjFj;>p_5o06Q(%W>G6v}y;
zvP*@v>23F>sNRwGvNJcTi#fVElbM>GaqgBT&8+<3!G=-3zHYm7t|wq@^GgLE^e@v+@5};`@=rkKq>{NB{{S0VIF~
zkN^@u0!RP}AOR$R1dzb0WH22J!FkQpY;$iHNG`+{FMf*{U1;KI@|y6cN4!3
zCwL$MB!C2v01`j~NB{{S0VIF~eqIU0em^t9mbOp8!4`hYj_mN7MI>G}WS6%TiSTSq
zyW&W?A__JeV3pfff5T#R)|$)8ZOhvq828x<_nT&YjXPWS{lH%#4vX9ytH0;vdum0P
z6|9GwQ|m;t))%D7+jM?Ux?w3-4W;(J_+W0*bvE4X2a~(0*02j=S`=X!
zdu#Q#zv8{;ZJA@Yg@e+ax8W=9zneMXuk(k6{*6QjFnkU^5k+iZ3Se}s^M{1@;_@*L
zKEOugkKZN00y};;NsKw`)wObXS5us||07?BMA+Cr?f~%fYT%e|B!C2v01`j~NB{{S
z0VIF~kN^_+DIoB6*OZeu#QJO9>;K{IiwD>L*V7k;zpN`@1b$A`@8KULfCP{L5h@6?0oP5UP7oDl@@q
zGW{WVv%5(1)b^5T)4Y4hFCNDxrcyEl@!S^1h|tc&?QhPC}(*Ebh}YUekm0n1(W;x%Zmj#CMG0X6hwg#-ZEUuE23lagc+Y{
zs@c+)^IcV|wiiD2tMGbif4{OQAMZu^W$RGC#uu?+TU)ksUa|Jim$+P4N+HTLny
zC_5%5WZThPYD&Bg#CbSyy$v<>ckO_B#AiF^pCs
zgRCc|B{ik$N_*B5(vk!l6r?moe7qZjM4tbqUe9`A3T2kaPo2ijY}eyW%j#2dy*hOq
z!+%z_kEAN>ssgKWwa{yn*k;mW`Hv8`xv69KXxge
z841&a$tL{|CVo4T_~XRyCw`Rp?Zls>ije>kKmter2_OL^fCP{L5xR)2_OL^fCP{L5ge
zlF5mBT2a!nn9^0DSgo$_xes$rt+1^)#XeF8+JF1;=!wyZ%-BR`ByiC7?EythiK^(@
znknh<@e7-%@T~z+gO6vK)a6x8wBTb}wrg3U|Lys}z0Qj!Cz$Y+4K|@&6*W`0ZP}3>
zUbBeA%ZBXomLd^ew`@akz-Ljg0qU)Z48-F^S0q$1C2O)H%C065g;#Y=g%2LsI&V_Z
z;br!94p|XgLvDQ0WB+m-NM+80XRTfE8=q4M?;A@}BSn(ngO<_?`>2=FDtH{}0nG;P3w*rvD~r|9>Zv
z_@l%R6W>dG=VyyGrUnTh0VIF~kN^@u0!RP}AOR$R1dzaUO5o-AL^K%B@0#W@(0bN3
zS0k9%5byacm`4!%QhcJ{9FNg6e-eqG8Hy)rXNJ!F;+f|Z3PuYFAOR$R1dsp{Kmter
z2_OL^fCQcZfnS`+><`9<*iwLwY4bu_g*66RN--rvF^K3;!mEzs@QUPUJP`zk7i2+I
zbjx*Zm$I4Y_08v-D*?2$pu1>8ofk|&lMUihNig%J3N0L%5D(Fi
zQo11ntZ%dR0iQLQ-QKGyT@=Hna({m?+6%XAq*Wnh7@7*p2^>ua$i%jIMbII|x2tQBsK`DUzn^sd8}*{8F806u;K_Jm#_0VIF~kN^@u0!RP}AOR$R1dsp{__-r6uIr+0n*wZIU?~Q#
zuH(zZHp{&JUdV2dDGTI^7sP5rq-Jd%!$_(6Q+LK
zmZ_Vn<~rtwJ{3U%p|+{{3QWy6jCIc|SJPLowvoQt5?6Ua6qmPb#n2%@S<5=>X8n$994sl4gRFhL)t?z@I*8merwdH!L>x=sD@
zO7sOE%h*%I(!FE>=HRP>_8fCL`l|}dq0CN!Wdv;eKbH7D+yDQ^KOH3v#u5o20VIF~
zkN^@u0!RP}AOR$R1dzbbJb^dknP`}fFn|7kn69DGflA~Hkq;w@Ur7AM&_`#!edhj|
zuf=vo{$S*vp7^B`*;D0{|MG-3`W+Cc|BL@w?8W#u;@@}zq(9ml8y*`Qi+=L1N31+`
zXrUzezrHxVFgv+4%Pmb#-J0c+{=FpkX3{E^@=0zAep6E9W@fKVW^OHU*CuZ*&R*eG
z?FuCxbym5h*?UXPr^zHYe`krC&)m9og-g-`$$=Lg5|=K4<6Oht-G#Z^lMDB`o3r;p
zzE~?*w36f?KDU})3h=hV^gm}BX0vrEm_tfy%
zSHBwlMD}%`wM%)H!d$6XZ9hNVX$kF*z#vYJz~QTos~>*c+f^8dRQIYLEv))NQ+Mv%
znw@OX^3A7ZBY}e=DbPTi3wUJa=ibfC9u%wE>tIoVV228l%PVbm7x*mE3WUxhlmYk!#X*Xh23h&gR}R1
z5;kGktKs?xXYGoIWH*l^oV6Q4(+!;TbLJbwN3X_*$6kIp`tiBIAc#}QK`{{g88~1P
z!QK8g;fK^+0+4Ouk;-Z-PIL21v+v9}3sy{@wOXt4qQOfp(`fIsM
zT}Q}w>8YQ*ub`8!L|*88-(NaEG&XTQ8p#zMx?A1K=RBGvHLv7fXW0uo3)@8%N-te~
zVQ6gnVn4xhrNrJ+)vVY!JNJ0(?C{vdi_vdJePd|cZ~T3s(;OPNS?wTDagTRYn3_S~
z3IMzadU@Fd*bf}k5m@Op=k#!$B=Dx&27mihWs*H#nfmC^*v+r@L)rdn%u4N7XY|vd
z9g6J^M^B%P{Boz>pO343^Tpw@^XH?FGro;5Lt{VZJ8gsA0=xJO+F$i@9(!MR=-96=
zhEV@1v+ORnr)Mdh&VBW9=5q31QRoL__
zThHCuoU_|7_!sMGotvMTy~nK{*PB%i>Wj&SXYG{6&-@_zjjvzmYi9l{IV%V6>r)`S
zc%-lt2%iMuH;xn*1L2V~KZyL*^I>hDb1e2>dI=2Vd~{#;4McV&!!TR}M($NG5YweR
zYyxNUl<3q{MHrEH>~;fTRzKEh9LMz{Fph)SXPh4z8k>B%uVK{R+H(E9J
zDZ#xrxiEcwa^X!$RmZuu%1f(G!jW=050zQ2QEzEf7{=Au(AeB-ebv&6MpkSKIS#2g
z!BR9Dt47X6CnK@zr(Za=8M}D$Kac#%Nae(jhW{e^r^Ej)_TL6J6WQqG;J=Ffi^17}
z|Jq;>`?bc|bM`lOCiYU~k3Y{y?SJi!;j#PU(fw>t`GJlBS}BtJWX)TzW}%4P&O!6I
zc{1m)Vm#X+^=92b>Xpl&Oz#wFx^JEY9&x22x7sNRMzz`qb``od8s~G)>Nx+ELvvY~
zR7e48W7**3jq#U<#@-t1XIrd_YvP5
z8k_xcBOaIGD(rjYYag9>ZFp>aJo>GhK@RNz-w7^pAfI-K0@a`$#(IhxhhH&h%db}g
zk)Yxii1{Lo%vyim&^W$x=kxa3en;J;yZP6ws5FA=+lJtI-jDsYI}5XO@659f
zFfgQgQpTYN?Aq+Y?ELiXBG{W>31D^xFveL`B6D|!b=XZ$E>2I*%(6Q(v$tkjcl>6=
z#Op(2cg7o9JlJUH)CJgJ2mUXd{$507+4@-R1NUpgWAcUQ{%DXjgStzjo$lvD|JF#)
z{MRkb($$vuhDWR8TjRrHJcKje9**CDYz5Nj+vv>XK_E@h10e)$&~fhl>%g8{tv5-(
z@)nm&e)H6&VZRq;E3oUpBmeh!haD#aZS`+4gJrOzk1apFUX;F;xY`68#H{(dXI{ZB
z6zEgqlVQqrsa~#M+Rt)+kIP5r1LLWe`@x^D9x$KaF0&t|h@2CsquX5A{Y|>(KlYz?
z^_8$k+azD3@VBG8H0S|kkLyKSXOpX=SYuN4QmwDl$?^R!ei>@fW6^!n&yubvxiYmh
z-ZEhVv0}M!CQ^l$HHQj{D%gfa57wfUWF17kjdffQQe>C3sURUwJHC
z^hZd(`Ss?T5;k)#loYO0NB+I`K>CvDJJhk-d{g+k>y`k^`k{l>L3VHmH+v3*)|T{
zABtU9)u-2Wb?nY&s83p~v-zV%KSx5n+%Jq8f1f~S^Zu(}9UglZT8DRfwGLTz4cc*y
z_CR;b@N7LFbygZXYQ}!`?tmwKT7^My?QO!<@nHV{5B>fBKF?+|hJ^%>01`j~NB{{S
z0VIF~kN^@u0!RP}dNB{{S
z0VIF~kN^@u0!RP}AOR%sBnVuJXGX$w69n!5KZ5!HPa;(G0|_7jB!C2v01`j~NB{{S
z0VIF~kN^^RW(2+zpBU*gm0)!AyOF_Dd(l&SXN<%Ns1dsp{Kmter
z2_S*znZW7OpF|TAnTfNR$m6qPXB*W^N%B7zE~W)JrRch4C^l@dC-XW?TjdqmX8Z36
zDlgj#l_ZBq1~HpXRZbMrl9?9aYweP@ykZ#^Q4JS1JD1?A=88lF-V!KOSpWUR=lb1wJFw>HpRh4WAmGdLqDbMx
z>4qe07W>|Lh-4%ozDFl^d4+BHr!KENo`{}i_(oUuC&u=hGkxp)gfvXg6?I)u71wlO
zYOg>9!YdSZem8VWRbEkbm@urt
zR`Qywn+~C->ncvFTwGgDs=!J!Wdmjq%dkzNr5e1YC>k#+nkzeoU`e*ZzT3`FXtHX{
zrpjvueArzvsR|mERnQF4sO4IY<;u*{q@Pf6IVp>(Ba5=g+oB5&RGILgbdiVsG;KkX
z1f4pz@8C+3s*>igXxJ*TV6Q>j;)yIP5P)UcGL>~#mjb`{?+=~=dL2ff@-5?IMj(Vy
zZWla3O$nm1oOE1OwGG#W7*qHKHU!b84zE#3G-Q{GqNIklj4)~pECbXAxqfIOv;WG_
zNig%3&t&Cl3Z{ZHD?i#Wk88fC<4gf(Gow!=_qSOuWKBsG`sM!4thts
zqiCcQO%JO=Q&u6vNGk8DA}kDW;FqDh3J)KOfb_`qE)5qS`M^@*ge?#V}!vC+(l^WO?46iiIWu4EVnOg|EI)q#mauxX*`z_Oi=8KRVOJF(WdE<*IAIk0J8b3}oG=MraOKgXm0*I2
z%8ME&7-^Nww@VpveS)09i#jJ7X<32~M5J_6{qD6m=y2>;Pc0VUn3xFjAy=kDsgDxn
z(oT78JDY;urB+J)Psq1|YFvg7D@k{4TJ~m2
z1(IXgD9AVLi_Fbi%OFDvd9k
z-*`9Hm7F84w`Hy11L!Rt6`CjPT?2PoOpbgfwUh*9iCxOG*8+Gl$6kS9-k=bgO{z9=
z=nU8lm}v0g>&v=YAf=M5GvzljA$*j$l?wLF;Eog=^KEvl$m~G2+b!lxt;nzs!~@7`
zD?&Z79w$;@{7-}{Q)OSQxq@1f3vyf8RtEvmKO!y9z(fvBin?W+>y=%mg1Uu3>Ej-q
zj^pU0+t$O>G$2O3&1>atmg8vEOEL3J)xYPRB9N7~Dfbq~(i=jAd*cm`y^P;_9_5=A
zIjFHIX;{^u!ennv4sJt5PGGksAuY-&Su?)6U3srA!!=#Ito*JKyrA)7DU+~wkkw{}}*5qw^}W!+sxN8q|q4-T%{
zD|4M?UuP-Cr2FPJBX4=)p1sqGi*KpTSCehb{vf5lww>f6>z;HFaTCiAcyq;ai
zHt|SXk@|ES2+3T5tkJ6*=ks{b
zEEr%p6_fyC8kP{4ve2hs7(Mk1k=WoT(UWt7pFGLdKl+6PkN^@u0-*@}jjs=~j<)Yi
z`_dB=usG6{sipCjX~0@F%Y`$MD!i;YR8UmGHZ00I+LX4+S-n!@*UrT49f@u|R9bJ$
zr_hoz&90_Yn0WVnZ}#aY+S#_Yol>=}!!=Y>`{+7eXMMA?t^G6-Lf17l|4`{n$n)2=
zu~p6GYrCZbt#B64wr2Qes>BAJ?jd`*`}e_kJK1%o;K!<7)jn!S;AorpbYlW8(W@I3
z(pEubJ)(Z4`bjZknGKpEhd^uA_FEPFjq<0f`7gH*6|m7H5!R!p1tBG9t)YUB7N)sh
z8DJ&YuiT>Lj9
zP$Ac0_Yp;btw|)Eo*?4?rM*LA-OPMG=lk{9!t98uuc52Wkh{=!DkzAWucm5Ul~(0yd$e0>
z;yg+U4Fxw8&Md2DZTQnsl0o7FZM3;-%o|`MDy3pz(f(t`_Gpax5{J?^wlE;@-c#dtW
zy>tkonF~y5&h-lcC+8F|c^s*3vb@`a@D*RoCPgl{RxDL03=F(Tcb<>Z6W(R4
z1mKzx`k66y+t+GUmT$+4C4+o9Dsh7rU_88XhrFc9Rx~m1pxa>+N+``+pOC4m0-$HT9gD+6Bsr>jJx&sm0xgm2X_D7!zEI=
zso!>|ugS{dlMm>`=-w<&uI;VgUVk|EE@4x!+QMq3
zrM>L!($D%ewNidKm3{DDMVQ&D-2z2a%~I~cU32=~ncKz1_ccu`+%6e5U31r_s#8i|
zWPTN?QNivC=i1ggJ4)ux-kMswm#xT^U2$P2voX7P{nk4Riv`_KH@(|S(njs}eMg(R
zeP?4xJqa`k
zb`~JlRK37BAv@O}+4&qTdj8ZjQIa9v!Hva69)O$hUt&M3vIm>yQJ533dF+r_9CI-1
z**dn%UQuFE0q^pp+q(c~uM`JQ+7j%+?4oSoP0xfYs>T9xa#b6uw#SLMKhp(@wVfQB
zi+*gEkAkjBg_jIY(9^1t79`jXv_AfC@`A{TuuH1UHfWU%G5G%9k0Xg6cQt|K2w;!L3pUR9}g@{kUVd}OmJF>%T7Lj<_kX_zVB*N>KZ77bUE23bth2e7h
z9$a(R_tvJj-K;FCcci`S%#G?|?tyWiZq8(Gnp3kg&fU`FhEcx$V2<3hr`Bz^;3rRb
zeO|t&R)kr>dbl~YPBd$ML7Kcx=l7%=mU7ilYVV5=<|bWd!`*%`xht>NuD*Y5X)8#F
z%9eQxu=Ts>Z`#>d{oVhz>`My=r8{r$e-JyBIl*8sEjAJz!0@sDS#oh!+?bM
z;_rJ{y&hY!218h_Y)t(2_8rQ2_OL^fCP{L5%3VbC|4b&EcQVOn!SDZl-DMZ+^Zb6#WH)*8
z&dhtw`>r@?X}lo0Bbj_kEVw1gg86n(KB`6jj2%_wa8B|6apC8vlCmZ{(+Ig!~IE
zQG=`Z@24N39QM~K*U_#!?614%zEAbdq~{HMdBF28UO6s}J239RxC7%3j5{#yz_LlqTt!fAWHPD;Ky|&O2u50{5C`evNyY(}ABg
z@$edV<7ef9RV&@gPhPTY-pQxCk6&>55!w;OygYJ9{fJ{1ESz`B(v|KhQ`{$`y)JF=)q`FT+(U0Cz)
zj)W~@xO6;KB5D
zF1u~goP)a7EsLkaQe~vHtZuxk)v-)5zd9an3W}pqyq27B?1EKp)8pNlbZgmD3i(JO
zo|2{=KK1ljQ&Y31hTT&a4>Jt@cR1ehhzrxF_q{@`nt4!HuJ!%Ivn}t!-uYc1SE3lZFoJs7icRBoA!tYW?+Ek#G3}*3N%`
z9NGRS8Yj+l*Uqj5^>g1ce(vk9Lil=d_SSz|PoWMa{)nj4Hpz=`%GUZ#si(|pLn(8%
zd{xa$>1y4vYuq@!n+h&&(?(9d=_!Y8l7K(tYW+jnh06G+S_^dZ7vlU#!)f`huK`d?FWGTOzfQ8qK$UmrP9mDOyM_O7G4Y?BT-q-)(GtvsdT(Y#oQ
zXVS`QLc1F#jNDI%f)5*s%aZ%lc_+_bJn!UboYy;De>Wjlbhntz%5f>|9=Yz1+(xE*
zyRDO!9Af&e$sfb}CV>UFQ@eUg?Z>}v*R7Q6IsD)F&$t8Q4vaf6?!dSM;|`2FFz(
z1LF>iJ239RxC7%3>~;{O`j9g5m2cwygd)epr?Xs{q
zkY|KIfMJ-`@pQb)-VQbWgOfj`ltPb^O1dnoe~^1~UEOz9hZW>9Rj=yUKb&@4OyI
z_rs91pY_5OJ+7JSX6@BI>7d!`PTOPl?1Pf5P|FvRe99x`Yl44B%*Kb%?;uN|`V@$v
za!t-vC56xAW0`6+IFy${nS5B8lNF=VZ1%9MlwYng4|AB3$ronNYD{4|409q1QhqjO
zz)@Soa4Mc|&O(V53fbYIAvqHgOOJ5N@ucKlB!!Zh>F)Wld?qCg
zv5c=i)s1nl5F=tf-gwEm=W`6l&0?8ZoX^d2!(Q(&FAVZLbB0;I`M%djH&$FT4{vz4
zPJwwvMkq!DwOBG6z(2Nbo+1?r@pM$dC-1r}s?O4D{)YeCPd-J;=QH_ZF)1%DD^rH2
zoK<8PPbeLi>qMGzgm%C(Nm1~P;((Pg$=#S(S;u%@Qrsm`j)&d#Z`7?$`pV?vwfbxs
zcOjFM()fROP;$eB!71%ldocM>@U~ms$Ty8z&tG9dIXy&IPV+Ly<`RByHpLdwQBU1!
zb>5BARzg!rZzNSs6)JpL$>x(o9(0x+7mgfrVq97^@3g^eIy$EiE2e_8S%#_LU%iO#
zPG3~JZ@h0XAn@(JHS&7jx(&W%WIyj`MKM+gr^Ctezwk{C$wD~bFLPW<_9d!y-%g1w
zJ${wIEKB4S7!gEw4{Z;%y)cJmr}VjjQo0>jlz&UpgS
z055xl6f2bk-PLS$Hdr4wQPk<4vDHI-j;-K|mWs%kWYOcPU(MJR3*x?$e5d%iQF%4<
zaD!f7C*QO@@lDuxzM;_7N~wz9$Hx*dLiM$ZKe_AtHxY)l
z54h={_V2V^VZXqBThBA}XZzRo7F}zs_U`WuY#+GZb!XRAwpBeRI+ok6r>lM6a801Q
z`rho`=(x#x7QMy#s%1srT-TxYl>=F)*uTH)V&`3b+Z;9LS9;gfhUYsy|
z&=>W~KI#QEDTS46FeD_>#Yog&3sKwEWZ0MTRx+h*rRoi4GiB;|HJPZ@0&&YiQX;6CURlU
z6D?4WsmYS3%%zjXP}U!2lp;$#swPwZNHOM*v7VH-m@LGo->S)IFs8sP0H~sJQHg|G0!M5mfE5wIo9t>CqgnF9~G-8-oLIVD;_ye^Hro&z7)$=
zsJqo9&y_;aj2QQ^X*OCaQ+KIJNvWijw3zpmD?wkXLfxq*)6rC>6f1d9X{{9U3F=pB
zGUu;(LunMTM3zg&S?ZT+vRVyAtE`ww#RZSo7p3k{lfiJP5@Cg+6b}0NN`l&~CX=!#
z26E+`T$R&YEkXT4O(p^XIqc(eHL;RRq@vXAYSLTIvjzCW1U;fJn4xY{lhq2FVbggf
z8s=l^M1i_hP38)o2*Y!sq$lq!=i<~YYEp`LW07P9A0}3-RWj7gYBE|ZiKSwoQsm+h
z*_WYiQj?L0EXM+o2$L?BqW%oENlh{p!CS
z1x3tcEA-*k=co%RwM3FFg*@SGDJs-)yd(XYB%
z4CSgEz0mqMYEC7hc#=Xk;%5{7Xfi|}Qa@4>?hrBcP0;R5+8AF1))AYDLmWhqNOiJBbdeM~N14b#i;@;UivB9*PlTtH!jh~%f|AUPv1CZ#G<;Nr8L_RU*-HqF9bd>4=o`(EfTdpAIJZXrdsXGq6&i&$Jh)8MzE!%%oDOQm}@H
z=M~ysKQiL2W>QffTTEsdNv2)(B+rVxSj-0lzH%`j&biKqE|%;Tpg
zs7Gc4@meVx50*ojIQn5}!TuCAF^krLQt^B9-dJArMCtwON3txslxu>*K}nXQ32!bI$!GWk?NI+vy6g!A6CPhMS*^+07=0FAzA_zRN<~lAn=KaOAy0;0
zT0b%nDn@clQ7nq#tSHed)IXGFvz*LJT+mytW@VXPg5*S8tD_u~;m`-2OA9PLTYY(|
zT#8CKS<=;Xv>eUSXFHyxCZ+PPyDoX4N2u~H@zO-1QA-uw)?
zTvO0n?JMT|`FyEFhty9bXK+^JOTj|Y8!ho=`Z)FFvW$=9OL#f1R><;UI_X?T#pSRp
zO1ZpWsfamTPUznHk$kiq5<)>7n@;7D^z?d?sfA)(!W#_6g5`XOUXGW~NS3R)N>*gb
z*<6v&$n;*$jnu?sDI5=aJvd)e312KtcdIW?7K<4stmHYFi$*0Mt5@eE+FJBVrp8CtfA>1gUK~9JVxuQh(smTPF;bI;*6iG<6
zl80VEub}3{YqE%sQ>f*+lp%1;j)qt-zT{FVPUHKeCdNv&D&rRlO3bftHJ(19exyB$6{?fl^$^3$$P`=(M8qa}tbggl<2z=jgMKy&JmkpiF2
z#ZuvLrQjF5B|6eqrRGFZ873Ia$pN++6SF1y*uH0|iIJFxm4#waR!UV=0qBG4FAo;8
zNz_L3aXuxaYxHSqQsRU7W(%1LF6`liNN4*VpeBZ^nQX9JiKcj8Ef!DFC#pw=^K3ck
z$K5H#mJ@*@ov$Y|B_<>UatU8G;;DG(GDTSyhm(OoIhLqNVWt*IROp;*ezzD7DgI2!
zBb1}LYCa>;;r@fE8DY^AOD0fr@cDymHbXnr-x(_6jw5-pV!W8h#*_5qdNRSvKADLW
z@|9Eq7prLhPpJz+Q6cS3m*QMLocHn!eJYaiU@=QTot&k_%O$k-X%Qa=t`Lrl+Y%F~_958HUaH;yzC_K+mivqq(r0sgyVl*Af)4
zlj}(#kVuGS53Vk3E+o+@*Zi)U=<#OxTFw*8m2nnl=}Gk?87Ywnhl5I)O~!D-u2hpk
z4#h4MNR-07HyTaT!GT5893fi4`J2RgBB3PCQF^g@WI*H;C7Z2;b7@?t75a1>IWfRh
zlCcaE%_Kb8Y&=8{*N@~%2`>{$XBDr<8&&9-Yd-aSfc4|=qbVOR#!HHa-cvo&Uydbn
z0mWNFMLgXBYP85X@vCR7Lr)liLIgTEg4dZc8qQt?%p8WTaW@u^4hRk?-&tZg5@ay1FOk+SwBu|8LxZG2+0%J^S|T(c|dpvb<|~
z&GLffPnIVw4_ofB++n%N@*~T4EZ11RXt~VtDa$%b)snNsEu!Ug%So0cuH#%@m)o_k
zYtR0h`hV1aS^tuOe4o(AI_`1YVXazom?C4dVByRyU5_{Lu?9Zcz~46Tkp}*zfe$xu
zYXcu@;DZf(pn>-{@V*A#+rWDoxTS%=Zs6SwysLqCHt<&s{AC00XyE1s{-S}mH}JLw
z-rB%h8hCR9Z))JC2Hx1fpEvMl4OF4)GPT)6GhLTCD}5Ep4lHZ3dOwG@g~pnrbQV|y
z#()Lj8lVDP1e8(Q^09J8R!Nkfs=s=-~?bCXa~lC6fjCr
z&Yi#ra0gHVZU=^e+khe96Tl#FD^LV(0j>dV2CfEf0-giB0eCj>I^bErtAS?%uLPa}
zycBpk@M7R9;9B5mzzXnGU>0}^Fa}%+TmxJITm?KCxEy#Aa4~Q>a4zsfU;uamkO3|O
zP6I9lx`D?7Cj*ZIP5>?e+JTFK6mStmId%dU0(Srx0Jj5=1#Sb*2R;Ej2DlYC54Z(5
z7q}TX2e=7%H1G!CY~XdkqkvZfj|5%`90pzr6o3~41HiRFKd=Jy0kc3aFb3p-Yk*iO
z*0Bo60ha?=;9?*HoC_QR27rS=1~>~i4R{354V(#_44eU+0GtlA1E&Eg;Ne&fXD9G5
z;11wa;CA4lz-_=ofKLFY0Jj1U25temft!H`0XG2;1l|BV0C*j6f8f=?{eV{jKL)%M
zxG(Tx;AG%h;3QxLxDPN3oCu5o_Xe&3?gd;0+!MGQxCd}CZ~|~H&;<+t2Y?K)A28(+asCsy3HT4-4Z!z-*8%?yyc&pggl$&>{|3Ai_%85b;7;IL;5)zy@NHlg_!ck*
zd=t0^_y%wl@O9vF;A_Cez#YK3z*m6*;444|_*dXG;LAWa@Fn16;ETWsz!!ja;C3Jd
ze4e7LJAr=z?f^aq+zxyexDEJc;1j@SfLnoo0&W3r18xTX5x5EXH1G!CQ^4zhe*j($
z{5|kW;O~H!0-pq4415B(7Wg=@0(=aZ1wIOl0e=fz1AGLy3iun~a^S6q26SxidE8r8r
zUjnxR?*MKAZU$}!{sOoOcsuY0;BCO`fVTp#2Hpa^5_mK4Qs7O%i-DVfYk@ZcE5M%v
zv%sGLW5AyR*8qP4Tm}3wa5?Ztz{S8DfOCOA1O|XV05ZVu1E&G62fBgZ15O5h7dQd<
z9iSa}9gqTk8%ta71bz#+1NcqgcHlRF+kn>sp8$RxxD|K}a0~Ei;AY_0fSZ6{1>OMs
z3h+AMmw{IUzXZGzcopzc;1_`x1Fr@60Pu4_2KZUvG~gvbH}EsS$-qwoCjdVMv;!{&Qo#R0dwM7ElfWIoPXM7+3>p4_pJx0apRDz~#UUa4|3qoC{0=13($b0F%IJzy#0@i~}bFW55Z(
zD9{d!04bn^r=WKN!@wQD5O6y%2;2q~flmO}0Jj2H1GfOr0d5AK4cr7g3wQ(YOyG6E
zGk{kEPX}HJTm`%ocpC6x;Hkj1z*B$~;7VW?xB?gho(xS+GQgR@X}}pkH*h*|GH@Dj0`PF49e5a!
z0#2nUYA5he;11v+!0o^(z-_>TflmP4z^%Z8fLnkE0yhH>0B!>A54-`mAMiTh$ADJ@
z_XS=FoD94aI0<+$a3A1W;6z{rxHm8h+zS{3?%Ajxf4qSgHE?|c*EMi$121gg1r0pE
zf#)^w+y>TEM18#K?7f@n@4ba`{n7PX*FCPEpw<5s*Ll6SxZc8w0Jpn7@A|LaTgLwz
z?GE(J>+$u>>~Z%@>~UKDX?YVZ-={5)ShiShvwYj~WwdxdX*t)DwMdpTEGJrywFs6$
z%ORG@mVOkHce-Eh{!{mNyT8_bdH=6nZ@XUZyMlh6zMJ01d5`mU=TDs1Iltn}IS+N*
z;n?7a+uyVAus>(N(|(iv2li|2SK2>oUvDqlMcY%h(`_f%=G!J&w^<*xey8{PUYq5|
z-H&zO*S)#>XWgGdX?I#CVWbm}Jam;+TAvNoRU))VXn`E75Sk}6M`%{1+AqrxnkF*Pz+a%{05A)jL;aNQ9>hxN`!_9#jwoCZwL}961s-a)r6iy=-DdOj5>>C&m{B=
zLQf}j6``jQdMcr(5W14k6@;El=t+bwC-g)@Pat#|p-Txpp3vh6T|($$LKhLbkkAE$
z9!uzaLXRPI9-(syokQr+D%DJ#O|nN3dL*I4gbIWP2=x={Bh*VMPpF4bj!>3RhR`9E
zYHxB-rOlA8pcVV`xU^~C4eAJu-rFxy2W#}puI1D|8vTOnI*Qil=bL{<
z?TT^ywSLB^y)lkwG>QtE<8h6mj^?;uqo}4i?$9V|YL1_46qPl{_cV(7n&WDXqRQsj
zpi$J?9G})GDsGMoHHx~Mqo7e#-yCs`q6X(UN277j6&gi7&aqgdQP9~Ml|VU-hCvV4
zXb5zFjRryY(5MJ%)#w_EvcIQM)a&d!G>WR7{W*=Ic4z;cMp41DKcrFA@$7eM6xBTY
zO&Uc_&;A3AqOxbdR->rz*{{?ns(kj(Y816T`+AL{;%6^w6m>tltWi||?4m|d1GJy2
zQB(r$$7>YzK>J*cqAFuXcU!6+kB0pK4}YR6je&wERCX8X`7-^
zR4i?iG>W>Vtxuz!TV)ZPR+MMp5Ck{z9Xu
zb6S6@QB*sv-_a;)p4P8w6qQfw7c`3cr}biuq6%s~U!$mnTJsu3MbsMADC(ltvown8
zsP!a`qDE?6s8KiQks3w4)QWLrj7Fv%*4|%g6cyLr8#Ri$Ywz_M-4pa0jqU-uQKJ(;
zKci6>=vs{qfMV)|`qukF6B_LU#lluuw8571~Y
z=$;zw0kvt=f;RBqHQEjOnnt@o|DsXUPI`V{r)ob}&sL581N1J9z7Kk{M*j}_Lyf)%
z`VEc#4fKl|eHZj|8r=zckw)JEt!VUZ(3D2s0u5^PP0-Uc`UdDyjlK>#PouAa`ZT%&
zbf!jM1$Arm70`(q{VS+bqc5WY{7;R(1p1~%Uj%(Yqc4CytPH#e-8R9js6UDlSY3E`hAW51oZ0~{W0hj8vPOIB^tc}be%?j2wKwU
z4?vR|{XXa#jb0CWiblT&dYneT3pz)m-vQ+{dL8I=jeZ;SK#hJ2bT5s56V$HJZ{YNP
zU!&K8zOK=)gFdg(Ye4^?(W^lp*67zj@7Cy7L2uFMS3qyj=$AmhsnM%IuhQrjLH}E$
zSCTr{$4Oo8B0|@b1J)6`me32yu@{i+`GlTF=(*(B8lhEZ_W@LY_W;#B;CjvVjO!8C
zovxp`zU8{Yb+N1JO1RE+E$to{|8JB#u&~?TJ*|7+Zf6(nNzI=D%R$}E=RmrSP{Wp#
z+N|gwY966B4Eldd!VViD&qT-2b0jm?#dy#Mv!X6SXL?6^!BwT>-FC;t`
z;o~Hnk8leKk3o173FjgFE(zx%{1OT0AiRWxMA84+#%Pcqj=ELpYIyQxRH8cqm%L
z?~w2igxg6t1>ut7(MlW-q|Q%N`x;XWkX8=;Mad!Zq`lZ1OBe1U{}Ap9K(Cm_6!gf4`)
zl5haw_et1~@T(;3L-^k$q!F$qp%Y<_gbsv3650{2AfXN6u_Ux2^pda_;bA1~K{$zo
z7KC;ZcB3i$E(yC3zDPpcF|EIEhH4wodOr#Of$%mGzK`$+B>X$VuaOYXeNX(W6G;l3pN6GA5mx1l-w9tr=5@MRJ{jqoWFK85f>68-_Q%T{4TaDaqg
zrYP#4B>WP>9VEO8;h#wOMTEa0;gtw~MZzmuTTdTv4KHd9*SCi2TEn%i;f1Z?1+C%v
zt>JmC;km70tu?GVG2NrT>v5{a>$V_<9On5*Q
z^rE&}VSS@mwMpOi?8sZ@7&j|uZ@J#Mf2WgBLL2$%PC^mFU7dsigzGyA3WP6o67mpg
zorD|&v6GO6u(*?ufiTobNJH4ClaPWX?{^YpGp~{m?(6i51cV=V65%Slda6
zLcl|!ja5B#UsMFb37rH9g13_phOmDpAq2tFNeH54{9-3Tgz#`DVGV>$orKj8zSc=N
z2f`;i31>q{brQ~ku(FeICWIq931>i<(n&ZSf}@kL3XS3&orKdMJl;t-6~gVEgi|1V
zvy-qA!X=%A6%dq8!pRWG+B&+za1w-honEmV!n97pi4a_!gcHyne!G*f48l{LgryMf
z>?9ly;d`Bg;~;##lduFrwUe+I!kSLPA_$8*2@4?%b`lmqnAk};7ER&5cM|49_;V-W
z7zp=v66QhpQ72(8geyA`nQ4sh}!jTa6>m&?AXx_l|cLM><
z;}<%;A^>4)C&3Tl#!i9{!dE*9UI?G)B=8XAPJ#!*icSItVOTE&8h14o!oi&c27n>%Ak6I~91h{|PQqaj
z2095-(I|ealW-`6KXeiff$*zN!W0PK?Iauw;j&JG8$zX%a1eymorD7+EbJs40AW@q
zVSfmFcM|qPd-%Oh!p9&yqZj;*a7bOm;~X9PQpGAF6bmogdlYi_J**u
zldu;APbXnd2p{Vt>;a*xlQ02o3&;#MtPJ#u(wVi}+2%qjGbV0~<6427@
zUe!TRJ0rX2bQ1mn;jm7^`w;r|0-{
z{C~ua@$vuh@&D#cL|x!veEh$8YTS!f_4xRIW45WfxW)MRf3s|S3+?ss@&A#T$h*t<
zKYHh||6hn2xVQiEzH{hlj@RwC+CFJr(|btI3=7@$Af*5FXMFR!|GmxcgpFmx_C4X&
zl~aw|YYdIuJ)`R5q?~uy>l^g59>e%4$v5xi>-XDOYTdpE%lGOR>@|8fy0`Dq+2hsk
zM`p-3?ZNgv#n$b6JP9!t7qB3EG#<*uN6q#<`VYwHHexg6n;(3=u(4n`sSL}`8w%5bquI=y#7a%YhC#Nqvjf8Qr3{yyOuV%X4stNBjy?x(CrqkD{E?A?^?>>
zn%KGrz(>tBe_J)JeqG~`*SjVgTr=!V^igxor`s-FU)GRsy4H^+kR3g5QEIy%eE{F+
zi(Pnqd>HkCh|nXk0frd>X@PaUdhIkN{kWm1tuMeL?UhGC9@?zBPdBu&INPHz}Pg8;X9Q)ZcxoP
z%|k71s=+*kt4J})UlZA6!kggQ%p2>Mdz3IVGYl<=`X#NA*PC~!!92r$?rr9k0$w)9
z=lueck0sOcm@9eAuou%u31ynCj8N4sLi}FUh+I>AFzXb
zpmppzFii4#^QIWgD^z(dRPts8zc=LxRNKrOI?&hu
zM*r{n@94j-|G)c7{b%aZJjM`ryB>^`!4O1GnH2P_&*
zKW6JaR9b}k2)wp1VA)J4nF&QRptTYo=me9R~`*Z(sUCYuS9%!GZ+go$Rt-e$sHX2PCk!X9SA1T(>9
zCJdMf{boX+nLwKfPBXz_CfLmco0(uW6MD^r9y7sWCUlz#U1kDhA~^qPCj7%pc;6^A
z*Z(sU-ZK;aW+uFAChRm5-Z2y2HWS`56W%lv-Y^qhHxphn6Ly#hubK(3m
z$lKQ6G^R&)u~FhP@tj{E%T=AEwsliFw?H*)UK4m@wxLg?!PhFOd!ga{k(fHLp2PJu;7E
z_a3R{hOyU%$1}(qMke%m;u^EzD7dztZW!MiW0gu8(;LFckdWEsk%=Kb$5!N!oQlYq
zWYOapk}7z(AT7$X)R_{>DDjMGeFU-T8S=!lg&U59XZnXSJn=##%IDIRbe1pXO8-eo
zGdz?b%cWvS46tdr%I0JRk7&aQp}Yrf%fueGMYFPj4A<+Nqc;3
zDZ%E#qwt(%F#_A!Gd17zZ2iIw0mC_)sAu%wslq4cEO;
zkgF9HOfxCRGs$Y9%{-H_?4fAVBQQBBs_<3bUrvvz>t5X(hQ|Y>t&=D8dE%L{fj1O5
ze_8OQL%BjKDysACcGa`7akgqEc?LOu>k1wEeAB#sEN5%)T7>PK?O(8+YF*q*TORDb
z42kid58?psyP$=&|m
ziw;J1n8Q4>KC40Kyn!er^3ngOZoQKV2C0~JQzvh2{vSz5a
zWyGwHXF{0*U(FY}-M^nxbIdmwFx&w2`KFVox1DLY}+A%&V
z=D`?_A4^ZSHv3^-Gp{%A41;;eP$ei!2_~70RU?)5ft@j3Z}SXuzG03>UvX;>yFRZs
z?{tHC@k&YXG5HFgkVDa`wlZ}nBg;~#5YMEQp)q~4g-IAv^bYz2W62}=rg{DLH!=P{
zjLmzkwO+>ZEsXzTSK#rVfA0WRAF`v^>8mNZ3}4KoQmIm~rW6#V?Sl6KZXKg&V8@Tb
zHq3hl1OC>Uu|D4{Z*a|KaIH{Qy!k*bS42OFPe`@7wu`rkQC3YEamO#T_R{I|P1i26
zZnU~)u5YQP1chog6)lP^BZkF9n`I<_}PQ4c|n7Pp6bnt{UKFCC(Oh
z`;NY*VOlKy5MW$HrqJE}JZJ*e%Cbtpz&t
zGmQC?hb4>yhTTx=rZ)0=^C*LP!D=PJClm~iE0J{SLr(@8wbwi>T7M;=Oi9N!G
z3k+pTDoNQ`$(u{msxeM(A2{2^I^t+8TdH}1LDujXyk?%3Z<@ERxZ(WP&8;|2$SFlk
zJ5JWbtn8_mEk!C6;_2vaKGAcebOe0H%ehvX)O_=U_uG3YD|J0(y}9?}JttTucRhn(
zH~m@9Z7{B{?(^oDTA&nhHJ*1
zYuJE4P>k`pTFT4RYQ8qtcCl}9l)C0MbZ3varsnmo8F#MnBtpevF)Ze!DpRYrPXQd`
zt{G+%HC;nq@0xMv8Yw5%#JIp_Q(Q=tK6F3!Xf11eYo9{XHRScK8F#Kp3ApD6#h{mo
z2XeLlX!6h~>PZ}Nhd1+{65g&UHn@92AC(y>@3hZ%n*C6G%;+WW*lz!_@jiD3(=
zWV%x20=_^v;1hQHo|vX%CgqRUmp`}n$W64kbT
z*wI^NTL=9}S=J5Qua9hq8JrgQY~CLdqkvc{)7BdNAay}QnT-o+F&-|I^1hrB4f1VH
z8@47LJy!?A6Prz^ktbz(;fAQ;>fjGZyeMXba1JxPGVNWByVzT%-qZiB)4TUWO-;u*
zldA*rdM6_WCv&xQFybp`xh$iu4EsNLoo&B5j1ngeTQWBtMqclvWN=cFg8o!A8xJdf
zA(&}@JnX|?XZ`7NCQvBGv4C@-5>Abpv)i~b{fLulzUgGY{Tj6YU!iPC>%l!&Sj6s`
z2*&IWCMlhVa-;9O4i{_wWJzLzY|NjJwO`A|^i&ZP2|<1K(#y1UBsBBQqJoZub77u-
z!b2*S%?6@ntWp~Dq|@!4*SpxkIQklQ99D51^m*H!XK3d2uGI{#RdOj`Cg{aLAIrpZ
z|IvW)=xW?)?ijXPLK$r4o334C-B2}5cqpf8LNp^wk-Q)TxDVZ#-J#O)QJe5E;yB;l
z5mx^u)O_=sz>`fC!>zU&!Z1B6m%VrdSQI;SPVD-;Vng+Bv`u&*PPPs^>+{4(ZbR8{
zB}hfGIkBXq5(<~d;MsIB8z0ioQyk0JPs}T3+a4@I`P1@E*U&kkK1!j#>(rv2JeNv@
zy<*%G=Givac5#}>sJ!d=2L1ll-8rDQe`TIA|48>e&`8{qjOkQGf7=~r_&`*
z%Ms`wmT*GNw&PCFmtimKj
zfBS^?574?DjjkEy)-+v1UhkT5H`tt~BqaSAE|4qNqQUkNyARM$H7Z?e9seh;=|=3F
zmi?)7x{svV*S-I6E9?JX>?7c2@dPH*X)gj@M@C<$``VGcaam4#30~FJvcRlfIB@d+
zuitC&ba#3uEeqG`-$~2DwR)jt;aa`WvT&_l*jK+0q+V!QxK=N;EL^J>+7_-IS#){K
zEDLPw=~^)IMXB2?k9@!ucU1v1ZqyP;x45epTHMtOE$-@tHg`w<#%Ny_Slu*J^=f>5
zb8Z&P%u>Je5lhC`H*Z=pa;p8WtZz>vPgkz?xmm7p(XZ&MANosQ*-R*z2}LuZU?wPLLf%Xm-+riB+DGnD
z@xqW7k574g?rFy_Ub$@P5pFr2l-!G?P%<;!JwKMu
zq@*F1@eN{wVEnEXVnocx8!tKce0^OAj(CQ>0j#OfI{2c`H{UlVN-Qz--!fsrFXuc#
ze^89X)9pi6A7Joh6!k=E?sy0BY+lPwr24$xwZ#V4@<~3!h9Vg$6AZ`r_Tka7IndX1
z$J08(qR$i87H(Js*YvaC6IBUoOl2jd$d@Xm|7fwaQ9jT&;uz1h4m9iY#7S<$LPPnh
zv0N@8M~b1Kj|s%v7qA+uXD!1I7F`l&~-*gSb^P3H>m1;~ekmCzdGKVEwJB;w{
z`t*=db*)t_
zFPCbbILU38ZzzBHL`E*AQsHnp?#T-6%MpA4Cr4NL!!Ar%GR@OE>Zk3(q~-Ol9b<5<
zQYeJ7Y&KuRkZIZ9K0Rct-fPvpt5kQ|&e~?a=^7>_&NEC(WZ7I!D#VMPe8wM0Ds9b&
z4^Y+&XYEIAQsPKC*rj<^)nC&h_!4gA!Tw=Po(|&`2otW;hQkD
zU3UBV|4k_6MI$E)Bcy9%E>3|Ep#)i++Aa-OY~Yuu%-DIvVrMqPKqGC6f&&
zaaD0q){YyVbvu{Wl}9Piy~kFgKpq>nsf(BdTW#7W_jNpMm>N0E>CnP@&y
z%hxI)K2iR#H>TZij^eJGr?z0Y4#lvM*PFMG!8~s
zip|4@6AVK(duo=vMxL0*Tr$yc-Sa3RB^YF+lwa}|OYP5Tm~8eml#Ru5o^syj4T)K=
z$GiLNSF~#djzte4X0( vYrC^TadZlD!QDE~X^IT%?rEh5dm{cr0y@wQJ@vs(BsG
z)@ELB-d+ats^KCV6sxRINC)$5`)W43ILmXio~^u~?ybXn$pgq*$s_ruc@$+eninbg
zy^OCa=Y1iOi?%<(w~G^tN4I&5Zj}tZd1}6C-g@DZJq#sJ2}apMv?QSLWn=O7o-UJ1
z-B`I#s3nXaWIe|F1j*~on_w^xyD19AYETOKWr6p%H=W1$O;$C@GwAmiYbYeIH_v4-
zFDGVvT)C8pCHRn6X5(7I}5HX8Q^(|I2%9AQCh
zYnT~5YCa~mTdW$nOZQW?3~__JZpALWJ!-ybPru_K%1#|l*^jn)ti!$gS)T5`rYnqu
z`mYfC
z%AA=@!mnhKQmm$kaj|{fkI{SQ*k$vuA0Ve|+cr(r@_O^mH(dP002j(}WpBt^uH{SZ
zOMQ&qJI5}Y$79Y$C-bzt-aO-a7qwbY2u8583>T;-easl@7}!EQ9ZH#31zH@v4dwAVK;Cg%1eAAwF#mj50OXemcl~75D
zaH&uvDtP#M(c1l&%x(FAx^+;D`Q`^-Z@auYFmuU7bVkqCI-_~X%X)IrTA~>ChyiWg
zK{1?)W2d2d4|F+>Zs@G2DCJB(oW)EgYRj&4ME|ePbkcuhk8W`YW4>v{Mg5m!aR{Zc
z+}%0_9rLlEBnRRVZ!sH96@6{3@m)Jdx-Q$D{Fx%<^O^jyQbCN%%9PSVS$#`BWmE{SqH?5-CJw^}MP`FKrKx1(|wP!Ul7
z-yM|PI9~>*G^d^s$%HIsF>&%rikQAyhluMjL0Md#r84C4={W&N@gCY?)Zk$-oT(w
zKsU?Jm?y3=m#Yg?=m*MDk$BFVtA>0Lu9i-RcC|(_lxbadV)yAV-i15uh-ZGGwf&&a
z6VHUpjZ07zi^)SV*ZH#Z5$Da$Yn&H5GXqn*U+jLkdsFwS
z>R#D>WcQSAN7s(7$GdLt`exT9T}sy(UGwTa6eIuF^~;fY+brLFGTV~XKfH4*(<_kLdZ-AnhBzru*OVS
zZ6+)+6Be5Zi_C#?f54X2NAyC=>LYq#lG)pBS$#zRik8(!^upd|ziKZmywk70
zC68P(_B4CN9%jM>Gr?sh444TmOFQU4N1xd%XfwfSCOFIlyP04!6Rc)JubI$eCRogb
zmZcr^mUNlDf-(`h|7j-t!zeV?m9U&`CY)s^v^;gKzTJ^u)$-J}UTAshS}(Lbb*&d#
zp1RfxEl*wRg_ftT^+L;2*LtDlscXH^^3=6nXnE>dFDy6n>O?c)1T$fonXuGMINnS+
zZs6$FI(g#)(EYyg6(hpm&4l;Nguj^y@0tla&4hQ%gtyIvx6Fh$&4f41gxAf4*UW?+
zX2Ppx!YgLNU(JMu34bsXes3oH&WU#OtG%~SuD4t-y8h_;
zt?M4w?XI7=u5*3G^?BESxz2OtToKoquH~);u3^`Z>rmIet^t>2VCTSJ2c8*teBl0p
zI|hC}@V$Yn2R00Rdf>u=!a#iBoPiYsiw9;8a07=A>_4!_fVKa<{vG|#_5ZH_q5eDj
zZ|eU+|F!*B_J6j2eSf)M?ic${?LWSMZojvGM*l(md-pr~{?YeF-}b(z`hL^*>%Lq2
ze$@A^zAyD%+V_dRT3@Cw+;@8434QbX0)4akru0qf>+9>H-=<%px6zN%_tL*WU&43j
zuhL(jFQ(6@^K_Ixi#~~7NFPZv^i=v|w2SU>zUzF&`Df=7&Ig>obl&K^9{mg(ou6^8
zbrzio=W6Fl=Mv}9PLFe%^8n|bPMhQJj@KN2as1w~)p3{OX2%a5-*9}<@j1suj*27Y
z2s%!4EOpFt_#87GZpTE2)BaEUoAwv%Pum}{Z?WHI|FQks_AlEnvwzZlu03m)>}S|d
zv>$61?1T10?33;N_HNrdwwG;xvOQ+I&$ik2GuwA#wYvtlzhO-Fk)f66-o^$(ppTv7TZ*&N|1+Tc=wO
zwC-iK_rBlzdhheSf9QR<_ilW><3HmLj5{#49B^7Db=QYU=?bBdCUrha=)Ht~nb0o~
z`gxVsd$j2?p(R3#gcb-@2+b3kBQ#5BhR`&jDMDpJlY}M+jT0IpG)kyMXqeCtp+Q1L
zLe~(wn$U9yJ)6+82tAX~GYCDM&{c$LrvX)I%soC`%|q=n$cUgw7)L2tsEPI)l*Zgia&$a6%6wbSj~T5_$-sQwTknP&c6m
z5qcn@2N1eHq5Bc~F+%qxbTXln2;GO!iG=P==w5{GN$4JgP9W4p=m4Spg!U0i6Y3
zL8zTj8=+Q0dkO6!)Iw-Cp