diff --git a/.circleci/config.yml b/.circleci/config.yml index 4ca2fcb0f1..e452a6a2f4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,7 +31,7 @@ jobs: cd .. git clone --depth=1 --no-single-branch https://github.com/mattermost/focalboard.git cd focalboard - git checkout $CIRCLE_BRANCH || git checkout main + git checkout $CIRCLE_BRANCH || git checkout rolling-stable echo $(git rev-parse HEAD) cd ../mattermost-server make setup-go-work @@ -110,6 +110,20 @@ jobs: cd mattermost-server make app-layers if [[ -n $(git status --porcelain) ]]; then echo "Please update the app layers using make app-layers"; exit 1; fi + + check-generate-worktemplates: + docker: + - image: cimg/go:1.18 + resource_class: medium + working_directory: /mnt/ramdisk + steps: + - attach_workspace: + at: /mnt/ramdisk + - run: + command: | + cd mattermost-server + make generate-worktemplates + if [[ -n $(git status --porcelain) ]]; then echo "Please update the worktemplates using make generate-worktemplates"; exit 1; fi check-go-mod-tidy: docker: - image: cimg/go:1.18 @@ -501,6 +515,9 @@ workflows: - check-app-layers: requires: - setup-multi-product-repositories + - check-generate-worktemplates: + requires: + - setup-multi-product-repositories - check-store-layers: requires: - setup-multi-product-repositories @@ -527,6 +544,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -541,6 +559,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -558,6 +577,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -576,6 +596,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -594,6 +615,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -608,6 +630,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -660,6 +683,9 @@ workflows: - check-app-layers: requires: - setup-multi-product-repositories + - check-generate-worktemplates: + requires: + - setup-multi-product-repositories - check-store-layers: requires: - setup-multi-product-repositories @@ -690,6 +716,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -704,6 +731,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -721,6 +749,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -737,6 +766,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -753,6 +783,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 791a45ee95..98d195af40 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -34,9 +34,10 @@ jobs: debug: false config-file: ./.github/codeql/codeql-config.yml - # Autobuild attempts to build any compiled languages - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + - name: Build + run: | + make setup-go-work + make build-linux-amd64 # Perform Analysis - name: Perform CodeQL Analysis diff --git a/Makefile b/Makefile index d26ae063d7..e305cf3a5c 100644 --- a/Makefile +++ b/Makefile @@ -149,18 +149,21 @@ TEMPLATES_DIR=templates PLUGIN_PACKAGES ?= mattermost-plugin-antivirus-v0.1.2 PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.2.2 PLUGIN_PACKAGES += mattermost-plugin-aws-SNS-v1.2.0 -PLUGIN_PACKAGES += mattermost-plugin-calls-v0.10.0 +PLUGIN_PACKAGES += mattermost-plugin-calls-v0.11.0 PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.0.0 -PLUGIN_PACKAGES += mattermost-plugin-custom-attributes-v1.3.0 -PLUGIN_PACKAGES += mattermost-plugin-github-v2.0.1 -PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.3.0 -PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.32.6 +PLUGIN_PACKAGES += mattermost-plugin-confluence-v1.3.0 +PLUGIN_PACKAGES += mattermost-plugin-custom-attributes-v1.3.1 +PLUGIN_PACKAGES += mattermost-plugin-github-v2.1.4 +PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.5.2 +PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.34.0 PLUGIN_PACKAGES += mattermost-plugin-jenkins-v1.1.0 -PLUGIN_PACKAGES += mattermost-plugin-jira-v2.4.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v3.2.2 +PLUGIN_PACKAGES += mattermost-plugin-jitsi-v2.0.1 PLUGIN_PACKAGES += mattermost-plugin-nps-v1.3.0 +PLUGIN_PACKAGES += mattermost-plugin-todo-v0.6.1 PLUGIN_PACKAGES += mattermost-plugin-welcomebot-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.6.0 -PLUGIN_PACKAGES += focalboard-v7.5.1 +PLUGIN_PACKAGES += focalboard-v7.5.2 PLUGIN_PACKAGES += mattermost-plugin-apps-v1.1.0 # Prepares the enterprise build if exists. The IGNORE stuff is a hack to get the Makefile to execute the commands outside a target @@ -359,6 +362,9 @@ telemetry-mocks: ## Creates mock files. store-layers: ## Generate layers for the store $(GO) generate $(GOFLAGS) ./store +generate-worktemplates: ## Generate work templates + $(GO) generate $(GOFLAGS) ./app/worktemplates + new-migration: ## Creates a new migration. Run with make new-migration name=<> $(GO) install github.com/mattermost/morph/cmd/morph@master @echo "Generating new migration for mysql" diff --git a/NOTICE.txt b/NOTICE.txt index 3f186ef717..7b11691cf2 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,12 +1,13 @@ Mattermost Server -© 2015-present Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information. + +©2015-present Mattermost,Inc. All Rights Reserved. See LICENSE for license information. NOTICES: -------- This document includes a list of open source components used in Mattermost Server, including those that have been modified. ------ +-------- ## Go @@ -47,22 +48,15 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## Masterminds/squirrel +## Masterminds/semver -This product contains 'squirrel' by GitHub user "Masterminds". +This product contains 'Masterminds/semver' by Masterminds. -Fluent SQL generation for golang +Work with Semantic Versions in Go -* HOMEPAGE: - * https://github.com/Masterminds/squirrel +* LICENSE: MIT License -* LICENSE: MIT - -Squirrel -The Masterminds -Copyright (C) 2014-2015, Lann Martin -Copyright (C) 2015-2016, Google -Copyright (C) 2015, Matt Farina and Matt Butcher +Copyright (C) 2014-2019, Matt Butcher and Matt Farina Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -82,220 +76,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- - -## NYTimes/gziphandler - -This product contains 'gziphandler' by The New York Times. - -Go middleware to gzip HTTP responses - -* HOMEPAGE: - * https://github.com/NYTimes/gziphandler - -* LICENSE: Apache-2.0 - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2016-2017 The New York Times Company - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. --- @@ -505,6 +285,223 @@ Go package for fast and reliable abstraction of browser user agent strings. --- +## aws/aws-sdk-go + +This product contains 'aws/aws-sdk-go' by Amazon Web Services. + +AWS SDK for the Go programming language. + +* HOMEPAGE: + * http://aws.amazon.com/sdk-for-go/ + +* LICENSE: Apache License 2.0 + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +--- + ## blang/semver This product contains 'semver' by Benedikt Lang. @@ -538,6 +535,315 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## blevesearch/bleve + +This product contains 'blevesearch/bleve' by bleve. + +A modern text indexing library for go + +* HOMEPAGE: + * https://github.com/blevesearch/bleve + +* LICENSE: Apache License 2.0 + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- + +## cespare/xxhash + +This product contains 'cespare/xxhash' by Caleb Spare. + +A Go implementation of the 64-bit xxHash algorithm (XXH64) + +* LICENSE: MIT License + +Copyright (c) 2016 Caleb Spare + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +--- + +## code.sajari.com/docconv + +This product contains 'code.sajari.com/docconv' by Search.io. + +Converts PDF, DOC, DOCX, XML, HTML, RTF, etc to plain text + +* HOMEPAGE: + * https://github.com/sajari/docconv + +* LICENSE: MIT License + +The MIT License (MIT) + +Copyright (c) 2014 Sajari Pty Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--- + +## dgrijalva/jwt-go + +This product contains 'dgrijalva/jwt-go' by Dave Grijalva. + +ARCHIVE - Golang implementation of JSON Web Tokens (JWT). This project is now maintained at: + +* HOMEPAGE: + * https://github.com/golang-jwt/jwt + +* LICENSE: MIT License + +Copyright (c) 2012 Dave Grijalva +Copyright (c) 2021 golang-jwt maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + --- ## dgryski/dgoogauth @@ -699,6 +1005,38 @@ SOFTWARE. --- +## francoispqt/gojay + +This product contains 'francoispqt/gojay' by Francois Parquet. + +high performance JSON encoder/decoder with stream API for Golang + +* LICENSE: MIT License + +MIT License + +Copyright (c) 2016 gojay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + ## fsnotify/fsnotify This product contains 'fsnotify' by GitHub user "fsnotify". @@ -741,78 +1079,59 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## go-ldap/ldap +## getsentry/sentry-go -This product contains 'ldap' by GitHub user "go-ldap". +This product contains 'getsentry/sentry-go' by Sentry. -Basic LDAP v3 functionality for the GO programming language. +Official Sentry SDK for Go * HOMEPAGE: - * https://github.com/go-ldap/ldap + * https://docs.sentry.io/platforms/go/ -* LICENSE: MIT +* LICENSE: BSD 2-Clause "Simplified" License + +Copyright (c) 2019 Sentry (https://sentry.io) and individual contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +--- + +## go-mail/mail + +This product contains 'go-mail/mail' by go-mail. + +Actively maintained fork of gomail. The best way to send emails in Go. + +* LICENSE: MIT License The MIT License (MIT) -Copyright (c) 2011-2015 Michael Mitton (mmitton@gmail.com) -Portions copyright (c) 2015-2016 go-ldap Authors +Copyright (c) 2014 Alexandre Cesaro -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- - -## go-redis/redis - -This product contains 'redis' by GitHub user "go-redis". - -Type-safe Redis client for Golang - -* HOMEPAGE: - * https://github.com/go-redis/redis - -* LICENSE: BSD-2-Clause - -Copyright (c) 2013 The github.com/go-redis/redis Authors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- @@ -1201,6 +1520,277 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. +--- + +## go-yaml/yaml + +This product contains 'yaml' by GitHub user "go-yaml". + +YAML support for the Go language. + +* HOMEPAGE: + * https://github.com/go-yaml/yaml + +* LICENSE: Apache-2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +* This package includes the following NOTICE: + +Copyright 2011-2016 Canonical Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- + +## golang-migrate/migrate + +This product contains 'golang-migrate/migrate' by golang-migrate. + +Database migrations. CLI and Golang library. + +* LICENSE: Other + +The MIT License (MIT) + +Original Work +Copyright (c) 2016 Matthias Kadenbach +https://github.com/mattes/migrate + +Modified Work +Copyright (c) 2018 Dale Hui +https://github.com/golang-migrate/migrate + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + --- ## golang/freetype @@ -1383,20 +1973,20 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## hako/durafmt +## graph-gophers/dataloader -This product contains 'durafmt' by Wesley Hill. +This product contains 'graph-gophers/dataloader' by graph-gophers. -:clock8: Better time duration formatting in Go! +Implementation of Facebook's DataLoader in Golang * HOMEPAGE: - * https://github.com/hako/durafmt + * https://github.com/graph-gophers/dataloader -* LICENSE: MIT +* LICENSE: MIT License -The MIT License (MIT) +MIT License -Copyright (c) 2016 Wesley Hill +Copyright (c) 2017 Nick Randall Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -1416,6 +2006,79 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## graph-gophers/graphql-go + +This product contains 'graph-gophers/graphql-go' by graph-gophers. + +GraphQL server with a focus on ease of use + +* LICENSE: BSD 2-Clause "Simplified" License + +Copyright (c) 2016 Richard Musiol. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +--- + +## h2non/go-is-svg + +This product contains 'h2non/go-is-svg' by Tom. + +Check if a given buffer is a valid SVG image in Go (golang) + +* LICENSE: MIT License + +The MIT License + +Copyright (c) 2016 Tomas Aparicio + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + --- ## hashicorp/go-hclog @@ -1820,405 +2483,219 @@ Exhibit B - “Incompatible With Secondary Licenses” Notice --- -## hashicorp/memberlist +## jaegertracing/jaeger-client-go -This product contains 'memberlist' by HashiCorp. +This product contains 'jaegertracing/jaeger-client-go' by Jaeger - Distributed Tracing Platform. -Golang package for gossip based membership and failure detection +🛑 This library is DEPRECATED! * HOMEPAGE: - * https://github.com/hashicorp/memberlist + * https://jaegertracing.io/ + +* LICENSE: Apache License 2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. -* LICENSE: MPL-2.0 - -Mozilla Public License, version 2.0 - -1. Definitions - -1.1. “Contributor” - - means each individual or legal entity that creates, contributes to the - creation of, or owns Covered Software. - -1.2. “Contributor Version” - - means the combination of the Contributions of others (if any) used by a - Contributor and that particular Contributor’s Contribution. - -1.3. “Contribution” - - means Covered Software of a particular Contributor. - -1.4. “Covered Software” - - means Source Code Form to which the initial Contributor has attached the - notice in Exhibit A, the Executable Form of such Source Code Form, and - Modifications of such Source Code Form, in each case including portions - thereof. - -1.5. “Incompatible With Secondary Licenses” - means - - a. that the initial Contributor has attached the notice described in - Exhibit B to the Covered Software; or - - b. that the Covered Software was made available under the terms of version - 1.1 or earlier of the License, but not also under the terms of a - Secondary License. - -1.6. “Executable Form” - - means any form of the work other than Source Code Form. - -1.7. “Larger Work” - - means a work that combines Covered Software with other material, in a separate - file or files, that is not Covered Software. - -1.8. “License” - - means this document. - -1.9. “Licensable” - - means having the right to grant, to the maximum extent possible, whether at the - time of the initial grant or subsequently, any and all of the rights conveyed by - this License. - -1.10. “Modifications” - - means any of the following: - - a. any file in Source Code Form that results from an addition to, deletion - from, or modification of the contents of Covered Software; or - - b. any new file in Source Code Form that contains any Covered Software. - -1.11. “Patent Claims” of a Contributor - - means any patent claim(s), including without limitation, method, process, - and apparatus claims, in any patent Licensable by such Contributor that - would be infringed, but for the grant of the License, by the making, - using, selling, offering for sale, having made, import, or transfer of - either its Contributions or its Contributor Version. - -1.12. “Secondary License” - - means either the GNU General Public License, Version 2.0, the GNU Lesser - General Public License, Version 2.1, the GNU Affero General Public - License, Version 3.0, or any later versions of those licenses. - -1.13. “Source Code Form” - - means the form of the work preferred for making modifications. - -1.14. “You” (or “Your”) - - means an individual or a legal entity exercising rights under this - License. For legal entities, “You” includes any entity that controls, is - controlled by, or is under common control with You. For purposes of this - definition, “control” means (a) the power, direct or indirect, to cause - the direction or management of such entity, whether by contract or - otherwise, or (b) ownership of more than fifty percent (50%) of the - outstanding shares or beneficial ownership of such entity. - - -2. License Grants and Conditions - -2.1. Grants - - Each Contributor hereby grants You a world-wide, royalty-free, - non-exclusive license: - - a. under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or as - part of a Larger Work; and - - b. under Patent Claims of such Contributor to make, use, sell, offer for - sale, have made, import, and otherwise transfer either its Contributions - or its Contributor Version. - -2.2. Effective Date - - The licenses granted in Section 2.1 with respect to any Contribution become - effective for each Contribution on the date the Contributor first distributes - such Contribution. - -2.3. Limitations on Grant Scope - - The licenses granted in this Section 2 are the only rights granted under this - License. No additional rights or licenses will be implied from the distribution - or licensing of Covered Software under this License. Notwithstanding Section - 2.1(b) above, no patent license is granted by a Contributor: - - a. for any code that a Contributor has removed from Covered Software; or - - b. for infringements caused by: (i) Your and any other third party’s - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - - c. under Patent Claims infringed by Covered Software in the absence of its - Contributions. - - This License does not grant any rights in the trademarks, service marks, or - logos of any Contributor (except as may be necessary to comply with the - notice requirements in Section 3.4). - -2.4. Subsequent Licenses - - No Contributor makes additional grants as a result of Your choice to - distribute the Covered Software under a subsequent version of this License - (see Section 10.2) or under the terms of a Secondary License (if permitted - under the terms of Section 3.3). - -2.5. Representation - - Each Contributor represents that the Contributor believes its Contributions - are its original creation(s) or it has sufficient rights to grant the - rights to its Contributions conveyed by this License. - -2.6. Fair Use - - This License is not intended to limit any rights You have under applicable - copyright doctrines of fair use, fair dealing, or other equivalents. - -2.7. Conditions - - Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in - Section 2.1. - - -3. Responsibilities - -3.1. Distribution of Source Form - - All distribution of Covered Software in Source Code Form, including any - Modifications that You create or to which You contribute, must be under the - terms of this License. You must inform recipients that the Source Code Form - of the Covered Software is governed by the terms of this License, and how - they can obtain a copy of this License. You may not attempt to alter or - restrict the recipients’ rights in the Source Code Form. - -3.2. Distribution of Executable Form - - If You distribute Covered Software in Executable Form then: - - a. such Covered Software must also be made available in Source Code Form, - as described in Section 3.1, and You must inform recipients of the - Executable Form how they can obtain a copy of such Source Code Form by - reasonable means in a timely manner, at a charge no more than the cost - of distribution to the recipient; and - - b. You may distribute such Executable Form under the terms of this License, - or sublicense it under different terms, provided that the license for - the Executable Form does not attempt to limit or alter the recipients’ - rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - - You may create and distribute a Larger Work under terms of Your choice, - provided that You also comply with the requirements of this License for the - Covered Software. If the Larger Work is a combination of Covered Software - with a work governed by one or more Secondary Licenses, and the Covered - Software is not Incompatible With Secondary Licenses, this License permits - You to additionally distribute such Covered Software under the terms of - such Secondary License(s), so that the recipient of the Larger Work may, at - their option, further distribute the Covered Software under the terms of - either this License or such Secondary License(s). - -3.4. Notices - - You may not remove or alter the substance of any license notices (including - copyright notices, patent notices, disclaimers of warranty, or limitations - of liability) contained within the Source Code Form of the Covered - Software, except that You may alter any license notices to the extent - required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - - You may choose to offer, and to charge a fee for, warranty, support, - indemnity or liability obligations to one or more recipients of Covered - Software. However, You may do so only on Your own behalf, and not on behalf - of any Contributor. You must make it absolutely clear that any such - warranty, support, indemnity, or liability obligation is offered by You - alone, and You hereby agree to indemnify every Contributor for any - liability incurred by such Contributor as a result of warranty, support, - indemnity or liability terms You offer. You may include additional - disclaimers of warranty and limitations of liability specific to any - jurisdiction. - -4. Inability to Comply Due to Statute or Regulation - - If it is impossible for You to comply with any of the terms of this License - with respect to some or all of the Covered Software due to statute, judicial - order, or regulation then You must: (a) comply with the terms of this License - to the maximum extent possible; and (b) describe the limitations and the code - they affect. Such description must be placed in a text file included with all - distributions of the Covered Software under this License. Except to the - extent prohibited by statute or regulation, such description must be - sufficiently detailed for a recipient of ordinary skill to be able to - understand it. - -5. Termination - -5.1. The rights granted under this License will terminate automatically if You - fail to comply with any of its terms. However, if You become compliant, - then the rights granted under this License from a particular Contributor - are reinstated (a) provisionally, unless and until such Contributor - explicitly and finally terminates Your grants, and (b) on an ongoing basis, - if such Contributor fails to notify You of the non-compliance by some - reasonable means prior to 60 days after You have come back into compliance. - Moreover, Your grants from a particular Contributor are reinstated on an - ongoing basis if such Contributor notifies You of the non-compliance by - some reasonable means, this is the first time You have received notice of - non-compliance with this License from such Contributor, and You become - compliant prior to 30 days after Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent - infringement claim (excluding declaratory judgment actions, counter-claims, - and cross-claims) alleging that a Contributor Version directly or - indirectly infringes any patent, then the rights granted to You by any and - all Contributors for the Covered Software under Section 2.1 of this License - shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user - license agreements (excluding distributors and resellers) which have been - validly granted by You or Your distributors under this License prior to - termination shall survive termination. - -6. Disclaimer of Warranty - - Covered Software is provided under this License on an “as is” basis, without - warranty of any kind, either expressed, implied, or statutory, including, - without limitation, warranties that the Covered Software is free of defects, - merchantable, fit for a particular purpose or non-infringing. The entire - risk as to the quality and performance of the Covered Software is with You. - Should any Covered Software prove defective in any respect, You (not any - Contributor) assume the cost of any necessary servicing, repair, or - correction. This disclaimer of warranty constitutes an essential part of this - License. No use of any Covered Software is authorized under this License - except under this disclaimer. - -7. Limitation of Liability - - Under no circumstances and under no legal theory, whether tort (including - negligence), contract, or otherwise, shall any Contributor, or anyone who - distributes Covered Software as permitted above, be liable to You for any - direct, indirect, special, incidental, or consequential damages of any - character including, without limitation, damages for lost profits, loss of - goodwill, work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses, even if such party shall have been - informed of the possibility of such damages. This limitation of liability - shall not apply to liability for death or personal injury resulting from such - party’s negligence to the extent applicable law prohibits such limitation. - Some jurisdictions do not allow the exclusion or limitation of incidental or - consequential damages, so this exclusion and limitation may not apply to You. - -8. Litigation - - Any litigation relating to this License may be brought only in the courts of - a jurisdiction where the defendant maintains its principal place of business - and such litigation shall be governed by laws of that jurisdiction, without - reference to its conflict-of-law provisions. Nothing in this Section shall - prevent a party’s ability to bring cross-claims or counter-claims. - -9. Miscellaneous - - This License represents the complete agreement concerning the subject matter - hereof. If any provision of this License is held to be unenforceable, such - provision shall be reformed only to the extent necessary to make it - enforceable. Any law or regulation which provides that the language of a - contract shall be construed against the drafter shall not be used to construe - this License against a Contributor. - - -10. Versions of the License - -10.1. New Versions - - Mozilla Foundation is the license steward. Except as provided in Section - 10.3, no one other than the license steward has the right to modify or - publish new versions of this License. Each version will be given a - distinguishing version number. - -10.2. Effect of New Versions - - You may distribute the Covered Software under the terms of the version of - the License under which You originally received the Covered Software, or - under the terms of any subsequent version published by the license - steward. - -10.3. Modified Versions - - If you create software not governed by this License, and you want to - create a new license for such software, you may create and use a modified - version of this License if you rename the license and remove any - references to the name of the license steward (except to note that such - modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses - If You choose to distribute Source Code Form that is Incompatible With - Secondary Licenses under the terms of this version of the License, the - notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice - - This Source Code Form is subject to the - terms of the Mozilla Public License, v. - 2.0. If a copy of the MPL was not - distributed with this file, You can - obtain one at - http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular file, then -You may include the notice in a location (such as a LICENSE file in a relevant -directory) where a recipient would be likely to look for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - “Incompatible With Secondary Licenses” Notice - - This Source Code Form is “Incompatible - With Secondary Licenses”, as defined by - the Mozilla Public License, v. 2.0. - ---- - -## icrowley/fake - -This product contains 'fake' by GitHub user "icrowley". - -Fake data generator for Go (Golang) - -* HOMEPAGE: - * https://github.com/icrowley/fake - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2014 Dmitry Afanasyev - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. --- @@ -2255,6 +2732,84 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## jmoiron/sqlx + +This product contains 'sqlx' by Jason Moiron. + +general purpose extensions to golang's database/sql + +* HOMEPAGE: + * https://github.com/jmoiron/sqlx + +* LICENSE: MIT + + Copyright (c) 2013, Jason Moiron + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + +--- + +## ledongthuc/pdf + +This product contains 'ledongthuc/pdf' by Thuc Le. + +PDF reader + +* HOMEPAGE: + * https://github.com/ledongthuc/pdf + +* LICENSE: BSD 3-Clause "New" or "Revised" License + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + --- ## lib/pq @@ -2279,39 +2834,347 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --- -## mattermost/gorp +## mattermost/go-i18n -This product contains 'gorp' by Mattermost (forked from original GitHub repo 'go-gorp/gorp' owned by GitHub user "go-gorp"). +This product contains 'mattermost/go-i18n' by Mattermost. -Go Relational Persistence - an ORM-ish library for Go +Translate your Go program into multiple languages. + +* LICENSE: MIT License + +Copyright (c) 2014 Nick Snyder https://github.com/nicksnyder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +--- + +## mattermost/gziphandler + +This product contains 'mattermost/gziphandler' by Mattermost. * HOMEPAGE: - * https://github.com/mattermost/gorp + * https://github.com/mattermost/gziphandler -* LICENSE: MIT +* LICENSE: Apache License 2.0 -(The MIT License) + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2012 James Cooper + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: + 1. Definitions. -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016-2017 The New York Times Company + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +--- + +## mattermost/ldap + +This product contains 'mattermost/ldap' by Mattermost. + +Basic LDAP v3 functionality for the GO programming language. + +* LICENSE: MIT License + +The MIT License (MIT) + +Copyright (c) 2011-2015 Michael Mitton (mmitton@gmail.com) +Portions copyright (c) 2015-2016 go-ldap Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +--- + +## mattermost/logr + +This product contains 'mattermost/logr' by Mattermost. + +Fully asynchronous, structured, pluggable logging for Go. + +* LICENSE: MIT License + +MIT License + +Copyright (c) 2019 wiggin77 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +--- + +## mattermost/morph + +This product contains 'mattermost/morph' by Mattermost. + +* LICENSE: Other + +The MIT License (MIT) + +Copyright (c) 2021 The go-morph AUTHORS. All rights reserved. +https://github.com/go-morph/morph + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- @@ -2356,20 +3219,55 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## mattermost/viper +## mattermost/squirrel -This product contains 'viper' by Mattermost (forked from original GitHub repo 'spf13/viper' owned by Steve Francia). +This product contains 'mattermost/squirrel' by Mattermost. -Go configuration with fangs +To house a custom fork of github.com/Masterminds/squirrel aka Fluent SQL generation for golang + +* LICENSE: Other + +Squirrel +The Masterminds +Copyright (C) 2014-2015, Lann Martin +Copyright (C) 2015-2016, Google +Copyright (C) 2015, Matt Farina and Matt Butcher + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +--- + +## mholt/archiver + +This product contains 'mholt/archiver' by Matt Holt. + +Easily create & extract archives, and compress & decompress files of various formats * HOMEPAGE: - * https://github.com/mattermost/viper + * https://pkg.go.dev/github.com/mholt/archiver/v4 -* LICENSE: MIT +* LICENSE: MIT License -The MIT License (MIT) +MIT License -Copyright (c) 2014 Steve Francia +Copyright (c) 2016 Matthew Holt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -2389,6 +3287,52 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## microcosm-cc/bluemonday + +This product contains 'microcosm-cc/bluemonday' by Microcosm. + +bluemonday: a fast golang HTML sanitizer (inspired by the OWASP Java HTML Sanitizer) to scrub user generated content of XSS + +* HOMEPAGE: + * https://github.com/microcosm-cc/bluemonday + +* LICENSE: Other + +SPDX short identifier: BSD-3-Clause +https://opensource.org/licenses/BSD-3-Clause + +Copyright (c) 2014, David Kitchen + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the organisation (Microcosm) nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + --- ## minio/minio-go @@ -2612,127 +3556,51 @@ Copyright 2015-2017 MinIO, Inc. --- -## nicksnyder/go-i18n +## oov/psd -This product contains 'go-i18n' by Mattermost, modified (forked) from original GitHub repo 'nicksnyder/go-i18n' owned by Nick Snyder. +This product contains 'psd' by oov. -Translate your Go program into multiple languages. +A PSD/PSB file reader for go * HOMEPAGE: - * https://github.com/mattermost/go-i18n + * https://github.com/oov/psd * LICENSE: MIT -Copyright (c) 2014 Nick Snyder https://github.com/nicksnyder +MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Copyright (c) 2016 oov -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. --- -## pborman/uuid +## opentracing/opentracing-go -This product contains 'uuid' by GitHub user "pborman". +This product contains 'opentracing/opentracing-go' by OpenTracing API. -Automatically exported from code.google.com/p/go-uuid +OpenTracing API for Go. 🛑 This library is DEPRECATED! https://github.com/opentracing/specification/issues/163 * HOMEPAGE: - * https://github.com/pborman/uuid + * http://opentracing.io -* LICENSE: BSD-3-Clause - -Copyright (c) 2009,2014 Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ---- - -## pkg/errors - -This product contains 'errors' by GitHub user "pkg". - -Simple error handling primitives - -* HOMEPAGE: - * https://github.com/pkg/errors - -* LICENSE: BSD-2-Clause - -Copyright (c) 2015, Dave Cheney -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ---- - -## prometheus/client_golang - -This product contains 'client_golang' by Prometheus. - -Prometheus instrumentation library for Go applications - -* HOMEPAGE: - * https://github.com/prometheus/client_golang - -* LICENSE: Apache-2.0 +* LICENSE: Apache License 2.0 Apache License Version 2.0, January 2004 @@ -2914,7 +3782,7 @@ Prometheus instrumentation library for Go applications APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" + boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -2922,7 +3790,7 @@ Prometheus instrumentation library for Go applications same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2016 The OpenTracing Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2936,31 +3804,120 @@ Prometheus instrumentation library for Go applications See the License for the specific language governing permissions and limitations under the License. -* This package includes the following NOTICE: -Prometheus instrumentation library for Go applications -Copyright 2012-2015 The Prometheus Authors +--- -This product includes software developed at -SoundCloud Ltd. (http://soundcloud.com/). +## pborman/uuid +This product contains 'uuid' by GitHub user "pborman". -The following components are included in this product: +Automatically exported from code.google.com/p/go-uuid -perks - a fork of https://github.com/bmizerany/perks -https://github.com/beorn7/perks -Copyright 2013-2015 Blake Mizerany, Björn Rabenstein -See https://github.com/beorn7/perks/blob/master/README.md for license details. +* HOMEPAGE: + * https://github.com/pborman/uuid -Go support for Protocol Buffers - Google's data interchange format -http://github.com/golang/protobuf/ -Copyright 2010 The Go Authors -See source code for license details. +* LICENSE: BSD-3-Clause + +Copyright (c) 2009,2014 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +## pkg/errors + +This product contains 'errors' by GitHub user "pkg". + +Simple error handling primitives + +* HOMEPAGE: + * https://github.com/pkg/errors + +* LICENSE: BSD-2-Clause + +Copyright (c) 2015, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +## reflog/dateconstraints + +This product contains 'reflog/dateconstraints' by Eli Yukelzon. + +Validate a date against constraints + +* HOMEPAGE: + * https://github.com/reflog/dateconstraints + +* LICENSE: MIT License + +MIT License + +Copyright (c) 2020 Eli Yukelzon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. -Support for streaming Protocol Buffer messages for the Go language (golang). -https://github.com/matttproud/golang_protobuf_extensions -Copyright 2013 Matt T. Proud -Licensed under the Apache License, Version 2.0 --- @@ -2995,6 +3952,21 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## rudderlabs/analytics-go + +This product contains 'rudderlabs/analytics-go' by RudderStack. + +RudderStack is an open-source, warehouse-first Customer Data Pipeline and Segment-alternative. It collects and routes clickstream data and builds your customer data lake on your data warehouse. + +* HOMEPAGE: + * https://rudderstack.com + +* LICENSE: MIT License + + + --- ## rwcarlsen/goexif @@ -3035,64 +4007,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## segmentio/analytics-go - -This product contains 'analytics-go' by Segment. - -Segment analytics client for Go - -* HOMEPAGE: - * https://github.com/segmentio/analytics-go - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2016 Segment, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- - -## sirupsen/logrus - -This product contains 'logrus' by Simon Eskildsen. - -Structured, pluggable logging for Go. - -* HOMEPAGE: - * https://github.com/sirupsen/logrus - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2014 Simon Eskildsen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - ## spf13/cobra This product contains 'cobra' by Steve Francia. @@ -3281,39 +4195,31 @@ A Commander for modern Go CLI interactions --- -## jmoiron/sqlx +## splitio/go-client -This product contains 'sqlx' by Jason Moiron. +This product contains 'splitio/go-client' by Split Software. -general purpose extensions to golang's database/sql +Go SDK client for Split Software * HOMEPAGE: - * https://github.com/jmoiron/sqlx + * https://split.io -* LICENSE: MIT +* LICENSE: Other - Copyright (c) 2013, Jason Moiron +Copyright © 2022 Split Software, Inc. - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. --- @@ -3378,71 +4284,22 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND --- -## tylerb/graceful +## tinylib/msgp -This product contains 'graceful' by Tyler Stillwater. +This product contains 'tinylib/msgp' by tinylib. -Graceful is a Go package enabling graceful shutdown of an http.Handler server. +A Go code generator for MessagePack / msgpack.org[Go] -* HOMEPAGE: - * https://github.com/tylerb/graceful +* LICENSE: MIT License -* LICENSE: MIT +Copyright (c) 2014 Philip Hofer +Portions Copyright (c) 2009 The Go Authors (license at http://golang.org) where indicated -The MIT License (MIT) +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -Copyright (c) 2014 Tyler Bunnell +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## zap - -This product contains 'zap' by Uber Technologies, Inc.. - -Blazing fast, structured, leveled logging in Go. - -* HOMEPAGE: - * https://github.com/uber-go/zap - -* LICENSE: MIT - -Copyright (c) 2016-2017 Uber Technologies, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- @@ -3567,6 +4424,48 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +--- + +## x/sync + +This product contains 'x/sync' by Go. + +[mirror] concurrency primitives + +* HOMEPAGE: + * https://github.com/golang/sync + +* LICENSE: BSD 3-Clause "New" or "Revised" License + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + --- ## x/text @@ -3610,931 +4509,16 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## mail +## x/tools -This product contains 'mail' by GitHub user "go-mail", modified (forked) from original GitHub repo 'go-gomail/gomail' owned by Gomail. +This product contains 'x/tools' by Go. -Actively maintained fork of gomail. The best way to send emails in Go. +[mirror] Go Tools * HOMEPAGE: - * https://github.com/go-mail/mail + * https://golang.org/x/tools -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2014 Alexandre Cesaro - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- - -## natefinch/lumberjack - -This product contains 'lumberjack' by Nate Finch. - -lumberjack is a log rolling package for Go - -* HOMEPAGE: - * https://github.com/natefinch/lumberjack - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2014 Nate Finch - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## olivere/elastic - -This product contains 'elastic' by Oliver Eilhard. - -Elasticsearch client for Go. - -* HOMEPAGE: - * https://github.com/olivere/elastic - -* LICENSE: MIT - -The MIT License (MIT) -Copyright © 2012-2015 Oliver Eilhard - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the “Software”), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. - ---- - -## go-yaml/yaml - -This product contains 'yaml' by GitHub user "go-yaml". - -YAML support for the Go language. - -* HOMEPAGE: - * https://github.com/go-yaml/yaml - -* LICENSE: Apache-2.0 - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -* This package includes the following NOTICE: - -Copyright 2011-2016 Canonical Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - ---- - -## go/imageproxy - -This product contains 'imageproxy' by Will Norris. - -A caching, resizing image proxy written in Go - -* HOMEPAGE: - * https://github.com/willnorris/imageproxy - -* LICENSE: Apache-2.0 - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - ---- - -## oov/psd - -This product contains 'psd' by oov. - -A PSD/PSB file reader for go - -* HOMEPAGE: - * https://github.com/oov/psd - -* LICENSE: MIT - -MIT License - -Copyright (c) 2016 oov - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## gopherjs - -This product contains 'gopherjs' by Richard Musiol. - -A Go code to javascript code compiler. - -* HOMEPAGE: - * https://github.com/gopherjs/gopherjs - -* LICENSE: - -Copyright (c) 2013 Richard Musiol. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -## AWS SDK for Go - -This product contains 'aws-sdk' by Amazon. - -AWS-SDK support for the Go language. - -* HOMEPAGE: - * https://github.com/aws/aws-sdk-go - -* LICENSE: - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - ---- - -## semver - -This product contains 'semver' by Masterminds. - -The semver package provides the ability to work with Semantic Versions in Go. - -* HOMEPAGE: - * https://github.com/Masterminds/semver - -* LICENSE: - -Copyright (C) 2014-2019, Matt Butcher and Matt Farina - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - -## Date Constraints - -This product contains 'dateconstraints' by Eli Yukelzon. - -Go library to validate a date against constraints - -* HOMEPAGE: - * https://github.com/reflog/dateconstraints - -* LICENSE: - -MIT License - -Copyright (c) 2020 Eli Yukelzon - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## Archiver - -This product contains 'archiver' by Matthew Holt - -A library to handle different archive files (zip, rar, tar.gz...) - -* HOMEPAGE: - * https://github.com/mholt/archiver - -* LICENSE: - -MIT License - -Copyright (c) 2016 Matthew Holt - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## PDF Reader library - -This product contains 'pdf' by the Go team and modified by Thuc Le - -A library to provide pdf reading support - -* HOMEPAGE: - * https://github.com/ledongthuc/pdf - -* LICENSE: +* LICENSE: BSD 3-Clause "New" or "Revised" License Copyright (c) 2009 The Go Authors. All rights reserved. @@ -4564,281 +4548,4 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ---- - -## GoOse - -This product contains 'GoOse' by Antonio Linari - -A library to provide html text extraction support - -* HOMEPAGE: - * https://github.com/advancedlogic/GoOse - -* LICENSE: - -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - ---- - -## Docconv - -This product contains 'docconv' by Sajari Pty Ltd - -A library to provide text extraction support for different documents - -* HOMEPAGE: - * https://github.com/sajari/docconv - -* LICENSE: - -The MIT License (MIT) - -Copyright (c) 2014 Sajari Pty Ltd - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - -## JWT-Go - -This product contains `jwt-go` by Dave Grijalva - -* HOMEPAGE: - * https://github.com/dgrijalva/jwt-go - -* LICENSE: - -The MIT License (MIT) - -Copyright (c) 2012 Dave Grijalva - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of -the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/api4/api.go b/api4/api.go index 28114af4fe..b50f119e19 100644 --- a/api4/api.go +++ b/api4/api.go @@ -139,6 +139,12 @@ type Routes struct { InsightsForUser *mux.Router // 'api/v4/users/me/top' Usage *mux.Router // 'api/v4/usage' + + WorkTemplates *mux.Router // 'api/v4/worktemplates' + + HostedCustomer *mux.Router // 'api/v4/hosted_customer' + + Drafts *mux.Router // 'api/v4/drafts' } type API struct { @@ -265,6 +271,12 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter() + api.BaseRoutes.WorkTemplates = api.BaseRoutes.APIRoot.PathPrefix("/worktemplates").Subrouter() + + api.BaseRoutes.HostedCustomer = api.BaseRoutes.APIRoot.PathPrefix("/hosted_customer").Subrouter() + + api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -308,6 +320,9 @@ func Init(srv *app.Server) (*API, error) { api.InitExport() api.InitInsights() api.InitUsage() + api.InitWorkTemplate() + api.InitHostedCustomer() + api.InitDrafts() if err := api.InitGraphQL(); err != nil { return nil, err } diff --git a/api4/channel.go b/api4/channel.go index 1bc8c1db06..63eefb58af 100644 --- a/api4/channel.go +++ b/api4/channel.go @@ -1491,7 +1491,7 @@ func viewChannel(c *Context, w http.ResponseWriter, r *http.Request) { return } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) c.ExtendSessionExpiryIfNeeded(w, r) // Returning {"status": "OK", ...} for backwards compatibility diff --git a/api4/cloud.go b/api4/cloud.go index 4537828afb..02fab0752a 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -13,7 +13,6 @@ import ( "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) @@ -23,6 +22,8 @@ func (api *API) InitCloud() { // GET /api/v4/cloud/limits api.BaseRoutes.Cloud.Handle("/limits", api.APISessionRequired(getCloudLimits)).Methods("GET") + api.BaseRoutes.Cloud.Handle("/products/selfhosted", api.APISessionRequired(getSelfHostedProducts)).Methods("GET") + // POST /api/v4/cloud/payment // POST /api/v4/cloud/payment/confirm api.BaseRoutes.Cloud.Handle("/payment", api.APISessionRequired(createCustomerPayment)).Methods("POST") @@ -38,7 +39,7 @@ func (api *API) InitCloud() { // GET /api/v4/cloud/subscription api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(getSubscription)).Methods("GET") api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.APISessionRequired(getInvoicesForSubscription)).Methods("GET") - api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods("GET") + api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods("GET") api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(changeSubscription)).Methods("PUT") // GET /api/v4/cloud/request-trial @@ -95,6 +96,8 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { } func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { + userId := c.AppContext.Session().UserId + if !c.App.Channels().License().IsCloud() { c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.license_error", nil, "", http.StatusInternalServerError) return @@ -117,13 +120,13 @@ func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { return } - currentSubscription, appErr := c.App.Cloud().GetSubscription(c.AppContext.Session().UserId) + currentSubscription, appErr := c.App.Cloud().GetSubscription(userId) if appErr != nil { c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) return } - changedSub, err := c.App.Cloud().ChangeSubscription(c.AppContext.Session().UserId, currentSubscription.ID, subscriptionChange) + changedSub, err := c.App.Cloud().ChangeSubscription(userId, currentSubscription.ID, subscriptionChange) if err != nil { c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) return @@ -135,9 +138,21 @@ func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { return } + product, err := c.App.Cloud().GetCloudProduct(c.AppContext.Session().UserId, subscriptionChange.ProductID) + if err != nil || product == nil { + c.Logger.Error("Error finding the new cloud product", mlog.Err(err)) + } + + if product.SKU == string(model.SkuCloudStarter) { + w.Write(json) + return + } + + isYearly := product.IsYearly() + // Log failures for purchase confirmation email, but don't show an error to the user so as not to confuse them // At this point, the upgrade is complete. - if appErr := c.App.SendUpgradeConfirmationEmail(); appErr != nil { + if appErr := c.App.SendUpgradeConfirmationEmail(isYearly); appErr != nil { c.Logger.Error("Error sending purchase confirmation email", mlog.Err(appErr)) } @@ -277,6 +292,40 @@ func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.R } } +func getSelfHostedProducts(c *Context, w http.ResponseWriter, r *http.Request) { + products, err := c.App.Cloud().GetSelfHostedProducts(c.AppContext.Session().UserId) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + byteProductsData, err := json.Marshal(products) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) { + sanitizedProducts := []model.UserFacingProduct{} + err = json.Unmarshal(byteProductsData, &sanitizedProducts) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + byteSanitizedProductsData, err := json.Marshal(sanitizedProducts) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(byteSanitizedProductsData) + return + } + + w.Write(byteProductsData) +} + func getCloudProducts(c *Context, w http.ResponseWriter, r *http.Request) { if !c.App.Channels().License().IsCloud() { c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.license_error", nil, "", http.StatusForbidden) @@ -601,7 +650,26 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { return } case model.EventTypeSendUpgradeConfirmationEmail: - if nErr := c.App.SendUpgradeConfirmationEmail(); nErr != nil { + + // isYearly determines whether to send the yearly or monthly Upgrade email + isYearly := false + if event.Subscription != nil && event.CloudWorkspaceOwner != nil { + user, appErr := c.App.GetUserByUsername(event.CloudWorkspaceOwner.UserName) + if appErr != nil { + c.Err = model.NewAppError("Api4.handleCWSWebhook", appErr.Id, nil, appErr.Error(), appErr.StatusCode) + return + } + + // Get the current cloud product to determine whether it's a monthly or yearly product + product, err := c.App.Cloud().GetCloudProduct(user.Id, event.Subscription.ProductID) + if err != nil { + c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.request_error", nil, err.Error(), http.StatusInternalServerError) + return + } + isYearly = product.IsYearly() + } + + if nErr := c.App.SendUpgradeConfirmationEmail(isYearly); nErr != nil { c.Err = nErr return } @@ -630,23 +698,6 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, err.Error(), http.StatusInternalServerError) return } - case model.EventTypeSubscriptionChanged: - // event.ProductLimits is nil if there was no change - if event.ProductLimits != nil { - if pluginsEnvironment := c.App.GetPluginsEnvironment(); pluginsEnvironment != nil { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.OnCloudLimitsUpdated(event.ProductLimits) - return true - }, plugin.OnCloudLimitsUpdatedID) - } - c.App.AdjustInProductLimits(event.ProductLimits, event.Subscription) - } - - if err := c.App.Cloud().UpdateSubscriptionFromHook(event.ProductLimits, event.Subscription); err != nil { - c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.subscription.update_error", nil, err.Error(), http.StatusInternalServerError) - return - } - c.Logger.Info("Updated subscription from webhook event") case model.EventTypeTriggerDelinquencyEmail: var emailToTrigger model.DelinquencyEmail if event.DelinquencyEmail != nil { diff --git a/api4/cloud_test.go b/api4/cloud_test.go index 15ff7b1831..f44176656c 100644 --- a/api4/cloud_test.go +++ b/api4/cloud_test.go @@ -651,3 +651,110 @@ func TestGetCloudProducts(t *testing.T) { require.Equal(t, returnedProducts[2].CrossSellsTo, "prod_test2") }) } + +func TestGetSelfHostedProducts(t *testing.T) { + products := []*model.Product{ + { + ID: "prod_test", + Name: "Self-Hosted Professional", + Description: "Ideal for small companies and departments with data security requirements", + PricePerSeat: 10, + SKU: "professional", + PriceID: "price_1JPXbNI67GP2qpb4VuFdFbwQ", + Family: "on-prem", + RecurringInterval: model.RecurringIntervalYearly, + }, + { + ID: "prod_test2", + Name: "Self-Hosted Enterprise", + Description: "Built to scale for high-trust organizations and companies in regulated industries.", + PricePerSeat: 30, + SKU: "enterprise", + PriceID: "price_1JPXaVI67GP2qpb4l40bXyRu", + Family: "on-prem", + RecurringInterval: model.RecurringIntervalYearly, + }, + } + + sanitizedProducts := []*model.Product{ + { + ID: "prod_test", + Name: "Self-Hosted Professional", + PricePerSeat: 10, + SKU: "professional", + RecurringInterval: model.RecurringIntervalYearly, + }, + { + ID: "prod_test2", + Name: "Self-Hosted Enterprise", + PricePerSeat: 30, + SKU: "enterprise", + RecurringInterval: model.RecurringIntervalYearly, + }, + } + + t.Run("get products for admins", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + cloud := mocks.CloudInterface{} + cloud.Mock.On("GetSelfHostedProducts", mock.Anything, mock.Anything).Return(products, nil) + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + returnedProducts, r, err := th.Client.GetSelfHostedProducts() + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") + require.Equal(t, returnedProducts, products) + }) + + t.Run("get products for non admins", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + cloud := mocks.CloudInterface{} + + cloud.Mock.On("GetSelfHostedProducts", mock.Anything, mock.Anything).Return(products, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + returnedProducts, r, err := th.Client.GetSelfHostedProducts() + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") + require.Equal(t, returnedProducts, sanitizedProducts) + + // make a more explicit check + require.Equal(t, returnedProducts[0].ID, "prod_test") + require.Equal(t, returnedProducts[0].Name, "Self-Hosted Professional") + require.Equal(t, returnedProducts[0].SKU, "professional") + require.Equal(t, returnedProducts[0].PricePerSeat, float64(10)) + require.Equal(t, returnedProducts[0].Description, "") + require.Equal(t, returnedProducts[0].PriceID, "") + require.Equal(t, returnedProducts[0].Family, model.SubscriptionFamily("")) + require.Equal(t, returnedProducts[0].RecurringInterval, model.RecurringInterval("year")) + require.Equal(t, returnedProducts[0].BillingScheme, model.BillingScheme("")) + require.Equal(t, returnedProducts[0].CrossSellsTo, "") + + require.Equal(t, returnedProducts[1].ID, "prod_test2") + require.Equal(t, returnedProducts[1].Name, "Self-Hosted Enterprise") + require.Equal(t, returnedProducts[1].SKU, "enterprise") + require.Equal(t, returnedProducts[1].PricePerSeat, float64(30)) + require.Equal(t, returnedProducts[1].Description, "") + require.Equal(t, returnedProducts[1].PriceID, "") + require.Equal(t, returnedProducts[1].Family, model.SubscriptionFamily("")) + require.Equal(t, returnedProducts[1].RecurringInterval, model.RecurringInterval("year")) + require.Equal(t, returnedProducts[1].BillingScheme, model.BillingScheme("")) + require.Equal(t, returnedProducts[1].CrossSellsTo, "") + }) +} diff --git a/api4/config.go b/api4/config.go index d2f2019c36..5f620e14d5 100644 --- a/api4/config.go +++ b/api4/config.go @@ -157,12 +157,7 @@ func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) { } if cfg.PluginSettings.PluginStates[model.PluginIdFocalboard].Enable && cfg.FeatureFlags.BoardsProduct { - c.Err = model.NewAppError("EnablePlugin", "app.plugin.product_mode.app_error", map[string]any{"Name": model.PluginIdFocalboard}, "", http.StatusBadRequest) - return - } - - if appErr := c.App.CheckFreemiumLimitsForConfigSave(appCfg, cfg); appErr != nil { - c.Err = appErr + c.Err = model.NewAppError("EnablePlugin", "app.plugin.product_mode.app_error", map[string]any{"Name": model.PluginIdFocalboard}, "", http.StatusInternalServerError) return } @@ -304,11 +299,6 @@ func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) { } } - if appErr := c.App.CheckFreemiumLimitsForConfigSave(appCfg, cfg); appErr != nil { - c.Err = appErr - return - } - // There are some settings that cannot be changed in a cloud env if c.App.Channels().License().IsCloud() { if cfg.ComplianceSettings.Directory != nil && *appCfg.ComplianceSettings.Directory != *cfg.ComplianceSettings.Directory { diff --git a/api4/config_test.go b/api4/config_test.go index 2af4fe7bac..3db4082e49 100644 --- a/api4/config_test.go +++ b/api4/config_test.go @@ -17,9 +17,7 @@ import ( "github.com/mattermost/mattermost-server/v6/app" "github.com/mattermost/mattermost-server/v6/config" - "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock" ) func TestGetConfig(t *testing.T) { @@ -249,59 +247,6 @@ func TestUpdateConfig(t *testing.T) { assert.Equal(t, newURL, *cfg2.PluginSettings.MarketplaceURL) }) - t.Run("Should not be able to save config if the new config exceeds Freemium limits", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - defer th.App.Srv().RemoveLicense() - - cloud := &mocks.CloudInterface{} - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = cloud - - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Integrations: &model.IntegrationsLimits{ - Enabled: model.NewInt(0), - }, - }, nil).Once() - - // Exceed freemium limit. Should throw error. - cfg1 := th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - _, _, err1 := th.SystemAdminClient.UpdateConfig(cfg1) - require.Error(t, err1) - - // No attempt to enable a plugin. Should not throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: false} - _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) - require.NoError(t, err1) - - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Integrations: &model.IntegrationsLimits{ - Enabled: model.NewInt(1), - }, - }, nil).Twice() - - // Exceed freemium limit while enabling more than one plugin. Should throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - cfg1.PluginSettings.PluginStates["new-plugin2"] = &model.PluginState{Enable: true} - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.Error(t, err1) - - // Match freemium limit. Should not throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) - require.NoError(t, err1) - - // Save same config with same plugin enabled. Should not throw error. - _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) - require.NoError(t, err1) - }) - t.Run("Should not be able to modify ComplianceSettings.Directory in cloud", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicense("cloud")) defer th.App.Srv().RemoveLicense() @@ -847,59 +792,6 @@ func TestPatchConfig(t *testing.T) { assert.Equal(t, newURL, *cfg.PluginSettings.MarketplaceURL) }) - t.Run("Should not be able to save config if the new config exceeds Freemium limits", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - defer th.App.Srv().RemoveLicense() - - cloud := &mocks.CloudInterface{} - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = cloud - - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Integrations: &model.IntegrationsLimits{ - Enabled: model.NewInt(0), - }, - }, nil).Once() - - // Exceed freemium limit. Should throw error. - cfg1 := th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - _, _, err1 := th.SystemAdminClient.PatchConfig(cfg1) - require.Error(t, err1) - - // No attempt to enable a plugin. Should not throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: false} - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.NoError(t, err1) - - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Integrations: &model.IntegrationsLimits{ - Enabled: model.NewInt(1), - }, - }, nil).Twice() - - // Exceed freemium limit while enabling more than one plugin. Should throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - cfg1.PluginSettings.PluginStates["new-plugin2"] = &model.PluginState{Enable: true} - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.Error(t, err1) - - // Match freemium limit. Should not throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.NoError(t, err1) - - // Save same config with same plugin enabled. Should not throw error. - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.NoError(t, err1) - }) - t.Run("System Admin should not be able to clear Site URL", func(t *testing.T) { cfg, _, err := th.SystemAdminClient.GetConfig() require.NoError(t, err) diff --git a/api4/drafts.go b/api4/drafts.go new file mode 100644 index 0000000000..102c164b8f --- /dev/null +++ b/api4/drafts.go @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +func (api *API) InitDrafts() { + api.BaseRoutes.Drafts.Handle("", api.APISessionRequired(upsertDraft)).Methods("POST") + + api.BaseRoutes.TeamForUser.Handle("/drafts", api.APISessionRequired(getDrafts)).Methods("GET") + + api.BaseRoutes.ChannelForUser.Handle("/drafts/{thread_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteDraft)).Methods("DELETE") + api.BaseRoutes.ChannelForUser.Handle("/drafts", api.APISessionRequired(deleteDraft)).Methods("DELETE") +} + +func upsertDraft(c *Context, w http.ResponseWriter, r *http.Request) { + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("upsertDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + var draft model.Draft + if jsonErr := json.NewDecoder(r.Body).Decode(&draft); jsonErr != nil { + c.SetInvalidParam("draft") + return + } + + draft.DeleteAt = 0 + draft.UserId = c.AppContext.Session().UserId + connectionID := r.Header.Get(model.ConnectionId) + + hasPermission := false + + if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost) { + hasPermission = true + } else if channel, err := c.App.GetChannel(c.AppContext, draft.ChannelId); err == nil { + // Temporary permission check method until advanced permissions, please do not copy + if channel.Type == model.ChannelTypeOpen && c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePostPublic) { + hasPermission = true + } + } + + if !hasPermission { + c.SetPermissionError(model.PermissionCreatePost) + return + } + + dt, err := c.App.UpsertDraft(c.AppContext, &draft, connectionID) + if err != nil { + c.Err = err + return + } + + w.WriteHeader(http.StatusCreated) + + if err := json.NewEncoder(w).Encode(dt); err != nil { + mlog.Warn("Error while writing response", mlog.Err(err)) + } +} + +func getDrafts(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Err != nil { + return + } + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("getDrafts", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + hasPermission := false + + if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + hasPermission = true + } + + if !hasPermission { + c.SetPermissionError(model.PermissionCreatePost) + return + } + + drafts, err := c.App.GetDraftsForUser(c.AppContext.Session().UserId, c.Params.TeamId) + if err != nil { + c.Err = err + return + } + + if err := json.NewEncoder(w).Encode(drafts); err != nil { + mlog.Warn("Error while writing response", mlog.Err(err)) + } +} + +func deleteDraft(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Err != nil { + return + } + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("deleteDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + rootID := "" + + connectionID := r.Header.Get(model.ConnectionId) + + if c.Params.ThreadId != "" { + rootID = c.Params.ThreadId + } + + userID := c.AppContext.Session().UserId + channelID := c.Params.ChannelId + + draft, err := c.App.GetDraft(userID, channelID, rootID) + if err != nil { + switch { + case err.StatusCode == http.StatusNotFound: + // If the draft doesn't exist in the server, we don't need to delete. + mlog.Debug("Unable to find the draft", mlog.Err(err)) + ReturnStatusOK(w) + default: + c.Err = err + } + return + } + + if c.AppContext.Session().UserId != draft.UserId { + c.SetPermissionError(model.PermissionDeletePost) + return + } + + if _, err := c.App.DeleteDraft(userID, channelID, rootID, connectionID); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} diff --git a/api4/drafts_test.go b/api4/drafts_test.go new file mode 100644 index 0000000000..9788bd1897 --- /dev/null +++ b/api4/drafts_test.go @@ -0,0 +1,227 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/utils/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpsertDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + // set config + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel := th.BasicChannel + user := th.BasicUser + + draft := &model.Draft{ + CreateAt: 12345, + UpdateAt: 12345, + UserId: user.Id, + ChannelId: channel.Id, + Message: "original", + } + + // try to upsert draft + draftResp, _, err := client.UpsertDraft(draft) + require.NoError(t, err) + + assert.Equal(t, draft.UserId, draftResp.UserId) + assert.Equal(t, draft.Message, draftResp.Message) + assert.Equal(t, draft.ChannelId, draftResp.ChannelId) + + // upload file + sent, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) + + fileResp, _, err := client.UploadFile(sent, channel.Id, "test.png") + require.NoError(t, err) + + draftWithFiles := draft + draftWithFiles.FileIds = []string{fileResp.FileInfos[0].Id} + + // try to upsert draft with file + draftResp, _, err = client.UpsertDraft(draftWithFiles) + require.NoError(t, err) + + assert.Equal(t, draftWithFiles.UserId, draftResp.UserId) + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + + // try to upsert draft for invalid channel + draftInvalidChannel := draft + draftInvalidChannel.ChannelId = "12345" + + _, resp, err := client.UpsertDraft(draft) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // try to upsert draft without config setting set to true + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + _, resp, err = client.UpsertDraft(draft) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) +} + +func TestGetDrafts(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel1 := th.BasicChannel + channel2 := th.BasicChannel2 + user := th.BasicUser + team := th.BasicTeam + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel1.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 11111, + UpdateAt: 32222, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + // upsert draft1 + _, _, err := client.UpsertDraft(draft1) + require.NoError(t, err) + + // upsert draft2 + _, _, err = client.UpsertDraft(draft2) + require.NoError(t, err) + + // try to get drafts + draftResp, _, err := client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.UserId, draftResp[1].UserId) + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + + assert.Len(t, draftResp, 2) + + // try to get drafts on invalid team + _, resp, err := client.GetDrafts(user.Id, "12345") + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // try to get drafts when config is turned off + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + _, resp, err = client.GetDrafts(user.Id, team.Id) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) +} + +func TestDeleteDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel1 := th.BasicChannel + channel2 := th.BasicChannel2 + user := th.BasicUser + team := th.BasicTeam + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel1.Id, + Message: "draft1", + RootId: "", + } + + draft2 := &model.Draft{ + CreateAt: 11111, + UpdateAt: 32222, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + RootId: model.NewId(), + } + + // upsert draft1 + _, _, err := client.UpsertDraft(draft1) + require.NoError(t, err) + + // upsert draft2 + _, _, err = client.UpsertDraft(draft2) + require.NoError(t, err) + + //get drafts + draftResp, _, err := client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.UserId, draftResp[1].UserId) + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + + // try to delete draft1 + _, _, err = client.DeleteDraft(user.Id, channel1.Id, draft1.RootId) + require.NoError(t, err) + + //get drafts + draftResp, _, err = client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + assert.Len(t, draftResp, 1) +} diff --git a/api4/group.go b/api4/group.go index 343db5ee7e..72c78e7179 100644 --- a/api4/group.go +++ b/api4/group.go @@ -950,12 +950,13 @@ func getGroupsAssociatedToChannelsByTeam(c *Context, w http.ResponseWriter, r *h } func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { + var teamID, NotAssociatedToChannelID, ChannelIDForMemberCount string + permissionErr := requireLicense(c) if permissionErr != nil { c.Err = permissionErr return } - var teamID, channelID string source := c.Params.GroupSource @@ -964,7 +965,11 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } if id := c.Params.NotAssociatedToChannel; model.IsValidId(id) { - channelID = id + NotAssociatedToChannelID = id + } + + if id := c.Params.IncludeChannelMemberCount; model.IsValidId(id) { + ChannelIDForMemberCount = id } // If they specify the group_source as custom when the feature is disabled, throw an error @@ -975,10 +980,12 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } // If they don't specify a source and custom groups are disabled, ensure they only get ldap groups in the response - if !c.App.Config().FeatureFlags.CustomGroups || !*c.App.Config().ServiceSettings.EnableCustomGroups { + if !*c.App.Config().ServiceSettings.EnableCustomGroups { source = model.GroupSourceLdap } + includeTimezones := r.URL.Query().Get("include_timezones") == "true" + opts := model.GroupSearchOpts{ Q: c.Params.Q, IncludeMemberCount: c.Params.IncludeMemberCount, @@ -986,6 +993,7 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { FilterParentTeamPermitted: c.Params.FilterParentTeamPermitted, Source: source, FilterHasMember: c.Params.FilterHasMember, + IncludeTimezones: includeTimezones, } if teamID != "" { @@ -998,8 +1006,8 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { opts.NotAssociatedToTeam = teamID } - if channelID != "" { - channel, appErr := c.App.GetChannel(c.AppContext, channelID) + if NotAssociatedToChannelID != "" { + channel, appErr := c.App.GetChannel(c.AppContext, NotAssociatedToChannelID) if appErr != nil { c.Err = appErr return @@ -1010,11 +1018,30 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } else { permission = model.PermissionManagePublicChannelMembers } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelID, permission) { + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), NotAssociatedToChannelID, permission) { c.SetPermissionError(permission) return } - opts.NotAssociatedToChannel = channelID + opts.NotAssociatedToChannel = NotAssociatedToChannelID + } + + if ChannelIDForMemberCount != "" { + channel, appErr := c.App.GetChannel(c.AppContext, ChannelIDForMemberCount) + if appErr != nil { + c.Err = appErr + return + } + var permission *model.Permission + if channel.Type == model.ChannelTypePrivate { + permission = model.PermissionManagePrivateChannelMembers + } else { + permission = model.PermissionManagePublicChannelMembers + } + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), ChannelIDForMemberCount, permission) { + c.SetPermissionError(permission) + return + } + opts.IncludeChannelMemberCount = ChannelIDForMemberCount } sinceString := r.URL.Query().Get("since") @@ -1301,8 +1328,6 @@ func deleteGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { // // err := licensedAndConfiguredForGroupBySource(c.App, group.Source) // err.Where = "Api4.getGroup" -// -// Temporarily, this function also checks for the CustomGroups feature flag. func licensedAndConfiguredForGroupBySource(app app.AppIface, source model.GroupSource) *model.AppError { lic := app.Srv().License() @@ -1318,7 +1343,7 @@ func licensedAndConfiguredForGroupBySource(app app.AppIface, source model.GroupS return model.NewAppError("", "api.custom_groups.license_error", nil, "", http.StatusBadRequest) } - if source == model.GroupSourceCustom && (!app.Config().FeatureFlags.CustomGroups || !*app.Config().ServiceSettings.EnableCustomGroups) { + if source == model.GroupSourceCustom && !*app.Config().ServiceSettings.EnableCustomGroups { return model.NewAppError("", "api.custom_groups.feature_disabled", nil, "", http.StatusBadRequest) } diff --git a/api4/group_test.go b/api4/group_test.go index 468e043d4e..a612291d8c 100644 --- a/api4/group_test.go +++ b/api4/group_test.go @@ -1291,6 +1291,28 @@ func TestGetGroups(t *testing.T) { assert.Len(t, groups, 1) assert.Equal(t, groups[0].Id, group2.Id) + // Test IncludeChannelMemberCount url param is working + opts.IncludeChannelMemberCount = th.BasicChannel.Id + opts.IncludeTimezones = true + opts.Q = "-fOo" + opts.IncludeMemberCount = true + + groups, _, _ = th.SystemAdminClient.GetGroups(opts) + assert.Equal(t, *groups[0].MemberCount, int(0)) + assert.Equal(t, *groups[0].ChannelMemberCount, int(0)) + + _, appErr = th.App.UpsertGroupMember(group2.Id, th.BasicUser.Id) + assert.Nil(t, appErr) + + groups, _, _ = th.SystemAdminClient.GetGroups(opts) + assert.NotNil(t, groups[0].MemberCount) + assert.Equal(t, *groups[0].ChannelMemberCount, int(1)) + + opts.IncludeChannelMemberCount = "" + opts.IncludeTimezones = false + opts.Q = "" + opts.IncludeMemberCount = false + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomGroups = false }) diff --git a/api4/hosted_customer.go b/api4/hosted_customer.go new file mode 100644 index 0000000000..6e696967c6 --- /dev/null +++ b/api4/hosted_customer.go @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +// APIs for self-hosted workspaces to communicate with the backing customer & payments system. +// Endpoints for cloud installations should not go in this file. +func (api *API) InitHostedCustomer() { + + // POST /api/v4/hosted_customer/bootstrap + api.BaseRoutes.HostedCustomer.Handle("/bootstrap", api.APISessionRequired(selfHostedBootstrap)).Methods("POST") +} + +func ensureSelfHostedAdmin(c *Context, where string) { + license := c.App.Channels().License() + + if license.IsCloud() { + c.Err = model.NewAppError(where, "api.cloud.license_error", nil, "Cloud installations do not use this endpoint", http.StatusBadRequest) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { + c.SetPermissionError(model.PermissionSysconsoleWriteBilling) + return + } +} + +func checkSelfHostedFirstTimePurchaseEnabled(c *Context) bool { + config := c.App.Config() + if config == nil { + return false + } + enabled := config.ServiceSettings.SelfHostedFirstTimePurchase + return enabled != nil && *enabled +} + +func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { + where := "Api4.selfHostedBootstrap" + if !checkSelfHostedFirstTimePurchaseEnabled(c) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) + return + } + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + + user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + if userErr != nil { + c.Err = userErr + return + } + + signupProgress, err := c.App.Cloud().BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: user.Email}) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) + return + } + json, err := json.Marshal(signupProgress) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) + return + } + + w.Write(json) +} diff --git a/api4/hosted_customer_test.go b/api4/hosted_customer_test.go new file mode 100644 index 0000000000..6eff1e922d --- /dev/null +++ b/api4/hosted_customer_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" + "github.com/mattermost/mattermost-server/v6/model" +) + +var valFalse = false +var valTrue = true + +func TestSelfHostedBootstrap(t *testing.T) { + t.Run("feature flag off returns not implemented", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valFalse }) + th.App.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusNotImplemented, r.StatusCode) + require.Error(t, err) + }) + + t.Run("cloud instances not allowed to bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusBadRequest, r.StatusCode) + require.Error(t, err) + }) + + t.Run("non-admins not allowed to bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.Error(t, err) + }) + + t.Run("self-hosted admins can bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.ReloadConfig() + cloud := mocks.CloudInterface{} + + cloud.Mock.On("BootstrapSelfHostedSignup", mock.Anything).Return(&model.BootstrapSelfHostedSignupResponse{Progress: "START"}, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + response, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, err) + require.Equal(t, "START", response.Progress) + }) +} diff --git a/api4/insights.go b/api4/insights.go index c5bfa018ee..dd9e204eae 100644 --- a/api4/insights.go +++ b/api4/insights.go @@ -89,13 +89,10 @@ func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Requ return } - js, err := json.Marshal(topReactionList) - if err != nil { - c.Err = model.NewAppError("getTopReactionsForTeamSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + if err := json.NewEncoder(w).Encode(topReactionList); err != nil { + c.Err = model.NewAppError("getTopReactionsForTeamSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { @@ -149,13 +146,10 @@ func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Requ return } - js, err := json.Marshal(topReactionList) - if err != nil { - c.Err = model.NewAppError("getTopReactionsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + if err := json.NewEncoder(w).Encode(topReactionList); err != nil { + c.Err = model.NewAppError("getTopReactionsForUserSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } // Top Channels @@ -218,13 +212,10 @@ func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reque return } - js, err := json.Marshal(topChannels) - if err != nil { - c.Err = model.NewAppError("getTopChannelsForTeamSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + if err := json.NewEncoder(w).Encode(topChannels); err != nil { + c.Err = model.NewAppError("getTopChannelsForTeamSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { @@ -285,13 +276,10 @@ func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Reque return } - js, jsonErr := json.Marshal(topChannels) - if jsonErr != nil { - c.Err = model.NewAppError("getTopChannelsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + if err := json.NewEncoder(w).Encode(topChannels); err != nil { + c.Err = model.NewAppError("getTopChannelsForUserSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } // Top Threads @@ -347,13 +335,10 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques return } - js, jsonError := json.Marshal(topThreads) - if jsonError != nil { - c.Err = model.NewAppError("getTopThreadsForTeamSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + if err := json.NewEncoder(w).Encode(topThreads); err != nil { + c.Err = model.NewAppError("getTopThreadsForTeamSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { @@ -407,13 +392,10 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques return } - js, jsonErr := json.Marshal(topThreads) - if jsonErr != nil { - c.Err = model.NewAppError("getTopThreadsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + if err := json.NewEncoder(w).Encode(topThreads); err != nil { + c.Err = model.NewAppError("getTopThreadsForUserSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } // Top DMs @@ -448,13 +430,10 @@ func getTopDMsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { return } - js, jsonErr := json.Marshal(topDMs) - if jsonErr != nil { - c.Err = model.NewAppError("getTopDMsForUserSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(topDMs); err != nil { + c.Err = model.NewAppError("getTopDMsForUserSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } // Top Channels @@ -651,11 +630,8 @@ func getNewTeamMembersSince(c *Context, w http.ResponseWriter, r *http.Request) ntms.TotalCount = count - js, jsonErr := json.Marshal(ntms) - if jsonErr != nil { - c.Err = model.NewAppError("getNewTeamembersForTeamSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(ntms); err != nil { + c.Err = model.NewAppError("getNewTeamembersForTeamSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } diff --git a/api4/insights_test.go b/api4/insights_test.go index e304a60a49..58a4f95d97 100644 --- a/api4/insights_test.go +++ b/api4/insights_test.go @@ -7,10 +7,12 @@ import ( "testing" "time" - "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/utils/testutils" ) // Top Reactions @@ -1157,6 +1159,41 @@ func TestNewTeamMembersSince(t *testing.T) { CheckNotImplementedStatus(t, resp) }) + t.Run("includes all data for the user", func(t *testing.T) { + checkUser := func(ntm *model.NewTeamMember, hasProfilePicture bool) { + require.Equal(t, th.BasicUser.Id, ntm.Id) + require.Equal(t, th.BasicUser.Username, ntm.Username) + require.Equal(t, th.BasicUser.FirstName, ntm.FirstName) + require.Equal(t, th.BasicUser.LastName, ntm.LastName) + require.Equal(t, th.BasicUser.Position, ntm.Position) + require.Equal(t, th.BasicUser.Nickname, ntm.Nickname) + member, err := th.App.GetTeamMember(team.Id, th.BasicUser.Id) + require.Nil(t, err) + require.Equal(t, member.CreateAt, ntm.CreateAt) + if hasProfilePicture { + require.Truef(t, ntm.LastPictureUpdate > int64(0), "should be greater than 0, but was %d", ntm.LastPictureUpdate) + } else { + require.Equal(t, int64(0), ntm.LastPictureUpdate) + } + } + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + list, resp, err := th.Client.GetNewTeamMembersSince(team.Id, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + CheckOKStatus(t, resp) + checkUser(list.Items[0], false) + + data, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) + _, err = th.Client.SetProfileImage(th.BasicUser.Id, data) + require.NoError(t, err) + + list, resp, err = th.Client.GetNewTeamMembersSince(team.Id, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + CheckOKStatus(t, resp) + checkUser(list.Items[0], true) + }) + t.Run("implements pagination", func(t *testing.T) { // check the first page of results list, resp, err := th.Client.GetNewTeamMembersSince(team.Id, model.TimeRangeToday, 0, 2) diff --git a/api4/license.go b/api4/license.go index 15c0b34fa8..bc89f7e1e2 100644 --- a/api4/license.go +++ b/api4/license.go @@ -139,7 +139,7 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { if c.App.Channels().License().IsCloud() { // If cloud, invalidate the caches when a new license is loaded - defer c.App.Srv().Cloud.InvalidateCaches() + defer c.App.Srv().Cloud.HandleLicenseChange() } auditRec.Success() diff --git a/api4/post.go b/api4/post.go index 05ca87b6c8..ff707c178c 100644 --- a/api4/post.go +++ b/api4/post.go @@ -23,6 +23,7 @@ func (api *API) InitPost() { api.BaseRoutes.Posts.Handle("/ids", api.APISessionRequired(getPostsByIds)).Methods("POST") api.BaseRoutes.Posts.Handle("/ephemeral", api.APISessionRequired(createEphemeralPost)).Methods("POST") api.BaseRoutes.Post.Handle("/thread", api.APISessionRequired(getPostThread)).Methods("GET") + api.BaseRoutes.Post.Handle("/info", api.APISessionRequired(getPostInfo)).Methods("GET") api.BaseRoutes.Post.Handle("/files/info", api.APISessionRequired(getFileInfosForPost)).Methods("GET") api.BaseRoutes.PostsForChannel.Handle("", api.APISessionRequired(getPostsForChannel)).Methods("GET") api.BaseRoutes.PostsForUser.Handle("/flagged", api.APISessionRequired(getFlaggedPostsForUser)).Methods("GET") @@ -38,6 +39,9 @@ func (api *API) InitPost() { api.BaseRoutes.Post.Handle("/pin", api.APISessionRequired(pinPost)).Methods("POST") api.BaseRoutes.Post.Handle("/unpin", api.APISessionRequired(unpinPost)).Methods("POST") + + api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(acknowledgePost)).Methods("POST") + api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(unacknowledgePost)).Methods("DELETE") } func createPost(c *Context, w http.ResponseWriter, r *http.Request) { @@ -99,7 +103,7 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { c.App.SetStatusOnline(c.AppContext.Session().UserId, false) } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) c.ExtendSessionExpiryIfNeeded(w, r) w.WriteHeader(http.StatusCreated) @@ -141,7 +145,7 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) rp = model.AddPostActionCookies(rp, c.App.PostActionCookieSecret()) - rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, true, false) + rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, true, false, true) rp, err := c.App.SanitizePostMetadataForUser(c.AppContext, rp, c.AppContext.Session().UserId) if err != nil { c.Err = err @@ -420,7 +424,7 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) { return } - post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, false, false) + post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, false, false, true) post, err = c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId) if err != nil { c.Err = err @@ -479,7 +483,7 @@ func getPostsByIds(c *Context, w http.ResponseWriter, r *http.Request) { } } - post = c.App.PreparePostForClient(c.AppContext, post, false, false) + post = c.App.PreparePostForClient(c.AppContext, post, false, false, true) post.StripActionIntegrations() posts = append(posts, post) } @@ -941,6 +945,80 @@ func unpinPost(c *Context, w http.ResponseWriter, _ *http.Request) { saveIsPinnedPost(c, w, false) } +func acknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { + // license check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequirePostId().RequireUserId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + acknowledgement, appErr := c.App.SaveAcknowledgementForPost(c.AppContext, c.Params.PostId, c.Params.UserId) + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(acknowledgement) + if err != nil { + c.Err = model.NewAppError("acknowledgePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(js) +} + +func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { + // license check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequirePostId().RequireUserId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + _, err := c.App.GetSinglePost(c.Params.PostId, false) + if err != nil { + c.Err = err + return + } + + appErr := c.App.DeleteAcknowledgementForPost(c.AppContext, c.Params.PostId, c.Params.UserId) + if appErr != nil { + c.Err = appErr + return + } + + ReturnStatusOK(w) +} + func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { c.RequirePostId() if c.Err != nil { @@ -978,3 +1056,24 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set(model.HeaderEtagServer, model.GetEtagForFileInfos(infos)) w.Write(js) } + +func getPostInfo(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequirePostId() + if c.Err != nil { + return + } + + info, appErr := c.App.GetPostInfo(c.AppContext, c.Params.PostId) + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(info) + if err != nil { + c.Err = model.NewAppError("getPostInfo", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(js) +} diff --git a/api4/post_test.go b/api4/post_test.go index 86b064191e..8594fae136 100644 --- a/api4/post_test.go +++ b/api4/post_test.go @@ -3342,3 +3342,343 @@ func TestPostReminder(t *testing.T) { require.Truef(t, caught, "User should have received %s event", model.WebsocketEventEphemeralMessage) } + +func TestPostGetInfo(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + client := th.Client + sysadminClient := th.SystemAdminClient + sysadminClient.AddTeamMember(th.BasicTeam.Id, th.SystemAdminUser.Id) + + openChannel, _, err := client.CreateChannel(&model.Channel{TeamId: th.BasicTeam.Id, Type: model.ChannelTypeOpen, Name: "open-channel", DisplayName: "Open Channel"}) + require.NoError(t, err) + sysadminClient.AddChannelMember(openChannel.Id, th.SystemAdminUser.Id) + openPost, _, err := client.CreatePost(&model.Post{ChannelId: openChannel.Id}) + require.NoError(t, err) + + privateChannel, _, err := sysadminClient.CreateChannel(&model.Channel{TeamId: th.BasicTeam.Id, Type: model.ChannelTypePrivate, Name: "private-channel", DisplayName: "Private Channel"}) + require.NoError(t, err) + privatePost, _, err := sysadminClient.CreatePost(&model.Post{ChannelId: privateChannel.Id}) + require.NoError(t, err) + + privateChannelBasicUser, _, err := client.CreateChannel(&model.Channel{TeamId: th.BasicTeam.Id, Type: model.ChannelTypePrivate, Name: "private-channel-basic-user", DisplayName: "Private Channel - Basic User"}) + require.NoError(t, err) + privatePostBasicUser, _, err := client.CreatePost(&model.Post{ChannelId: privateChannelBasicUser.Id}) + require.NoError(t, err) + + user3 := th.CreateUser() + gmChannel, _, err := client.CreateGroupChannel([]string{th.BasicUser.Id, th.BasicUser2.Id, user3.Id}) + require.NoError(t, err) + gmPost, _, err := client.CreatePost(&model.Post{ChannelId: gmChannel.Id}) + require.NoError(t, err) + + dmChannel, _, err := client.CreateDirectChannel(th.BasicUser.Id, th.BasicUser2.Id) + require.NoError(t, err) + dmPost, _, err := client.CreatePost(&model.Post{ChannelId: dmChannel.Id}) + require.NoError(t, err) + + openTeam, _, err := sysadminClient.CreateTeam(&model.Team{Type: model.TeamOpen, Name: "open-team", DisplayName: "Open Team"}) + require.NoError(t, err) + openTeamOpenChannel, _, err := sysadminClient.CreateChannel(&model.Channel{TeamId: openTeam.Id, Type: model.ChannelTypeOpen, Name: "open-team-open-channel", DisplayName: "Open Team - Open Channel"}) + require.NoError(t, err) + openTeamOpenPost, _, err := sysadminClient.CreatePost(&model.Post{ChannelId: openTeamOpenChannel.Id}) + require.NoError(t, err) + + // Alt team is a team without the sysadmin in it. + altOpenTeam, _, err := client.CreateTeam(&model.Team{Type: model.TeamOpen, Name: "alt-open-team", DisplayName: "Alt Open Team"}) + require.NoError(t, err) + altOpenTeamOpenChannel, _, err := client.CreateChannel(&model.Channel{TeamId: altOpenTeam.Id, Type: model.ChannelTypeOpen, Name: "alt-open-team-open-channel", DisplayName: "Open Team - Open Channel"}) + require.NoError(t, err) + altOpenTeamOpenPost, _, err := client.CreatePost(&model.Post{ChannelId: altOpenTeamOpenChannel.Id}) + require.NoError(t, err) + + inviteTeam, _, err := sysadminClient.CreateTeam(&model.Team{Type: model.TeamInvite, Name: "invite-team", DisplayName: "Invite Team"}) + require.NoError(t, err) + inviteTeamOpenChannel, _, err := sysadminClient.CreateChannel(&model.Channel{TeamId: inviteTeam.Id, Type: model.ChannelTypeOpen, Name: "invite-team-open-channel", DisplayName: "Invite Team - Open Channel"}) + require.NoError(t, err) + inviteTeamOpenPost, _, err := sysadminClient.CreatePost(&model.Post{ChannelId: inviteTeamOpenChannel.Id}) + require.NoError(t, err) + + testCases := []struct { + name string + team *model.Team + hasJoinedTeam bool + channel *model.Channel + hasJoinedChannel bool + post *model.Post + client *model.Client4 + hasAccess bool + }{ + // Open channel - Current Team + { + name: "Open post - Current team - Basic user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: openChannel, + hasJoinedChannel: true, + post: openPost, + client: client, + hasAccess: true, + }, + { + name: "Open post - Current team - Sysadmin user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: openChannel, + hasJoinedChannel: true, + post: openPost, + client: sysadminClient, + hasAccess: true, + }, + + // Private channel - Current Team + { + name: "Private post by sysadmin - Current team - Basic user", + team: th.BasicTeam, + channel: privateChannel, + post: privatePost, + client: client, + hasAccess: false, + }, + { + name: "Private post by sysadmin - Current team - Sysadmin user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: privateChannel, + hasJoinedChannel: true, + post: privatePost, + client: sysadminClient, + hasAccess: true, + }, + { + name: "Private post by basic user - Current team - Basic user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: privateChannelBasicUser, + hasJoinedChannel: true, + post: privatePostBasicUser, + client: client, + hasAccess: true, + }, + { + name: "Private post by basic user - Current team - Sysadmin user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: privateChannelBasicUser, + hasJoinedChannel: false, + post: privatePostBasicUser, + client: sysadminClient, + hasAccess: true, + }, + + // GM channel + { + name: "GM post - Current team - Basic user", + team: nil, + channel: gmChannel, + hasJoinedChannel: true, + post: gmPost, + client: client, + hasAccess: true, + }, + { + name: "GM post - Current team - Sysadmin user", + team: nil, + channel: gmChannel, + post: gmPost, + client: sysadminClient, + hasAccess: false, + }, + + // DM channel + { + name: "DM post - Current team - Basic user", + team: nil, + channel: dmChannel, + hasJoinedChannel: true, + post: dmPost, + client: client, + hasAccess: true, + }, + { + name: "DM post - Current team - Sysadmin user", + team: nil, + channel: dmChannel, + post: dmPost, + client: sysadminClient, + hasAccess: false, + }, + + // Open channel - Open Team + { + name: "Open post - Open team - Basic user", + team: openTeam, + hasJoinedTeam: false, + channel: openTeamOpenChannel, + hasJoinedChannel: false, + post: openTeamOpenPost, + client: client, + hasAccess: true, + }, + { + name: "Open post - Open team - Sysadmin user", + team: openTeam, + hasJoinedTeam: true, + channel: openTeamOpenChannel, + hasJoinedChannel: true, + post: openTeamOpenPost, + client: sysadminClient, + hasAccess: true, + }, + + // Open channel - Alt Open Team + { + name: "Open post - Alt open team - Basic user", + team: altOpenTeam, + hasJoinedTeam: true, + channel: altOpenTeamOpenChannel, + hasJoinedChannel: true, + post: altOpenTeamOpenPost, + client: client, + hasAccess: true, + }, + { + name: "Open post - Alt open team - Sysadmin user", + team: altOpenTeam, + hasJoinedTeam: false, + channel: altOpenTeamOpenChannel, + hasJoinedChannel: false, + post: altOpenTeamOpenPost, + client: sysadminClient, + hasAccess: true, + }, + + // Open channel - Invite Team + { + name: "Open post - Invite team - Basic user", + team: inviteTeam, + channel: inviteTeamOpenChannel, + post: inviteTeamOpenPost, + client: client, + hasAccess: false, + }, + { + name: "Open post - Invite team - Sysadmin user", + team: inviteTeam, + hasJoinedTeam: true, + channel: inviteTeamOpenChannel, + hasJoinedChannel: true, + post: inviteTeamOpenPost, + client: sysadminClient, + hasAccess: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + info, resp, err := tc.client.GetPostInfo(tc.post.Id) + if !tc.hasAccess { + require.Error(t, err) + CheckNotFoundStatus(t, resp) + return + } + + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Equal(t, tc.channel.Id, info.ChannelId) + require.Equal(t, tc.channel.Type, info.ChannelType) + require.Equal(t, tc.channel.DisplayName, info.ChannelDisplayName) + require.Equal(t, tc.hasJoinedChannel, info.HasJoinedChannel) + if tc.team != nil { + require.Equal(t, tc.team.Id, info.TeamId) + require.Equal(t, tc.team.Type, info.TeamType) + require.Equal(t, tc.team.DisplayName, info.TeamDisplayName) + require.Equal(t, tc.hasJoinedTeam, info.HasJoinedTeam) + } + }) + } +} + +func TestAcknowledgePost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + client := th.Client + + post := th.BasicPost + ack, _, err := client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 1) + require.Equal(t, acks[0], ack) + + _, resp, err := client.AcknowledgePost("junk", th.BasicUser.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.AcknowledgePost(GenerateTestId(), th.BasicUser.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + _, resp, err = client.AcknowledgePost(post.Id, "junk") + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.AcknowledgePost(post.Id, th.BasicUser2.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + client.Logout() + _, resp, err = client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) + + _, _, err = th.SystemAdminClient.AcknowledgePost(post.Id, th.SystemAdminUser.Id) + require.NoError(t, err) +} + +func TestUnacknowledgePost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + client := th.Client + + post := th.BasicPost + ack, _, err := client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 1) + require.Equal(t, acks[0], ack) + + resp, err := client.UnacknowledgePost("junk", th.BasicUser.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + resp, err = client.UnacknowledgePost(GenerateTestId(), th.BasicUser.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + resp, err = client.UnacknowledgePost(post.Id, "junk") + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + resp, err = client.UnacknowledgePost(post.Id, th.BasicUser2.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + _, err = client.UnacknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 0) + + client.Logout() + resp, err = client.UnacknowledgePost(post.Id, th.BasicUser.Id) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) +} diff --git a/api4/resolver_channel_member_test.go b/api4/resolver_channel_member_test.go index d4278145e7..96d5da7806 100644 --- a/api4/resolver_channel_member_test.go +++ b/api4/resolver_channel_member_test.go @@ -61,17 +61,18 @@ func TestGraphQLChannelMembers(t *testing.T) { SchemeManaged bool `json:"schemeManaged"` BuiltIn bool `json:"builtIn"` } `json:"roles"` - LastViewedAt float64 `json:"lastViewedAt"` - LastUpdateAt float64 `json:"lastUpdateAt"` - MsgCount float64 `json:"msgCount"` - MentionCount float64 `json:"mentionCount"` - MentionCountRoot float64 `json:"mentionCountRoot"` - MsgCountRoot float64 `json:"msgCountRoot"` - NotifyProps model.StringMap `json:"notifyProps"` - SchemeGuest bool `json:"schemeGuest"` - SchemeUser bool `json:"schemeUser"` - SchemeAdmin bool `json:"schemeAdmin"` - Cursor string `json:"cursor"` + LastViewedAt float64 `json:"lastViewedAt"` + LastUpdateAt float64 `json:"lastUpdateAt"` + MsgCount float64 `json:"msgCount"` + MentionCount float64 `json:"mentionCount"` + MentionCountRoot float64 `json:"mentionCountRoot"` + UrgentMentionCount float64 `json:"urgentMentionCount"` + MsgCountRoot float64 `json:"msgCountRoot"` + NotifyProps model.StringMap `json:"notifyProps"` + SchemeGuest bool `json:"schemeGuest"` + SchemeUser bool `json:"schemeUser"` + SchemeAdmin bool `json:"schemeAdmin"` + Cursor string `json:"cursor"` } `json:"channelMembers"` } @@ -101,6 +102,7 @@ func TestGraphQLChannelMembers(t *testing.T) { msgCount mentionCount mentionCountRoot + urgentMentionCount msgCountRoot schemeGuest schemeUser @@ -181,6 +183,7 @@ func TestGraphQLChannelMembers(t *testing.T) { msgCount mentionCount mentionCountRoot + urgentMentionCount } } `, diff --git a/api4/resolver_user.go b/api4/resolver_user.go index a1058054cd..0d962a3fe0 100644 --- a/api4/resolver_user.go +++ b/api4/resolver_user.go @@ -56,7 +56,7 @@ func getGraphQLUser(ctx context.Context, id string) (*user, error) { } } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) return &user{*usr}, nil } diff --git a/api4/schema.graphqls b/api4/schema.graphqls index 5af4d4d87c..e277832f04 100644 --- a/api4/schema.graphqls +++ b/api4/schema.graphqls @@ -69,21 +69,22 @@ type Channel { } type ChannelMember { - channel : Channel - user : User - roles : [Role]! - lastViewedAt : Float! - msgCount : Float! - mentionCount : Float! - mentionCountRoot : Float! - msgCountRoot : Float! - notifyProps : StringMap! - lastUpdateAt : Float! - schemeGuest : Boolean! - schemeUser : Boolean! - schemeAdmin : Boolean! - explicitRoles : String! - cursor: String + channel : Channel + user : User + roles : [Role]! + lastViewedAt : Float! + msgCount : Float! + mentionCount : Float! + urgentMentionCount: Float! + mentionCountRoot : Float! + msgCountRoot : Float! + notifyProps : StringMap! + lastUpdateAt : Float! + schemeGuest : Boolean! + schemeUser : Boolean! + schemeAdmin : Boolean! + explicitRoles : String! + cursor : String } # Deliberately omitting password, authData, mfaSecret. diff --git a/api4/scheme.go b/api4/scheme.go index efb5f781b0..82421ee610 100644 --- a/api4/scheme.go +++ b/api4/scheme.go @@ -33,7 +33,7 @@ func createScheme(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) auditRec.AddEventParameter("scheme", scheme) - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.CustomPermissionsSchemes { + if c.App.Channels().License() == nil || (!*c.App.Channels().License().Features.CustomPermissionsSchemes && c.App.Channels().License().SkuShortName != model.LicenseShortSkuProfessional) { c.Err = model.NewAppError("Api4.CreateScheme", "api.scheme.create_scheme.license.error", nil, "", http.StatusNotImplemented) return } @@ -194,7 +194,7 @@ func patchScheme(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventParameter("scheme_patch", patch) defer c.LogAuditRec(auditRec) - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.CustomPermissionsSchemes { + if c.App.Channels().License() == nil || (!*c.App.Channels().License().Features.CustomPermissionsSchemes && c.App.Channels().License().SkuShortName != model.LicenseShortSkuProfessional) { c.Err = model.NewAppError("Api4.PatchScheme", "api.scheme.patch_scheme.license.error", nil, "", http.StatusNotImplemented) return } @@ -239,7 +239,7 @@ func deleteScheme(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventParameter("scheme_id", c.Params.SchemeId) defer c.LogAuditRec(auditRec) - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.CustomPermissionsSchemes { + if c.App.Channels().License() == nil || (!*c.App.Channels().License().Features.CustomPermissionsSchemes && c.App.Channels().License().SkuShortName != model.LicenseShortSkuProfessional) { c.Err = model.NewAppError("Api4.DeleteScheme", "api.scheme.delete_scheme.license.error", nil, "", http.StatusNotImplemented) return } diff --git a/api4/scheme_test.go b/api4/scheme_test.go index 8fda68d19f..ba478a243e 100644 --- a/api4/scheme_test.go +++ b/api4/scheme_test.go @@ -150,6 +150,31 @@ func TestCreateScheme(t *testing.T) { _, r6, _ := th.SystemAdminClient.CreateScheme(scheme6) CheckNotImplementedStatus(t, r6) + // Create scheme with a Professional SKU license but no explicit 'custom_permissions_schemes' license feature. + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuProfessional, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + th.App.Srv().SetLicense(lic) + scheme6b := &model.Scheme{ + DisplayName: model.NewId(), + Name: model.NewId(), + Description: model.NewId(), + Scope: model.SchemeScopeTeam, + } + _, resp, err := th.SystemAdminClient.CreateScheme(scheme6b) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + th.App.SetPhase2PermissionsMigrationStatus(false) th.LoginSystemAdmin() @@ -579,6 +604,24 @@ func TestPatchScheme(t *testing.T) { _, r11, _ := th.SystemAdminClient.PatchScheme(s6.Id, schemePatch) CheckNotImplementedStatus(t, r11) + // Patch scheme with a Professional SKU license but no explicit 'custom_permissions_schemes' license feature. + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuProfessional, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + th.App.Srv().SetLicense(lic) + _, _, err = th.SystemAdminClient.PatchScheme(s6.Id, schemePatch) + require.NoError(t, err) + th.App.SetPhase2PermissionsMigrationStatus(false) th.LoginSystemAdmin() @@ -745,6 +788,15 @@ func TestDeleteScheme(t *testing.T) { s1, _, err := th.SystemAdminClient.CreateScheme(scheme1) require.NoError(t, err) + scheme2 := &model.Scheme{ + DisplayName: model.NewId(), + Name: model.NewId(), + Description: model.NewId(), + Scope: model.SchemeScopeChannel, + } + s2, _, err := th.SystemAdminClient.CreateScheme(scheme2) + require.NoError(t, err) + // Test with unknown ID. r2, err := th.SystemAdminClient.DeleteScheme(model.NewId()) require.Error(t, err) @@ -766,6 +818,24 @@ func TestDeleteScheme(t *testing.T) { require.Error(t, err) CheckNotImplementedStatus(t, r5) + // Delete scheme with a Professional SKU license but no explicit 'custom_permissions_schemes' license feature. + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuProfessional, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + th.App.Srv().SetLicense(lic) + _, err = th.SystemAdminClient.DeleteScheme(s2.Id) + require.NoError(t, err) + th.App.SetPhase2PermissionsMigrationStatus(false) th.App.Srv().SetLicense(model.NewTestLicense("custom_permissions_schemes")) diff --git a/api4/team.go b/api4/team.go index a4d687f1ff..08b520ebe2 100644 --- a/api4/team.go +++ b/api4/team.go @@ -407,6 +407,10 @@ func regenerateTeamInviteId(c *Context, w http.ResponseWriter, r *http.Request) c.App.SanitizeTeam(*c.AppContext.Session(), patchedTeam) + if !*c.App.Config().PrivacySettings.ShowEmailAddress && !c.IsSystemAdmin() { + patchedTeam.Email = "" + } + auditRec.Success() auditRec.AddEventResultState(patchedTeam) auditRec.AddEventObjectType("team") @@ -493,6 +497,12 @@ func getTeamsForUser(c *Context, w http.ResponseWriter, r *http.Request) { c.App.SanitizeTeams(*c.AppContext.Session(), teams) + if !*c.App.Config().PrivacySettings.ShowEmailAddress && !c.IsSystemAdmin() { + for _, team := range teams { + team.Email = "" + } + } + js, err := json.Marshal(teams) if err != nil { c.Err = model.NewAppError("getTeamsForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) diff --git a/api4/team_test.go b/api4/team_test.go index 04cca44f71..4ffe1db335 100644 --- a/api4/team_test.go +++ b/api4/team_test.go @@ -871,11 +871,21 @@ func TestRegenerateTeamInviteId(t *testing.T) { assert.NotEqual(t, team.InviteId, "") assert.NotEqual(t, team.InviteId, "inviteid0") + *th.App.Config().PrivacySettings.ShowEmailAddress = true rteam, _, err := client.RegenerateTeamInviteId(team.Id) require.NoError(t, err) assert.NotEqual(t, team.InviteId, rteam.InviteId) assert.NotEqual(t, team.InviteId, "") + assert.NotEqual(t, rteam.Email, "") + + *th.App.Config().PrivacySettings.ShowEmailAddress = false + rteam, _, err = client.RegenerateTeamInviteId(team.Id) + require.NoError(t, err) + + assert.NotEqual(t, team.InviteId, rteam.InviteId) + assert.NotEqual(t, team.InviteId, "") + assert.Equal(t, rteam.Email, "") } func TestSoftDeleteTeam(t *testing.T) { @@ -1819,6 +1829,17 @@ func TestGetTeamsForUserSanitization(t *testing.T) { require.NotEmpty(t, rteam.Email, "should not have sanitized email") require.NotEmpty(t, rteam.InviteId, "should have not sanitized inviteid") } + *th.App.Config().PrivacySettings.ShowEmailAddress = false + rteams, _, err2 := th.Client.GetTeamsForUser(th.BasicUser.Id, "") + require.NoError(t, err2) + for _, rteam := range rteams { + if rteam.Id != team.Id && rteam.Id != team2.Id { + continue + } + + require.Empty(t, rteam.Email, "should have sanitized email") + require.NotEmpty(t, rteam.InviteId, "should have not sanitized inviteid") + } }) t.Run("system admin", func(t *testing.T) { @@ -2611,6 +2632,51 @@ func TestRemoveTeamMember(t *testing.T) { require.NoError(t, err) } +func TestRemoveTeamMemberEvents(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + client1 := th.CreateClient() + th.LoginBasicWithClient(client1) + WebSocketClient, err := th.CreateWebSocketClientWithClient(client1) + require.NoError(t, err) + defer WebSocketClient.Close() + WebSocketClient.Listen() + resp := <-WebSocketClient.ResponseChannel + require.Equal(t, resp.Status, model.StatusOk) + + client2 := th.CreateClient() + th.LoginBasic2WithClient(client2) + WebSocketClient2, err := th.CreateWebSocketClientWithClient(client2) + require.NoError(t, err) + defer WebSocketClient2.Close() + WebSocketClient2.Listen() + resp = <-WebSocketClient2.ResponseChannel + require.Equal(t, resp.Status, model.StatusOk) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + // remove second user from basic team + _, err := client.RemoveTeamMember(th.BasicTeam.Id, th.BasicUser2.Id) + require.NoError(t, err) + + assertExpectedWebsocketEvent(t, WebSocketClient, model.WebsocketEventLeaveTeam, func(event *model.WebSocketEvent) { + eventUserId, ok := event.GetData()["user_id"].(string) + require.True(t, ok, "expected user") + // assert eventUser.Id is same as th.BasicUser.Id + assert.Equal(t, eventUserId, th.BasicUser2.Id) + // assert this event doesn't go to event creator + assert.Equal(t, event.GetBroadcast().OmitUsers[eventUserId], true) + }) + assertExpectedWebsocketEvent(t, WebSocketClient2, model.WebsocketEventLeaveTeam, func(event *model.WebSocketEvent) { + eventUserId, ok := event.GetData()["user_id"].(string) + require.True(t, ok, "expected user") + // assert eventUser.Id is same as th.BasicUser.Id + assert.Equal(t, eventUserId, th.BasicUser2.Id) + }) + }) + +} + func TestGetTeamStats(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/api4/upload.go b/api4/upload.go index e5800df9b3..a89bc1149c 100644 --- a/api4/upload.go +++ b/api4/upload.go @@ -65,6 +65,13 @@ func createUpload(c *Context, w http.ResponseWriter, r *http.Request) { if c.AppContext.Session().UserId != "" { us.UserId = c.AppContext.Session().UserId } + + if us.FileSize > *c.App.Config().FileSettings.MaxFileSize { + c.Err = model.NewAppError("createUpload", "api.upload.create.upload_too_large.app_error", + map[string]any{"channelId": us.ChannelId}, "", http.StatusRequestEntityTooLarge) + return + } + rus, err := c.App.CreateUploadSession(c.AppContext, &us) if err != nil { c.Err = err diff --git a/api4/upload_test.go b/api4/upload_test.go index e373ecf5f3..9cf4e44b90 100644 --- a/api4/upload_test.go +++ b/api4/upload_test.go @@ -45,6 +45,17 @@ func TestCreateUpload(t *testing.T) { require.Equal(t, http.StatusForbidden, resp.StatusCode) }) + t.Run("FileSize over limit", func(t *testing.T) { + maxFileSize := *th.App.Config().FileSettings.MaxFileSize + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = us.FileSize - 1 }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize }) + us.ChannelId = th.BasicChannel.Id + u, resp, err := th.Client.CreateUpload(us) + require.Nil(t, u) + CheckErrorID(t, err, "api.upload.create.upload_too_large.app_error") + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) + }) + t.Run("not allowed in cloud", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicense("cloud")) defer th.App.Srv().RemoveLicense() diff --git a/api4/usage.go b/api4/usage.go index 3822972ba4..542c89ffa8 100644 --- a/api4/usage.go +++ b/api4/usage.go @@ -18,8 +18,6 @@ func (api *API) InitUsage() { api.BaseRoutes.Usage.Handle("/storage", api.APISessionRequired(getStorageUsage)).Methods("GET") // GET /api/v4/usage/teams api.BaseRoutes.Usage.Handle("/teams", api.APISessionRequired(getTeamsUsage)).Methods("GET") - // GET /api/v4/usage/integrations - api.BaseRoutes.Usage.Handle("/integrations", api.APISessionRequired(getIntegrationsUsage)).Methods("GET") } func getPostsUsage(c *Context, w http.ResponseWriter, r *http.Request) { @@ -74,30 +72,3 @@ func getTeamsUsage(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(json) } - -func getIntegrationsUsage(c *Context, w http.ResponseWriter, r *http.Request) { - if !*c.App.Config().PluginSettings.Enable { - json, err := json.Marshal(&model.IntegrationsUsage{}) - if err != nil { - c.Err = model.NewAppError("Api4.getIntegrationsUsage", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - w.Write(json) - return - } - - usage, appErr := c.App.GetIntegrationsUsage() - if appErr != nil { - c.Err = appErr - return - } - - json, err := json.Marshal(usage) - if err != nil { - c.Err = model.NewAppError("Api4.getIntegrationsUsage", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - w.Write(json) -} diff --git a/api4/usage_test.go b/api4/usage_test.go index 41d96ae75c..9171614cc8 100644 --- a/api4/usage_test.go +++ b/api4/usage_test.go @@ -91,28 +91,3 @@ func TestGetTeamsUsage(t *testing.T) { assert.Equal(t, int64(3), usage.Active) }) } - -func TestGetIntegrationsUsage(t *testing.T) { - t.Run("unauthenticated users can not access", func(t *testing.T) { - th := Setup(t) - defer th.TearDown() - - th.Client.Logout() - - usage, r, err := th.Client.GetIntegrationsUsage() - assert.Error(t, err) - assert.Nil(t, usage) - assert.Equal(t, http.StatusUnauthorized, r.StatusCode) - }) - - t.Run("good request returns response", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - usage, r, err := th.Client.GetIntegrationsUsage() - assert.NoError(t, err) - assert.Equal(t, http.StatusOK, r.StatusCode) - assert.NotNil(t, usage) - assert.Equal(t, 0, usage.Enabled) - }) -} diff --git a/api4/user.go b/api4/user.go index 439660e991..e0e32708b3 100644 --- a/api4/user.go +++ b/api4/user.go @@ -221,7 +221,7 @@ func getUser(c *Context, w http.ResponseWriter, r *http.Request) { } else { c.App.SanitizeProfile(user, c.IsSystemAdmin()) } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) w.Header().Set(model.HeaderEtagServer, etag) if err := json.NewEncoder(w).Encode(user); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) @@ -693,14 +693,44 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidURLParam("inactive") } + roleNamesAll := []string{} + // MM-47378: validate 'role' related parameters + if role != "" || rolesString != "" || channelRolesString != "" || teamRolesString != "" { + // fetch all role names + rolesAll, err := c.App.GetAllRoles() + if err != nil { + c.Err = model.NewAppError("Api4.getUsers", "api.user.get_users.validation.app_error", nil, "Error fetching roles during validation.", http.StatusBadRequest) + return + } + for _, role := range rolesAll { + roleNamesAll = append(roleNamesAll, role.Name) + } + } roles := []string{} var rolesValid bool + if role != "" { + roles, rolesValid = model.CleanRoleNames([]string{role}) + if !rolesValid { + c.SetInvalidParam("role") + return + } + roleValid := utils.StringInSlice(role, roleNamesAll) + if !roleValid { + c.SetInvalidParam("role") + return + } + } if rolesString != "" { roles, rolesValid = model.CleanRoleNames(strings.Split(rolesString, ",")) if !rolesValid { c.SetInvalidParam("roles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, roles) + if len(validRoleNames) != len(roles) { + c.SetInvalidParam("roles") + return + } } channelRoles := []string{} if channelRolesString != "" && inChannelId != "" { @@ -709,6 +739,11 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidParam("channelRoles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, channelRoles) + if len(validRoleNames) != len(channelRoles) { + c.SetInvalidParam("channelRoles") + return + } } teamRoles := []string{} if teamRolesString != "" && inTeamId != "" { @@ -717,6 +752,11 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidParam("teamRoles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, teamRoles) + if len(validRoleNames) != len(teamRoles) { + c.SetInvalidParam("teamRoles") + return + } } restrictions, appErr := c.App.GetViewUsersRestrictions(c.AppContext.Session().UserId) @@ -864,7 +904,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { if etag != "" { w.Header().Set(model.HeaderEtagServer, etag) } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) js, err := json.Marshal(profiles) if err != nil { @@ -1914,6 +1954,10 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) { } func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { + campaignToURL := map[string]string{ + "focalboard": "/boards", + } + if !c.App.Channels().License().IsCloud() { c.Err = model.NewAppError("loginCWS", "api.user.login_cws.license.error", nil, "", http.StatusUnauthorized) return @@ -1921,6 +1965,7 @@ func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { r.ParseForm() var loginID string var token string + var campaign string if len(r.Form) > 0 { for key, value := range r.Form { if key == "login_id" { @@ -1929,6 +1974,9 @@ func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { if key == "cws_token" { token = value[0] } + if key == "utm_campaign" { + campaign = value[0] + } } } @@ -1952,7 +2000,14 @@ func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { } c.LogAuditWithUserId(user.Id, "success") c.App.AttachSessionCookies(c.AppContext, w, r) - http.Redirect(w, r, *c.App.Config().ServiceSettings.SiteURL, http.StatusFound) + + redirectURL := *c.App.Config().ServiceSettings.SiteURL + if campaign != "" { + if url, ok := campaignToURL[campaign]; ok { + redirectURL += url + } + } + http.Redirect(w, r, redirectURL, http.StatusFound) } func logout(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/api4/user_local.go b/api4/user_local.go index 50a451ea96..79578f2484 100644 --- a/api4/user_local.go +++ b/api4/user_local.go @@ -7,11 +7,13 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/utils" ) func (api *API) InitUserLocal() { @@ -56,7 +58,78 @@ func localGetUsers(c *Context, w http.ResponseWriter, r *http.Request) { active := r.URL.Query().Get("active") inactive := r.URL.Query().Get("inactive") role := r.URL.Query().Get("role") + rolesString := r.URL.Query().Get("roles") + channelRolesString := r.URL.Query().Get("channel_roles") + teamRolesString := r.URL.Query().Get("team_roles") sort := r.URL.Query().Get("sort") + roleNamesAll := []string{} + // MM-47378: validate 'role' related parameters + if role != "" || rolesString != "" || channelRolesString != "" || teamRolesString != "" { + // fetch all role names + rolesAll, err := c.App.GetAllRoles() + if err != nil { + c.Err = model.NewAppError("Api4.getUsers", "api.user.get_users.validation.app_error", nil, "Error fetching roles during validation.", http.StatusBadRequest) + return + } + for _, role := range rolesAll { + roleNamesAll = append(roleNamesAll, role.Name) + } + } + + var roles []string + var rolesValid bool + + if role != "" { + _, rolesValid = model.CleanRoleNames([]string{role}) + if !rolesValid { + c.SetInvalidParam("role") + return + } + roleValid := utils.StringInSlice(role, roleNamesAll) + if !roleValid { + c.SetInvalidParam("role") + return + } + } + + if rolesString != "" { + roles, rolesValid = model.CleanRoleNames(strings.Split(rolesString, ",")) + if !rolesValid { + c.SetInvalidParam("roles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, roles) + if len(validRoleNames) != len(roles) { + c.SetInvalidParam("roles") + return + } + } + var channelRoles []string + if channelRolesString != "" && inChannelId != "" { + channelRoles, rolesValid = model.CleanRoleNames(strings.Split(channelRolesString, ",")) + if !rolesValid { + c.SetInvalidParam("channelRoles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, channelRoles) + if len(validRoleNames) != len(channelRoles) { + c.SetInvalidParam("channelRoles") + return + } + } + var teamRoles []string + if teamRolesString != "" && inTeamId != "" { + teamRoles, rolesValid = model.CleanRoleNames(strings.Split(teamRolesString, ",")) + if !rolesValid { + c.SetInvalidParam("teamRoles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, teamRoles) + if len(validRoleNames) != len(teamRoles) { + c.SetInvalidParam("teamRoles") + return + } + } if notInChannelId != "" && inTeamId == "" { c.SetInvalidURLParam("team_id") diff --git a/api4/user_test.go b/api4/user_test.go index db15a06604..871f2c56e8 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -25,7 +25,7 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mail" "github.com/mattermost/mattermost-server/v6/utils/testutils" - _ "github.com/mattermost/mattermost-server/v6/model/gitlab" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" ) func TestCreateUser(t *testing.T) { @@ -2410,6 +2410,20 @@ func TestGetUsers(t *testing.T) { // Check default params for page and per_page _, err = client.DoAPIGet("/users", "") require.NoError(t, err) + + // Check role params validity + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "in_channel=random_channel_id&channel_roles=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing channelRoles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "in_team=random_channel_id&team_roles=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing teamRoles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "roles=random_role_doesnt_exist%2Csystem_user", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing roles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "role=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing role in request body.") }) th.Client.Logout() @@ -5720,10 +5734,12 @@ func TestUpdatePassword(t *testing.T) { } func TestGetThreadsForUser(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") os.Setenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS", "true") defer os.Unsetenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS") + th := Setup(t).InitBasic() + defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ThreadAutoFollow = true @@ -5820,7 +5836,49 @@ func TestGetThreadsForUser(t *testing.T) { require.NoError(t, err) require.Len(t, uss.Threads, 1) require.Greater(t, uss.Threads[0].Post.DeleteAt, int64(0)) + }) + t.Run("isUrgent, 1 thread", func(t *testing.T) { + testCases := []struct { + featureEnabled bool + expected bool + }{ + {featureEnabled: true, expected: true}, + {featureEnabled: false, expected: false}, + } + + for _, tc := range testCases { + func() { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = tc.featureEnabled + cfg.FeatureFlags.PostPriority = true + }) + + client := th.Client + + rpost, resp, err := client.CreatePost(&model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "testMsg", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + _, resp, err = client.CreatePost(&model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + + defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) + + uss, _, err := th.Client.GetUserThreads(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) + require.NoError(t, err) + require.Len(t, uss.Threads, 1) + require.Equal(t, uss.Threads[0].IsUrgent, tc.expected) + }() + } }) t.Run("paged, 30 threads", func(t *testing.T) { @@ -6515,13 +6573,20 @@ func TestThreadCounts(t *testing.T) { } func TestSingleThreadGet(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") os.Setenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS", "true") defer os.Unsetenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS") + + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ThreadAutoFollow = true + *cfg.ServiceSettings.PostPriority = false *cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true }) client := th.Client @@ -6534,7 +6599,15 @@ func TestSingleThreadGet(t *testing.T) { postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) // create another thread to check that we are not returning it by mistake - rpost2, _ := postAndCheck(t, client, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testMsg2"}) + rpost2, _ := postAndCheck(t, client, &model.Post{ + ChannelId: th.BasicChannel2.Id, + Message: "testMsg2", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }) postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testReply", RootId: rpost2.Id}) // regular user should have two threads with 3 replies total @@ -6546,9 +6619,23 @@ func TestSingleThreadGet(t *testing.T) { require.Equal(t, threads.Threads[0].PostId, tr.PostId) require.Empty(t, tr.Participants[0].Username) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = false + }) + tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) require.NoError(t, err) require.NotEmpty(t, tr.Participants[0].Username) + require.Equal(t, false, tr.IsUrgent) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true + }) + + tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) + require.NoError(t, err) + require.Equal(t, true, tr.IsUrgent) } func TestMaintainUnreadMentionsInThread(t *testing.T) { diff --git a/api4/websocket.go b/api4/websocket.go index 5f1cb2cdd3..d2c1c10f44 100644 --- a/api4/websocket.go +++ b/api4/websocket.go @@ -61,7 +61,7 @@ func connectWebSocket(c *Context, w http.ResponseWriter, r *http.Request) { } } - wc := c.App.Srv().Platform().NewWebConn(cfg, c.App, c.App.Srv().Channels().GetPluginsEnvironment) + wc := c.App.Srv().Platform().NewWebConn(cfg, c.App, c.App.Srv().Channels()) if c.AppContext.Session().UserId != "" { c.App.Srv().Platform().HubRegister(wc) } diff --git a/api4/worktemplates.go b/api4/worktemplates.go new file mode 100644 index 0000000000..37af4bfdfb --- /dev/null +++ b/api4/worktemplates.go @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func (api *API) InitWorkTemplate() { + api.BaseRoutes.WorkTemplates.Handle("/categories", api.APISessionRequired(needsWorkTemplateFeatureFlag(getWorkTemplateCategories))).Methods("GET") + api.BaseRoutes.WorkTemplates.Handle("/categories/{category}/templates", api.APISessionRequired(needsWorkTemplateFeatureFlag(getWorkTemplates))).Methods("GET") +} + +func needsWorkTemplateFeatureFlag(h handlerFunc) handlerFunc { + return func(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.WorkTemplate { + http.NotFound(w, r) + return + } + + h(c, w, r) + } +} + +func getWorkTemplateCategories(c *Context, w http.ResponseWriter, r *http.Request) { + t := c.AppContext.GetT() + + categories, appErr := c.App.GetWorkTemplateCategories(t) + if appErr != nil { + c.Err = appErr + return + } + + b, err := json.Marshal(categories) + if err != nil { + c.Err = model.NewAppError("getWorkTemplateCategories", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func getWorkTemplates(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireCategory() + if c.Err != nil { + return + } + t := c.AppContext.GetT() + + workTemplates, appErr := c.App.GetWorkTemplates(c.Params.Category, c.App.Config().FeatureFlags.ToMap(), t) + if appErr != nil { + c.Err = appErr + return + } + + b, err := json.Marshal(workTemplates) + if err != nil { + c.Err = model.NewAppError("getWorkTemplates", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} diff --git a/api4/worktemplates_test.go b/api4/worktemplates_test.go new file mode 100644 index 0000000000..6f919d321c --- /dev/null +++ b/api4/worktemplates_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/stretchr/testify/require" +) + +func TestWorkTemplateCategories(t *testing.T) { + // Setup + cleanup := setupWorktemplateFeatureFlag(t) + defer cleanup() + + th := Setup(t).InitBasic() + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = []*worktemplates.WorkTemplateCategory{ + { + ID: "test-category", + Name: "Test Category", + }, + { + ID: "test-category-2", + Name: "Test Category 2", + }, + } + + // Act + categories, _, clientErr := th.Client.GetWorktemplateCategories() + + // Assert + require.NoError(t, clientErr) + require.Len(t, categories, 2) + assert.Equal("test-category", categories[0].ID) + assert.Equal("test-category-2", categories[1].ID) +} + +func TestGetWorkTemplatesByCategory(t *testing.T) { + // Setup + cleanup := setupWorktemplateFeatureFlag(t) + defer cleanup() + + th := Setup(t).InitBasic() + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = []*worktemplates.WorkTemplateCategory{ + { + ID: "test-category", + Name: "Test Category", + }, + { + ID: "test-category-2", + Name: "Test Category 2", + }, + } + + worktemplates.OrderedWorkTemplates = []*worktemplates.WorkTemplate{ + { + ID: "test-template", + Category: "test-category", + UseCase: "Test Template", + }, + { + ID: "test-template-2", + Category: "test-category", + UseCase: "Test Template 2", + }, + { // This one should not be returned because of the feature flag + ID: "test-template-3", + Category: "test-category", + UseCase: "Test Template 3", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: "random-nonexistant-feature-flag", + Value: "true", + }, + }, + { // this one should not be returned because of the category + ID: "test-template-4", + Category: "test-category-2", + UseCase: "Test Template 4", + }, + } + + // Act + workTemplates, _, clientErr := th.Client.GetWorkTemplatesByCategory("test-category") + + // Assert + assert.NoError(clientErr, "error while retrieve worktemplates list") + assert.Len(workTemplates, 2) + assert.Equal("test-template", workTemplates[0].ID) + assert.Equal("test-template-2", workTemplates[1].ID) +} + +func setupWorktemplateFeatureFlag(t *testing.T) func() { + t.Helper() + + oldFFValue := os.Getenv("MM_FEATUREFLAGS_WORKTEMPLATE") + os.Setenv("MM_FEATUREFLAGS_WORKTEMPLATE", "true") + + return func() { + os.Setenv("MM_FEATUREFLAGS_WORKTEMPLATE", oldFFValue) + } +} diff --git a/app/app_iface.go b/app/app_iface.go index ab22b9cbf9..c27ea210c1 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -69,8 +69,6 @@ type AppIface interface { // If includeRemovedMembers is true, then channel members who left or were removed from the channel will // be included; otherwise, they will be excluded. ChannelMembersToAdd(since int64, channelID *string, includeRemovedMembers bool) ([]*model.UserChannelIDPair, *model.AppError) - // CheckFreemiumLimitsForConfigSave returns an error if the configuration being saved violates a cloud plan's limits - CheckFreemiumLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError // CheckProviderAttributes returns the empty string if the patch can be applied without // overriding attributes set by the user's login provider; otherwise, the name of the offending // field is returned. @@ -85,6 +83,8 @@ type AppIface interface { ConvertBotToUser(c request.CTX, bot *model.Bot, userPatch *model.UserPatch, sysadmin bool) (*model.User, *model.AppError) // ConvertUserToBot converts a user to bot. ConvertUserToBot(user *model.User) (*model.Bot, *model.AppError) + // Create/ Update a subscription history event + SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) // CreateBot creates the given bot and corresponding user. CreateBot(c request.CTX, bot *model.Bot) (*model.Bot, *model.AppError) // CreateChannelScheme creates a new Scheme of scope channel and assigns it to the channel. @@ -184,8 +184,6 @@ type AppIface interface { GetFilteredUsersStats(options *model.UserCountOptions) (*model.UsersStats, *model.AppError) // GetGroupsByTeam returns the paged list and the total count of group associated to the given team. GetGroupsByTeam(teamID string, opts model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.AppError) - // GetIntegrationsUsage returns usage information on enabled integrations - GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) // GetKnownUsers returns the list of user ids of users with any direct // relationship with a user. That means any user sharing any channel, including // direct and group channels. @@ -478,6 +476,7 @@ type AppIface interface { CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) CreateCommand(cmd *model.Command) (*model.Command, *model.AppError) CreateCommandWebhook(commandID string, args *model.CommandArgs) (*model.CommandWebhook, *model.AppError) + CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) CreateGroup(group *model.Group) (*model.Group, *model.AppError) CreateGroupChannel(c request.CTX, userIDs []string, creatorId string) (*model.Channel, *model.AppError) @@ -513,11 +512,13 @@ type AppIface interface { DeactivateGuests(c *request.Context) *model.AppError DeactivateMfa(userID string) *model.AppError DeauthorizeOAuthAppForUser(userID, appID string) *model.AppError + DeleteAcknowledgementForPost(c *request.Context, postID, userID string) *model.AppError DeleteAllExpiredPluginKeys() *model.AppError DeleteAllKeysForPlugin(pluginID string) *model.AppError DeleteBrandImage() *model.AppError DeleteChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError DeleteCommand(commandID string) *model.AppError + DeleteDraft(userID, channelID, rootID, connectionID string) (*model.Draft, *model.AppError) DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError DeleteEphemeralPost(userID, postID string) DeleteExport(name string) *model.AppError @@ -571,6 +572,8 @@ type AppIface interface { GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) GeneratePublicLink(siteURL string, info *model.FileInfo) string GenerateSupportPacket() []model.FileData + GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) + GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) GetAllChannels(c request.CTX, page, perPage int, opts model.ChannelSearchOpts) (model.ChannelListWithTeamData, *model.AppError) GetAllChannelsCount(c request.CTX, opts model.ChannelSearchOpts) (int64, *model.AppError) @@ -627,6 +630,8 @@ type AppIface interface { GetCustomStatus(userID string) (*model.CustomStatus, *model.AppError) GetDefaultProfileImage(user *model.User) ([]byte, *model.AppError) GetDeletedChannels(c request.CTX, teamID string, offset int, limit int, userID string) (model.ChannelList, *model.AppError) + GetDraft(userID, channelID, rootID string) (*model.Draft, *model.AppError) + GetDraftsForUser(userID, teamID string) ([]*model.Draft, *model.AppError) GetEmoji(c request.CTX, emojiId string) (*model.Emoji, *model.AppError) GetEmojiByName(c request.CTX, emojiName string) (*model.Emoji, *model.AppError) GetEmojiImage(c request.CTX, emojiId string) ([]byte, string, *model.AppError) @@ -707,6 +712,7 @@ type AppIface interface { GetPostIdAfterTime(channelID string, time int64, collapsedThreads bool) (string, *model.AppError) GetPostIdBeforeTime(channelID string, time int64, collapsedThreads bool) (string, *model.AppError) GetPostIfAuthorized(c request.CTX, postID string, session *model.Session, includeDeleted bool) (*model.Post, *model.AppError) + GetPostInfo(c request.CTX, postID string) (*model.PostInfo, *model.AppError) GetPostThread(postID string, opts model.GetPostsOptions, userID string) (*model.PostList, *model.AppError) GetPosts(channelID string, offset int, limit int) (*model.PostList, *model.AppError) GetPostsAfterPost(options model.GetPostsOptions) (*model.PostList, *model.AppError) @@ -720,6 +726,8 @@ type AppIface interface { GetPreferenceByCategoryForUser(userID string, category string) (model.Preferences, *model.AppError) GetPreferencesForUser(userID string) (model.Preferences, *model.AppError) GetPrevPostIdFromPostList(postList *model.PostList, collapsedThreads bool) string + GetPriorityForPost(postId string) (*model.PostPriority, *model.AppError) + GetPriorityForPostList(list *model.PostList) (map[string]*model.PostPriority, *model.AppError) GetPrivateChannelsForTeam(c request.CTX, teamID string, offset int, limit int) (model.ChannelList, *model.AppError) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) GetPublicChannelsByIdsForTeam(c request.CTX, teamID string, channelIDs []string) (model.ChannelList, *model.AppError) @@ -843,6 +851,8 @@ type AppIface interface { GetViewUsersRestrictions(userID string) (*model.ViewUsersRestrictions, *model.AppError) GetWarnMetricsBot() (*model.Bot, *model.AppError) GetWarnMetricsStatus() (map[string]*model.WarnMetricStatus, *model.AppError) + GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) + GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) HTTPService() httpservice.HTTPService Handle404(w http.ResponseWriter, r *http.Request) HandleCommandResponse(c request.CTX, command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.CommandResponse, *model.AppError) @@ -876,7 +886,6 @@ type AppIface interface { IsLeader() bool IsPasswordValid(password string) *model.AppError IsPhase2MigrationCompleted() *model.AppError - IsUserAway(lastActivityAt int64) bool IsUserSignUpAllowed() *model.AppError JoinChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError JoinDefaultChannels(c request.CTX, teamID string, user *model.User, shouldBeAdmin bool, userRequestorId string) *model.AppError @@ -931,8 +940,8 @@ type AppIface interface { PostUpdateChannelPurposeMessage(c request.CTX, userID string, channel *model.Channel, oldChannelPurpose string, newChannelPurpose string) *model.AppError PostWithProxyAddedToImageURLs(post *model.Post) *model.Post PostWithProxyRemovedFromImageURLs(post *model.Post) *model.Post - PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost bool) *model.Post - PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost bool) *model.Post + PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post + PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post PreparePostListForClient(c request.CTX, originalList *model.PostList) *model.PostList ProcessSlackText(text string) string Publish(message *model.WebSocketEvent) @@ -986,6 +995,7 @@ type AppIface interface { SanitizeProfile(user *model.User, asAdmin bool) SanitizeTeam(session model.Session, team *model.Team) *model.Team SanitizeTeams(session model.Session, teams []*model.Team) []*model.Team + SaveAcknowledgementForPost(c *request.Context, postID, userID string) (*model.PostAcknowledgement, *model.AppError) SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError SaveAdminNotifyData(data *model.NotifyAdminData) (*model.NotifyAdminData, *model.AppError) SaveBrandImage(imageData *multipart.FileHeader) *model.AppError @@ -1027,7 +1037,7 @@ type AppIface interface { SendPasswordReset(email string, siteURL string) (bool, *model.AppError) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError SendTestPushNotification(deviceID string) string - SendUpgradeConfirmationEmail() *model.AppError + SendUpgradeConfirmationEmail(isYearly bool) *model.AppError ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) SessionHasPermissionTo(session model.Session, permission *model.Permission) bool SessionHasPermissionToAny(session model.Session, permissions []*model.Permission) bool @@ -1094,6 +1104,7 @@ type AppIface interface { UpdateChannelPrivacy(c request.CTX, oldChannel *model.Channel, user *model.User) (*model.Channel, *model.AppError) UpdateCommand(oldCmd, updatedCmd *model.Command) (*model.Command, *model.AppError) UpdateConfig(f func(*model.Config)) + UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post UpdateExpiredDNDStatuses() ([]*model.Status, error) UpdateGroup(group *model.Group) (*model.Group, *model.AppError) @@ -1101,7 +1112,6 @@ type AppIface interface { UpdateHashedPassword(user *model.User, newHashedPassword string) *model.AppError UpdateHashedPasswordByUserId(userID, newHashedPassword string) *model.AppError UpdateIncomingWebhook(oldHook, updatedHook *model.IncomingWebhook) (*model.IncomingWebhook, *model.AppError) - UpdateLastActivityAtIfNeeded(session model.Session) UpdateMfa(c request.CTX, activate bool, userID, token string) *model.AppError UpdateMobileAppBadge(userID string) UpdateOAuthApp(oldApp, updatedApp *model.OAuthApp) (*model.OAuthApp, *model.AppError) @@ -1139,6 +1149,7 @@ type AppIface interface { UpdateUserRolesWithUser(c request.CTX, user *model.User, newRoles string, sendWebSocketEvent bool) (*model.User, *model.AppError) UploadData(c *request.Context, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) UploadEmojiImage(c request.CTX, id string, imageData *multipart.FileHeader) *model.AppError + UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) diff --git a/app/channel.go b/app/channel.go index f5c864ca38..fdc8d17da3 100644 --- a/app/channel.go +++ b/app/channel.go @@ -343,15 +343,13 @@ func (a *App) CreateChannel(c request.CTX, channel *model.Channel, addMember boo a.InvalidateCacheForUser(channel.CreatorId) } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.ChannelHasBeenCreated(pluginContext, sc) - return true - }, plugin.ChannelHasBeenCreatedID) - }) - } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.ChannelHasBeenCreated(pluginContext, sc) + return true + }, plugin.ChannelHasBeenCreatedID) + }) return sc, nil } @@ -429,15 +427,13 @@ func (a *App) handleCreationEvent(c request.CTX, userID, otherUserID string, cha a.InvalidateCacheForUser(userID) a.InvalidateCacheForUser(otherUserID) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.ChannelHasBeenCreated(pluginContext, channel) - return true - }, plugin.ChannelHasBeenCreatedID) - }) - } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.ChannelHasBeenCreated(pluginContext, channel) + return true + }, plugin.ChannelHasBeenCreatedID) + }) message := model.NewWebSocketEvent(model.WebsocketEventDirectAdded, "", channel.Id, "", nil, "") message.Add("creator_id", userID) @@ -1599,15 +1595,13 @@ func (a *App) AddChannelMember(c request.CTX, userID string, channel *model.Chan return nil, err } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.UserHasJoinedChannel(pluginContext, cm, userRequestor) - return true - }, plugin.UserHasJoinedChannelID) - }) - } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasJoinedChannel(pluginContext, cm, userRequestor) + return true + }, plugin.UserHasJoinedChannelID) + }) if opts.UserRequestorID == "" || userID == opts.UserRequestorID { if err := a.postJoinChannelMessage(c, user, channel); err != nil { @@ -2177,15 +2171,13 @@ func (a *App) JoinChannel(c request.CTX, channel *model.Channel, userID string) return err } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.UserHasJoinedChannel(pluginContext, cm, nil) - return true - }, plugin.UserHasJoinedChannelID) - }) - } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasJoinedChannel(pluginContext, cm, nil) + return true + }, plugin.UserHasJoinedChannelID) + }) if err := a.postJoinChannelMessage(c, user, channel); err != nil { return err @@ -2484,21 +2476,19 @@ func (a *App) removeUserFromChannel(c request.CTX, userIDToRemove string, remove a.InvalidateCacheForUser(userIDToRemove) a.invalidateCacheForChannelMembers(channel.Id) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var actorUser *model.User - if removerUserId != "" { - actorUser, _ = a.GetUser(removerUserId) - } - - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.UserHasLeftChannel(pluginContext, cm, actorUser) - return true - }, plugin.UserHasLeftChannelID) - }) + var actorUser *model.User + if removerUserId != "" { + actorUser, _ = a.GetUser(removerUserId) } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasLeftChannel(pluginContext, cm, actorUser) + return true + }, plugin.UserHasLeftChannelID) + }) + message := model.NewWebSocketEvent(model.WebsocketEventUserRemoved, "", channel.Id, "", nil, "") message.Add("user_id", userIDToRemove) message.Add("remover_id", removerUserId) @@ -2609,12 +2599,12 @@ func (a *App) MarkChannelAsUnreadFromPost(c request.CTX, postID string, userID s return nil, err } - unreadMentions, unreadMentionsRoot, err := a.countMentionsFromPost(c, user, post) + unreadMentions, unreadMentionsRoot, urgentMentions, err := a.countMentionsFromPost(c, user, post) if err != nil { return nil, err } - channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, true) + channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, urgentMentions, true) if nErr != nil { return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } @@ -2641,7 +2631,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st threadId = post.Id } - unreadMentions, unreadMentionsRoot, appErr := a.countMentionsFromPost(c, user, post) + unreadMentions, unreadMentionsRoot, urgentMentions, appErr := a.countMentionsFromPost(c, user, post) if appErr != nil { return nil, appErr } @@ -2650,7 +2640,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st // In CRT Supported Client: badge on channel only sums mentions in root posts including and below the post that was marked. // In CRT Unsupported Client: badge on channel sums mentions in all posts (root & replies) including and below the post that was marked unread. if post.RootId == "" { - channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, true) + channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, urgentMentions, true) if nErr != nil { return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } @@ -2706,7 +2696,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st if mErr != nil { return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(mErr) } - thread, mErr := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true) + thread, mErr := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true, a.isPostPriorityEnabled()) if mErr != nil { return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(mErr) } @@ -2724,7 +2714,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st } } - channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, 0, false) + channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, 0, 0, false) if nErr != nil { return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } @@ -2741,6 +2731,7 @@ func (a *App) sendWebSocketPostUnreadEvent(c request.CTX, channelUnread *model.C } message.Add("mention_count", channelUnread.MentionCount) message.Add("mention_count_root", channelUnread.MentionCountRoot) + message.Add("urgent_mention_count", channelUnread.UrgentMentionCount) message.Add("last_viewed_at", channelUnread.LastViewedAt) message.Add("post_id", postID) a.Publish(message) diff --git a/app/channels.go b/app/channels.go index 2c9f1cee51..e4a25f3749 100644 --- a/app/channels.go +++ b/app/channels.go @@ -322,5 +322,33 @@ func (s *hooksService) RegisterHooks(productID string, hooks any) error { return errors.New("could not find plugins environment") } - return s.ch.pluginsEnvironment.AddProduct(productID, hooks) + return s.ch.srv.hooksManager.AddProduct(productID, hooks) +} + +func (ch *Channels) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) { + if env := ch.GetPluginsEnvironment(); env != nil { + env.RunMultiPluginHook(hookRunnerFunc, hookId) + } + + // run hook for the products + ch.srv.hooksManager.RunMultiHook(hookRunnerFunc, hookId) +} + +func (ch *Channels) HooksForPluginOrProduct(id string) (plugin.Hooks, error) { + var hooks plugin.Hooks + if env := ch.GetPluginsEnvironment(); env != nil { + // we intentionally ignore the error here, because the id can be a product id + // we are going to check if we have the hooks or not + hooks, _ = env.HooksForPlugin(id) + if hooks != nil { + return hooks, nil + } + } + + hooks = ch.srv.hooksManager.HooksForProduct(id) + if hooks != nil { + return hooks, nil + } + + return nil, fmt.Errorf("could not find hooks for id %s", id) } diff --git a/app/cloud.go b/app/cloud.go index e4f19ac46b..f045c57311 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -174,7 +174,7 @@ func getNextBillingDateString() string { return fmt.Sprintf("%s %d, %d", t.Month(), t.Day(), t.Year()) } -func (a *App) SendUpgradeConfirmationEmail() *model.AppError { +func (a *App) SendUpgradeConfirmationEmail(isYearly bool) *model.AppError { sysAdmins, e := a.getSysAdminsEmailRecipients() if e != nil { return e @@ -200,7 +200,7 @@ func (a *App) SendUpgradeConfirmationEmail() *model.AppError { name = admin.Username } - err := a.Srv().EmailService.SendCloudUpgradeConfirmationEmail(admin.Email, name, billingDate, admin.Locale, *a.Config().ServiceSettings.SiteURL, subscription.GetWorkSpaceNameFromDNS()) + err := a.Srv().EmailService.SendCloudUpgradeConfirmationEmail(admin.Email, name, billingDate, admin.Locale, *a.Config().ServiceSettings.SiteURL, subscription.GetWorkSpaceNameFromDNS(), isYearly) if err != nil { a.Log().Error("Error sending trial ended email to", mlog.String("email", admin.Email), mlog.Err(err)) countNotOks++ @@ -230,3 +230,20 @@ func (a *App) SendNoCardPaymentFailedEmail() *model.AppError { } return nil } + +// Create/ Update a subscription history event +func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) { + license := a.Srv().License() + + // No need to create a Subscription History Event if the license isn't cloud + if !license.IsCloud() { + return nil, nil + } + + // Get user count + userCount, err := a.Srv().Store().User().Count(model.UserCountOptions{}) + if err != nil { + return nil, err + } + return a.Cloud().CreateOrUpdateSubscriptionHistoryEvent(userID, int(userCount)) +} diff --git a/app/cluster_handlers.go b/app/cluster_handlers.go index beb8f71c73..3fa90abf1e 100644 --- a/app/cluster_handlers.go +++ b/app/cluster_handlers.go @@ -28,15 +28,15 @@ func (s *Server) clusterRemovePluginHandler(msg *model.ClusterMessage) { } func (s *Server) clusterPluginEventHandler(msg *model.ClusterMessage) { - env := s.Channels().GetPluginsEnvironment() - if env == nil { - return - } if msg.Props == nil { mlog.Warn("ClusterMessage.Props for plugin event should not be nil") return } pluginID := msg.Props["PluginID"] + // if the plugin key is empty, the message might be coming from a product. + if pluginID == "" { + pluginID = msg.Props["ProductID"] + } eventID := msg.Props["EventID"] if pluginID == "" || eventID == "" { mlog.Warn("Invalid ClusterMessage.Props values for plugin event", @@ -44,7 +44,12 @@ func (s *Server) clusterPluginEventHandler(msg *model.ClusterMessage) { return } - hooks, err := env.HooksForPlugin(pluginID) + channels, ok := s.products["channels"].(*Channels) + if !ok { + return + } + + hooks, err := channels.HooksForPluginOrProduct(pluginID) if err != nil { mlog.Warn("Getting hooks for plugin failed", mlog.String("plugin_id", pluginID), mlog.Err(err)) return diff --git a/app/draft.go b/app/draft.go new file mode 100644 index 0000000000..18dd2bde00 --- /dev/null +++ b/app/draft.go @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +func (a *App) GetDraft(userID, channelID, rootID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("GetDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + draft, err := a.Srv().Store().Draft().Get(userID, channelID, rootID, false) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, err.Error(), http.StatusNotFound) + default: + return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + return draft, nil +} + +func (a *App) UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("UpsertDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + dt, dErr := a.Srv().Store().Draft().Get(draft.UserId, draft.ChannelId, draft.RootId, true) + var notFoundErr *store.ErrNotFound + if dErr != nil && !errors.As(dErr, ¬FoundErr) { + return nil, model.NewAppError("UpsertDraft", "app.select_error", nil, dErr.Error(), http.StatusInternalServerError) + } + + var err *model.AppError + if dt == nil { + dt, err = a.CreateDraft(c, draft, connectionID) + if err != nil { + return nil, err + } + } else { + dt, err = a.UpdateDraft(c, draft, connectionID) + if err != nil { + return nil, err + } + } + + return dt, nil +} + +func (a *App) CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("CreateDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + // Check that channel exists and has not been deleted + channel, errCh := a.Srv().Store().Channel().Get(draft.ChannelId, true) + if errCh != nil { + err := model.NewAppError("CreateDraft", "api.context.invalid_param.app_error", map[string]interface{}{"Name": "draft.channel_id"}, errCh.Error(), http.StatusBadRequest) + return nil, err + } + + if channel.DeleteAt != 0 { + err := model.NewAppError("CreateDraft", "api.draft.create_draft.can_not_draft_to_deleted.error", nil, "", http.StatusBadRequest) + return nil, err + } + + _, nErr := a.Srv().Store().User().Get(context.Background(), draft.UserId) + if nErr != nil { + return nil, model.NewAppError("CreateDraft", "app.user.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt, nErr := a.Srv().Store().Draft().Save(draft) + if nErr != nil { + return nil, model.NewAppError("CreateDraft", "app.draft.save.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt = a.prepareDraftWithFileInfos(draft.UserId, dt) + + message := model.NewWebSocketEvent(model.WebsocketEventDraftCreated, "", dt.ChannelId, dt.UserId, nil, connectionID) + draftJSON, jsonErr := json.Marshal(dt) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON", mlog.Err(jsonErr)) + } + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return dt, nil +} + +func (a *App) UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts { + return nil, model.NewAppError("UpsertDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + // Check that channel exists and has not been deleted + channel, errCh := a.Srv().Store().Channel().Get(draft.ChannelId, true) + if errCh != nil { + err := model.NewAppError("UpdateDraft", "api.context.invalid_param.app_error", map[string]interface{}{"Name": "draft.channel_id"}, errCh.Error(), http.StatusBadRequest) + return nil, err + } + + if channel.DeleteAt != 0 { + err := model.NewAppError("UpdateDraft", "api.draft.create_draft.can_not_draft_to_deleted.error", nil, "", http.StatusBadRequest) + return nil, err + } + + _, nErr := a.Srv().Store().User().Get(context.Background(), draft.UserId) + if nErr != nil { + return nil, model.NewAppError("UpdateDraft", "app.user.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt, nErr := a.Srv().Store().Draft().Update(draft) + if nErr != nil { + return nil, model.NewAppError("UpdateDraft", "app.draft.update.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt = a.prepareDraftWithFileInfos(draft.UserId, dt) + + message := model.NewWebSocketEvent(model.WebsocketEventDraftUpdated, "", draft.ChannelId, draft.UserId, nil, connectionID) + draftJSON, jsonErr := json.Marshal(dt) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON", mlog.Err(jsonErr)) + } + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return dt, nil +} + +func (a *App) GetDraftsForUser(userID, teamID string) ([]*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("GetDraftsForUser", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + drafts, err := a.Srv().Store().Draft().GetDraftsForUser(userID, teamID) + + if err != nil { + return nil, model.NewAppError("GetDraftsForUser", "app.draft.get_drafts.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + for _, draft := range drafts { + a.prepareDraftWithFileInfos(userID, draft) + } + return drafts, nil +} + +func (a *App) prepareDraftWithFileInfos(userID string, draft *model.Draft) *model.Draft { + if fileInfos, err := a.getFileInfosForDraft(draft); err != nil { + mlog.Error("Failed to get files for a user's drafts", mlog.String("user_id", userID), mlog.Err(err)) + } else { + draft.Metadata = &model.PostMetadata{} + draft.Metadata.Files = fileInfos + } + + return draft +} + +func (a *App) getFileInfosForDraft(draft *model.Draft) ([]*model.FileInfo, *model.AppError) { + if len(draft.FileIds) == 0 { + return nil, nil + } + + fileInfos, err := a.Srv().Store().FileInfo().GetByIds(draft.FileIds) + if err != nil { + return nil, model.NewAppError("GetFileInfosForDraft", "app.draft.get_for_draft.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + a.generateMiniPreviewForInfos(fileInfos) + + return fileInfos, nil +} + +func (a *App) DeleteDraft(userID, channelID, rootID, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("DeleteDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + draft, nErr := a.Srv().Store().Draft().Get(userID, channelID, rootID, false) + if nErr != nil { + return nil, model.NewAppError("DeleteDraft", "app.draft.get.app_error", nil, nErr.Error(), http.StatusBadRequest) + } + + if err := a.Srv().Store().Draft().Delete(userID, channelID, rootID); err != nil { + return nil, model.NewAppError("DeleteDraft", "app.draft.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + draftJSON, jsonErr := json.Marshal(draft) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON") + } + + message := model.NewWebSocketEvent(model.WebsocketEventDraftDeleted, "", draft.ChannelId, draft.UserId, nil, connectionID) + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return draft, nil +} diff --git a/app/draft_test.go b/app/draft_test.go new file mode 100644 index 0000000000..2ab0983f45 --- /dev/null +++ b/app/draft_test.go @@ -0,0 +1,428 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/utils/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft", + } + + _, upsertDraftErr := th.App.UpsertDraft(th.Context, draft, "") + assert.Nil(t, upsertDraftErr) + + t.Run("get draft", func(t *testing.T) { + draftResp, err := th.App.GetDraft(user.Id, channel.Id, "") + assert.Nil(t, err) + + assert.Equal(t, draft.Message, draftResp.Message) + assert.Equal(t, draft.ChannelId, draftResp.ChannelId) + }) + + t.Run("get draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.GetDraft(user.Id, channel.Id, "") + assert.NotNil(t, err) + }) +} + +func TestUpsertDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00002, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft2", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("upsert draft", func(t *testing.T) { + draftResp, err := th.App.UpsertDraft(th.Context, draft2, "") + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + assert.Equal(t, draft2.CreateAt, draftResp.CreateAt) + + assert.NotEqual(t, draft1.UpdateAt, draftResp.UpdateAt) + }) + + t.Run("upsert draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.UpsertDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestCreateDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + channel2 := th.CreateChannel(th.Context, th.BasicTeam) + th.AddUserToChannel(user, channel2) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("create draft", func(t *testing.T) { + draftResp, err := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + }) + + t.Run("create draft with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, uploadFileErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, uploadFileErr) + + draftWithFiles := draft2 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, err := th.App.CreateDraft(th.Context, draftWithFiles, "") + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + }) + + t.Run("create draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.CreateDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestUpdateDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00002, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft2", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("update draft", func(t *testing.T) { + draftResp, err := th.App.UpdateDraft(th.Context, draft2, "") + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + + assert.NotEqual(t, draft1.UpdateAt, draftResp.UpdateAt) + }) + + t.Run("update draft with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, uploadFileErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, uploadFileErr) + + draftWithFiles := draft1 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, err := th.App.UpdateDraft(th.Context, draft1, "") + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + }) + + t.Run("create draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.UpdateDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestGetDraftsForUser(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + channel2 := th.CreateChannel(th.Context, th.BasicTeam) + th.AddUserToChannel(user, channel2) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, createDraftErr1 := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr1) + + _, createDraftErr2 := th.App.CreateDraft(th.Context, draft2, "") + assert.Nil(t, createDraftErr2) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + }) + + t.Run("get drafts with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, updateDraftErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, updateDraftErr) + + draftWithFiles := draft1 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, updateDraftErr := th.App.UpdateDraft(th.Context, draft1, "") + assert.Nil(t, updateDraftErr) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + + draftsWithFilesResp, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftsWithFilesResp[0].Message) + assert.Equal(t, draftWithFiles.ChannelId, draftsWithFilesResp[0].ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftsWithFilesResp[0].FileIds) + + assert.Equal(t, fileResp.Name, draftsWithFilesResp[0].Metadata.Files[0].Name) + + assert.Len(t, draftsWithFilesResp, 2) + }) + + t.Run("get drafts feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.NotNil(t, err) + }) +} + +func TestDeleteDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("delete draft", func(t *testing.T) { + draftResp, err := th.App.DeleteDraft(user.Id, channel.Id, "", "") + assert.Nil(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + }) + + t.Run("get drafts feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.DeleteDraft(user.Id, channel.Id, "", "") + assert.NotNil(t, err) + }) +} diff --git a/app/email/email.go b/app/email/email.go index 1bd25f999b..d434d94935 100644 --- a/app/email/email.go +++ b/app/email/email.go @@ -233,13 +233,13 @@ func (es *Service) SendWelcomeEmail(userID string, email string, verified bool, return nil } -func (es *Service) SendCloudUpgradeConfirmationEmail(userEmail, name, date, locale, siteURL, workspaceName string) error { +func (es *Service) SendCloudUpgradeConfirmationEmail(userEmail, name, date, locale, siteURL, workspaceName string, isYearly bool) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.cloud_upgrade_confirmation.subject") data := es.NewEmailTemplateData(locale) data.Props["Title"] = T("api.templates.cloud_upgrade_confirmation.title") - data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation.subtitle", map[string]any{"WorkspaceName": workspaceName, "Date": date}) + data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation_monthly.subtitle", map[string]any{"WorkspaceName": workspaceName, "Date": date}) data.Props["SiteURL"] = siteURL data.Props["ButtonURL"] = siteURL data.Props["Button"] = T("api.templates.cloud_welcome_email.button") @@ -247,6 +247,12 @@ func (es *Service) SendCloudUpgradeConfirmationEmail(userEmail, name, date, loca data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail + if isYearly { + data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation_yearly.subtitle", map[string]any{"WorkspaceName": workspaceName}) + data.Props["ButtonURL"] = siteURL + "/admin_console/billing/billing_history" + data.Props["Button"] = T("api.templates.cloud_welcome_email.yearly_plan_button") + } + body, err := es.templatesContainer.RenderToString("cloud_upgrade_confirmation", data) if err != nil { return err @@ -1037,7 +1043,7 @@ func (es *Service) SendNoCardPaymentFailedEmail(email string, locale string, sit func (es *Service) SendDelinquencyEmail7(email, locale, siteURL, planName string) error { T := i18n.GetUserTranslations(locale) - subject := T("api.templates.payment_failed.subject") + subject := T("api.templates.payment_failed.subject", map[string]any{"Plan": planName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL diff --git a/app/email/email_batching.go b/app/email/email_batching.go index ff016c0314..c4b3b01b60 100644 --- a/app/email/email_batching.go +++ b/app/email/email_batching.go @@ -33,6 +33,7 @@ type postData struct { Time string ShowChannelIcon bool OtherChannelMembersCount int + MessageAttachments []*EmailMessageAttachment } func (es *Service) InitEmailBatching() { @@ -314,6 +315,7 @@ func (es *Service) sendBatchedEmailNotification(userID string, notifications []* MessageURL: MessageURL, ShowChannelIcon: showChannelIcon, OtherChannelMembersCount: otherChannelMembersCount, + MessageAttachments: ProcessMessageAttachments(notification.post), }) } } diff --git a/app/email/email_test.go b/app/email/email_test.go index 0fe3b237e0..2ad00123c3 100644 --- a/app/email/email_test.go +++ b/app/email/email_test.go @@ -258,7 +258,7 @@ func TestSendCloudUpgradedEmail(t *testing.T) { emailTo := "testclouduser@example.com" emailToUsername := strings.Split(emailTo, "@")[0] - t.Run("SendCloudUpgradedEmail", func(t *testing.T) { + t.Run("SendCloudMonthlyUpgradedEmail", func(t *testing.T) { verifyMailbox := func(t *testing.T) { t.Helper() @@ -278,10 +278,44 @@ func TestSendCloudUpgradedEmail(t *testing.T) { require.NoError(t, err, "Could not get message from mailbox") require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text) require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "You'll be billed from", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "Open Mattermost", "Wrong received message %s", resultsEmail.Body.Text) } mail.DeleteMailBox(emailTo) - err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName") + // Send Update to Monthly Plan email + err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", false) + require.NoError(t, err) + + verifyMailbox(t) + }) + + t.Run("SendCloudYearlyUpgradedEmail", func(t *testing.T) { + verifyMailbox := func(t *testing.T) { + t.Helper() + + var resultsMailbox mail.JSONMessageHeaderInbucket + err2 := mail.RetryInbucket(5, func() error { + var err error + resultsMailbox, err = mail.GetMailBox(emailTo) + return err + }) + if err2 != nil { + t.Skipf("No email was received, maybe due load on the server: %v", err2) + } + + require.Len(t, resultsMailbox, 1) + require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient") + resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID) + require.NoError(t, err, "Could not get message from mailbox") + require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "View your invoice", "Wrong received message %s", resultsEmail.Body.Text) + } + mail.DeleteMailBox(emailTo) + + // Send Update to Monthly Plan email + err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", true) require.NoError(t, err) verifyMailbox(t) diff --git a/app/email/mocks/ServiceInterface.go b/app/email/mocks/ServiceInterface.go index 08cddbe4ad..98b39f90fe 100644 --- a/app/email/mocks/ServiceInterface.go +++ b/app/email/mocks/ServiceInterface.go @@ -125,13 +125,13 @@ func (_m *ServiceInterface) SendChangeUsernameEmail(newUsername string, _a1 stri return r0 } -// SendCloudUpgradeConfirmationEmail provides a mock function with given fields: userEmail, name, trialEndDate, locale, siteURL, workspaceName -func (_m *ServiceInterface) SendCloudUpgradeConfirmationEmail(userEmail string, name string, trialEndDate string, locale string, siteURL string, workspaceName string) error { - ret := _m.Called(userEmail, name, trialEndDate, locale, siteURL, workspaceName) +// SendCloudUpgradeConfirmationEmail provides a mock function with given fields: userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly +func (_m *ServiceInterface) SendCloudUpgradeConfirmationEmail(userEmail string, name string, trialEndDate string, locale string, siteURL string, workspaceName string, isYearly bool) error { + ret := _m.Called(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly) var r0 error - if rf, ok := ret.Get(0).(func(string, string, string, string, string, string) error); ok { - r0 = rf(userEmail, name, trialEndDate, locale, siteURL, workspaceName) + if rf, ok := ret.Get(0).(func(string, string, string, string, string, string, bool) error); ok { + r0 = rf(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly) } else { r0 = ret.Error(0) } diff --git a/app/email/notification_email.go b/app/email/notification_email.go index 6f83b1191f..06c0364b38 100644 --- a/app/email/notification_email.go +++ b/app/email/notification_email.go @@ -4,6 +4,8 @@ package email import ( + "html" + "html/template" "net/url" "path/filepath" "strings" @@ -11,8 +13,21 @@ import ( "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/i18n" "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/utils" ) +type FieldRow struct { + Cells []*model.SlackAttachmentField +} + +type EmailMessageAttachment struct { + model.SlackAttachment + + Pretext template.HTML + Text template.HTML + FieldRows []FieldRow +} + func (es *Service) GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string { if strings.TrimSpace(post.Message) != "" || len(post.FileIds) == 0 { return post.Message @@ -44,3 +59,78 @@ func (es *Service) GetMessageForNotification(post *model.Post, translateFunc i18 } return translateFunc("api.post.get_message_for_notification.files_sent", len(filenames), props) } + +func ProcessMessageAttachments(post *model.Post) []*EmailMessageAttachment { + emailMessageAttachments := []*EmailMessageAttachment{} + + for _, messageAttachment := range post.Attachments() { + emailMessageAttachment := &EmailMessageAttachment{ + SlackAttachment: *messageAttachment, + Pretext: prepareTextForEmail(messageAttachment.Pretext), + Text: prepareTextForEmail(messageAttachment.Text), + } + + stripedTitle, err := utils.StripMarkdown(emailMessageAttachment.Title) + if err != nil { + mlog.Warn("Failed parse to markdown from messageatatchment title", mlog.String("post_id", post.Id), mlog.Err(err)) + stripedTitle = "" + } + + emailMessageAttachment.Title = stripedTitle + + shortFieldRow := FieldRow{} + + for i := range messageAttachment.Fields { + // Create a new instance to avoid altering the original pointer reference + // We update field value to parse markdown. + // If we do that on the original pointer, the rendered text in mattermost + // becomes invalid as its no longer a markdown string, but rather an HTML string. + field := &model.SlackAttachmentField{ + Title: messageAttachment.Fields[i].Title, + Value: messageAttachment.Fields[i].Value, + Short: messageAttachment.Fields[i].Short, + } + + if stringValue, ok := field.Value.(string); ok { + field.Value = prepareTextForEmail(stringValue) + } + + if !field.Short { + if len(shortFieldRow.Cells) > 0 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, FieldRow{[]*model.SlackAttachmentField{field}}) + } else { + shortFieldRow.Cells = append(shortFieldRow.Cells, field) + + if len(shortFieldRow.Cells) == 2 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + } + } + + // collect any leftover short fields + if len(shortFieldRow.Cells) > 0 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + + emailMessageAttachments = append(emailMessageAttachments, emailMessageAttachment) + } + + return emailMessageAttachments +} + +func prepareTextForEmail(text string) template.HTML { + escapedText := html.EscapeString(text) + markdownText, err := utils.MarkdownToHTML(escapedText) + if err != nil { + mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(err)) + return template.HTML(text) + } + + return template.HTML(markdownText) +} diff --git a/app/email/notification_email_test.go b/app/email/notification_email_test.go new file mode 100644 index 0000000000..4f8e176c5b --- /dev/null +++ b/app/email/notification_email_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func TestProcessMessageAttachments(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + post := &model.Post{ + Message: "This is the message", + } + + messageAttachments := []*model.SlackAttachment{ + { + Color: "#FF0000", + Pretext: "message attachment 1 pretext", + AuthorName: "author name", + AuthorLink: "https://example.com/slack_attachment_1/author_link", + AuthorIcon: "https://example.com/slack_attachment_1/author_icon", + Title: "message attachment 1 title", + TitleLink: "https://example.com/slack_attachment_1/title_link", + Text: "message attachment 1 text", + ImageURL: "https://example.com/slack_attachment_1/image", + ThumbURL: "https://example.com/slack_attachment_1/thumb", + Fields: []*model.SlackAttachmentField{ + { + Short: true, + Title: "message attachment 1 field 1 title", + Value: "message attachment 1 field 1 value", + }, + { + Short: false, + Title: "message attachment 1 field 2 title", + Value: "message attachment 1 field 2 value", + }, + { + Short: true, + Title: "message attachment 1 field 3 title", + Value: "message attachment 1 field 3 value", + }, + { + Short: true, + Title: "message attachment 1 field 4 title", + Value: "message attachment 1 field 4 value", + }, + }, + }, + { + Color: "#FF0000", + Pretext: "message attachment 2 pretext", + AuthorName: "author name 2", + Text: "message attachment 2 text", + }, + } + + model.ParseSlackAttachment(post, messageAttachments) + + processedAttachcmentsPost := ProcessMessageAttachments(post) + require.NotNil(t, processedAttachcmentsPost) + require.Len(t, processedAttachcmentsPost, 2) + require.Equal(t, processedAttachcmentsPost[0].Color, "#FF0000") + require.Equal(t, processedAttachcmentsPost[0].FieldRows[0].Cells[0].Title, "message attachment 1 field 1 title") + require.Equal(t, processedAttachcmentsPost[1].Color, "#FF0000") +} diff --git a/app/email/service.go b/app/email/service.go index 0f9158e343..d8745a1735 100644 --- a/app/email/service.go +++ b/app/email/service.go @@ -129,7 +129,7 @@ type ServiceInterface interface { SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error SendSignInChangeEmail(email, method, locale, siteURL string) error SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error - SendCloudUpgradeConfirmationEmail(userEmail, name, trialEndDate, locale, siteURL, workspaceName string) error + SendCloudUpgradeConfirmationEmail(userEmail, name, trialEndDate, locale, siteURL, workspaceName string, isYearly bool) error SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error SendPasswordChangeEmail(email, method, locale, siteURL string) error SendUserAccessTokenAddedEmail(email, locale, siteURL string) error diff --git a/app/export.go b/app/export.go index fae99af054..6afd544534 100644 --- a/app/export.go +++ b/app/export.go @@ -7,11 +7,13 @@ import ( "archive/zip" "context" "encoding/json" + "fmt" "io" "net/http" "os" "path/filepath" "strings" + "time" "github.com/pkg/errors" @@ -157,9 +159,17 @@ func (a *App) exportWriteLine(w io.Writer, line *imports.LineImportData) *model. func (a *App) exportVersion(writer io.Writer) *model.AppError { version := 1 + + info := &imports.VersionInfoImportData{ + Generator: "mattermost-server", + Version: fmt.Sprintf("%s (%s, enterprise: %s)", model.CurrentVersion, model.BuildHash, model.BuildEnterpriseReady), + Created: time.Now().Format(time.RFC3339Nano), + } + versionLine := &imports.LineImportData{ Type: "version", Version: &version, + Info: info, } return a.exportWriteLine(writer, versionLine) diff --git a/app/file.go b/app/file.go index 341c5f655e..06a516bc86 100644 --- a/app/file.go +++ b/app/file.go @@ -895,29 +895,27 @@ func (a *App) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTe info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(info.MimeType) } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var rejectionError *model.AppError - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - var newBytes bytes.Buffer - replacementInfo, rejectionReason := hooks.FileWillBeUploaded(pluginContext, info, bytes.NewReader(data), &newBytes) - if rejectionReason != "" { - rejectionError = model.NewAppError("DoUploadFile", "File rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) - return false - } - if replacementInfo != nil { - info = replacementInfo - } - if newBytes.Len() != 0 { - data = newBytes.Bytes() - info.Size = int64(len(data)) - } - - return true - }, plugin.FileWillBeUploadedID) - if rejectionError != nil { - return nil, data, rejectionError + var rejectionError *model.AppError + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + var newBytes bytes.Buffer + replacementInfo, rejectionReason := hooks.FileWillBeUploaded(pluginContext, info, bytes.NewReader(data), &newBytes) + if rejectionReason != "" { + rejectionError = model.NewAppError("DoUploadFile", "File rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) + return false } + if replacementInfo != nil { + info = replacementInfo + } + if newBytes.Len() != 0 { + data = newBytes.Bytes() + info.Size = int64(len(data)) + } + + return true + }, plugin.FileWillBeUploadedID) + if rejectionError != nil { + return nil, data, rejectionError } if _, err := a.WriteFile(bytes.NewReader(data), info.Path); err != nil { @@ -1380,6 +1378,28 @@ func (a *App) ComputeLastAccessibleFileTime() error { return appErr } + if limit == 0 { + // All files are accessible - we must check if a previous value was set so we can clear it + systemValue, err := a.Srv().Store().System().GetByName(model.SystemLastAccessibleFileTime) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + // All files are already accessible + return nil + default: + return model.NewAppError("ComputeLastAccessibleFileTime", "app.system.get_by_name.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + if systemValue != nil { + // Previous value was set, so we must clear it + if _, err := a.Srv().Store().System().PermanentDeleteByName(model.SystemLastAccessibleFileTime); err != nil { + return model.NewAppError("ComputeLastAccessibleFileTime", "app.system.permanent_delete_by_name.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + return nil + } + createdAt, err := a.Srv().GetStore().FileInfo().GetUptoNSizeFileTime(limit) if err != nil { var nfErr *store.ErrNotFound diff --git a/app/file_test.go b/app/file_test.go index 09c03e640e..b781af6483 100644 --- a/app/file_test.go +++ b/app/file_test.go @@ -591,30 +591,61 @@ func TestGetLastAccessibleFileTime(t *testing.T) { } func TestComputeLastAccessibleFileTime(t *testing.T) { - th := SetupWithStoreMock(t) - defer th.TearDown() + t.Run("Updates the time, if cloud limit is applicable", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - cloud := &eMocks.CloudInterface{} - th.App.Srv().Cloud = cloud + cloud := &eMocks.CloudInterface{} + th.App.Srv().Cloud = cloud - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Files: &model.FilesLimits{ - TotalStorage: model.NewInt64(1), - }, - }, nil) + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Files: &model.FilesLimits{ + TotalStorage: model.NewInt64(1), + }, + }, nil) - mockStore := th.App.Srv().Store().(*storemocks.Store) - mockFileStore := storemocks.FileInfoStore{} - mockFileStore.On("GetUptoNSizeFileTime", mock.Anything).Return(int64(1), nil) - mockSystemStore := storemocks.SystemStore{} - mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil) - mockStore.On("FileInfo").Return(&mockFileStore) - mockStore.On("System").Return(&mockSystemStore) + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockFileStore := storemocks.FileInfoStore{} + mockFileStore.On("GetUptoNSizeFileTime", mock.Anything).Return(int64(1), nil) + mockSystemStore := storemocks.SystemStore{} + mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil) + mockStore.On("FileInfo").Return(&mockFileStore) + mockStore.On("System").Return(&mockSystemStore) - err := th.App.ComputeLastAccessibleFileTime() - require.NoError(t, err) + err := th.App.ComputeLastAccessibleFileTime() + require.NoError(t, err) - mockSystemStore.AssertCalled(t, "SaveOrUpdate", mock.Anything) + mockSystemStore.AssertCalled(t, "SaveOrUpdate", mock.Anything) + }) + + t.Run("Removes the time, if cloud limit is not applicable", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := &eMocks.CloudInterface{} + th.App.Srv().Cloud = cloud + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, nil) + + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockFileStore := storemocks.FileInfoStore{} + mockFileStore.On("GetUptoNSizeFileTime", mock.Anything).Return(int64(1), nil) + mockSystemStore := storemocks.SystemStore{} + mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessibleFileTime, Value: "10"}, nil) + mockSystemStore.On("PermanentDeleteByName", mock.Anything).Return(nil, nil) + mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil) + mockStore.On("FileInfo").Return(&mockFileStore) + mockStore.On("System").Return(&mockSystemStore) + + err := th.App.ComputeLastAccessibleFileTime() + require.NoError(t, err) + + mockSystemStore.AssertNotCalled(t, "SaveOrUpdate", mock.Anything) + mockSystemStore.AssertCalled(t, "PermanentDeleteByName", mock.Anything) + + }) } diff --git a/app/imports/import_types.go b/app/imports/import_types.go index 0780245feb..30996df709 100644 --- a/app/imports/import_types.go +++ b/app/imports/import_types.go @@ -5,6 +5,7 @@ package imports import ( "archive/zip" + "encoding/json" "github.com/mattermost/mattermost-server/v6/model" ) @@ -22,6 +23,14 @@ type LineImportData struct { DirectPost *DirectPostImportData `json:"direct_post,omitempty"` Emoji *EmojiImportData `json:"emoji,omitempty"` Version *int `json:"version,omitempty"` + Info *VersionInfoImportData `json:"info,omitempty"` +} + +type VersionInfoImportData struct { + Generator string `json:"generator"` + Version string `json:"version"` + Created string `json:"created"` + Additional json.RawMessage `json:"additional,omitempty"` } type TeamImportData struct { diff --git a/app/integrations.go b/app/integrations.go deleted file mode 100644 index 0a810cbf0b..0000000000 --- a/app/integrations.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package app - -import ( - "net/http" - "sort" - "strings" - - "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/shared/mlog" -) - -func (a *App) checkIntegrationLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError { - pluginIds := []string{} - for pluginId, newState := range newConfig.PluginSettings.PluginStates { - oldState, ok := oldConfig.PluginSettings.PluginStates[pluginId] - if newState.Enable && !(ok && oldState.Enable) { - pluginIds = append(pluginIds, pluginId) - } - } - - if len(pluginIds) > 0 { - return a.checkIfIntegrationsMeetFreemiumLimits(pluginIds) - } - - return nil -} - -func (ch *Channels) getInstalledIntegrations() ([]*model.InstalledIntegration, *model.AppError) { - out := []*model.InstalledIntegration{} - - pluginsEnvironment := ch.GetPluginsEnvironment() - if pluginsEnvironment == nil { - return out, nil - } - - plugins, err := pluginsEnvironment.Available() - if err != nil { - return nil, model.NewAppError("getInstalledIntegrations", "app.plugin.sync.read_local_folder.app_error", nil, "", 0).Wrap(err) - } - - pluginStates := ch.cfgSvc.Config().PluginSettings.PluginStates - for _, p := range plugins { - if _, ok := model.InstalledIntegrationsIgnoredPlugins[p.Manifest.Id]; !ok { - enabled := false - if state, ok := pluginStates[p.Manifest.Id]; ok { - enabled = state.Enable - } - - integration := &model.InstalledIntegration{ - Type: "plugin", - ID: p.Manifest.Id, - Name: p.Manifest.Name, - Version: p.Manifest.Version, - Enabled: enabled, - } - - out = append(out, integration) - } - } - - // Sort result alphabetically, by display name. - sort.SliceStable(out, func(i, j int) bool { - return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) - }) - - return out, nil -} - -func (a *App) checkIfIntegrationsMeetFreemiumLimits(originalPluginIds []string) *model.AppError { - if !a.License().IsCloud() { - return nil - } - - pluginIds := map[string]bool{} - for _, pluginId := range originalPluginIds { - if _, ok := model.InstalledIntegrationsIgnoredPlugins[pluginId]; !ok { - pluginIds[pluginId] = true - } - } - - limits, err := a.Cloud().GetCloudLimits("") - if err != nil { - a.Log().Error("Error fetching cloud limits for enabled integrations", mlog.Err(err)) - return nil - } - - if limits == nil || limits.Integrations == nil || limits.Integrations.Enabled == nil { - return nil - } - - installed, appErr := a.ch.getInstalledIntegrations() - if appErr != nil { - a.Log().Error("Failed to get installed integrations to check cloud limit", mlog.Err(appErr)) - return nil - } - - enableCount := len(pluginIds) - for _, integration := range installed { - if _, ok := pluginIds[integration.ID]; !ok && integration.Enabled { - enableCount++ - } - } - - limit := *limits.Integrations.Enabled - if enableCount > limit { - return model.NewAppError("checkIfIntegrationMeetsFreemiumLimits", "app.install_integration.reached_max_limit.error", map[string]any{"NumIntegrations": limit}, "", http.StatusBadRequest) - } - - return nil -} diff --git a/app/integrations_test.go b/app/integrations_test.go deleted file mode 100644 index 3a3b6b6c6a..0000000000 --- a/app/integrations_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package app - -import ( - "testing" - - "github.com/mattermost/mattermost-server/v6/model" - "github.com/stretchr/testify/require" -) - -func TestGetIntegrationsUsage(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - samplePluginCode := ` - package main - - import ( - "github.com/mattermost/mattermost-server/v6/plugin" - ) - - type MyPlugin struct { - plugin.MattermostPlugin - } - - func main() { - plugin.ClientMain(&MyPlugin{}) - } - ` - - setupMultiPluginAPITest(t, - []string{samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode}, []string{ - `{"id": "otherplugin", "name": "Other Plugin", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "mattermost-autolink", "name": "Autolink", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "playbooks", "name": "Playbooks", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "focalboard", "name": "Mattermost Boards", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "com.mattermost.calls", "name": "Calls", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "com.mattermost.nps", "name": "User Satisfaction Surveys", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "com.mattermost.apps", "server": {"executable": "backend.exe"}}`, - }, []string{"otherplugin", "mattermost-autolink", "playbooks", "focalboard", "com.mattermost.calls", "com.mattermost.nps", "com.mattermost.apps"}, - true, th.App, th.Context) - - integrations, appErr := th.App.ch.getInstalledIntegrations() - require.Nil(t, appErr) - - expected := []*model.InstalledIntegration{ - { - Type: "plugin", - ID: "mattermost-autolink", - Name: "Autolink", - Version: "1.2.0", - Enabled: true, - }, - { - Type: "plugin", - ID: "otherplugin", - Name: "Other Plugin", - Version: "1.2.0", - Enabled: true, - }, - } - require.Equal(t, expected, integrations) - - usage, appErr := th.App.GetIntegrationsUsage() - require.Nil(t, appErr) - - // 2 enabled integrations - expectedUsage := &model.IntegrationsUsage{ - Enabled: 2, - } - require.Equal(t, expectedUsage, usage) -} diff --git a/app/login.go b/app/login.go index bd63787546..e0854bef37 100644 --- a/app/login.go +++ b/app/login.go @@ -157,17 +157,15 @@ func (a *App) GetUserForLogin(id, loginId string) (*model.User, *model.AppError) } func (a *App) DoLogin(c *request.Context, w http.ResponseWriter, r *http.Request, user *model.User, deviceID string, isMobile, isOAuthUser, isSaml bool) *model.AppError { - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var rejectionReason string - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - rejectionReason = hooks.UserWillLogIn(pluginContext, user) - return rejectionReason == "" - }, plugin.UserWillLogInID) + var rejectionReason string + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + rejectionReason = hooks.UserWillLogIn(pluginContext, user) + return rejectionReason == "" + }, plugin.UserWillLogInID) - if rejectionReason != "" { - return model.NewAppError("DoLogin", "Login rejected by plugin: "+rejectionReason, nil, "", http.StatusBadRequest) - } + if rejectionReason != "" { + return model.NewAppError("DoLogin", "Login rejected by plugin: "+rejectionReason, nil, "", http.StatusBadRequest) } session := &model.Session{UserId: user.Id, Roles: user.GetRawRoles(), DeviceId: deviceID, IsOAuth: false, Props: map[string]string{ @@ -226,15 +224,12 @@ func (a *App) DoLogin(c *request.Context, w http.ResponseWriter, r *http.Request }) } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.UserHasLoggedIn(pluginContext, user) - return true - }, plugin.UserHasLoggedInID) - }) - } + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasLoggedIn(pluginContext, user) + return true + }, plugin.UserHasLoggedInID) + }) return nil } diff --git a/app/notification.go b/app/notification.go index 99c1a9a9bb..cd275134d4 100644 --- a/app/notification.go +++ b/app/notification.go @@ -305,7 +305,8 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea mentionedUsersList = append(mentionedUsersList, id) } - nErr := a.Srv().Store().Channel().IncrementMentionCount(post.ChannelId, mentionedUsersList, post.RootId == "") + nErr := a.Srv().Store().Channel().IncrementMentionCount(post.ChannelId, mentionedUsersList, post.RootId == "", post.IsUrgent()) + if nErr != nil { mlog.Warn( "Failed to update mention count", @@ -596,7 +597,7 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea } threadMembership = tm } - userThread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true) + userThread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true, a.isPostPriorityEnabled()) if err != nil { return nil, errors.Wrapf(err, "cannot get thread %q for user %q", post.RootId, uid) } diff --git a/app/notification_email.go b/app/notification_email.go index 965dd9a292..a56180ea2f 100644 --- a/app/notification_email.go +++ b/app/notification_email.go @@ -12,6 +12,7 @@ import ( "strings" "time" + email "github.com/mattermost/mattermost-server/v6/app/email" "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/i18n" @@ -201,18 +202,6 @@ func truncateUserNames(name string, i int) string { return name } -type FieldRow struct { - Cells []*model.SlackAttachmentField -} - -type EmailMessageAttachment struct { - model.SlackAttachment - - Pretext template.HTML - Text template.HTML - FieldRows []FieldRow -} - type postData struct { SenderName string ChannelName string @@ -223,7 +212,7 @@ type postData struct { Time string ShowChannelIcon bool OtherChannelMembersCount int - MessageAttachments []*EmailMessageAttachment + MessageAttachments []*email.EmailMessageAttachment } /** @@ -258,7 +247,7 @@ func (a *App) getNotificationEmailBody(c request.CTX, recipient *model.User, pos } pData.Message = template.HTML(normalizedPostMessage) pData.Time = translateFunc("app.notification.body.dm.time", messageTime) - pData.MessageAttachments = a.processMessageAttachments(post) + pData.MessageAttachments = email.ProcessMessageAttachments(post) } data := a.Srv().EmailService.NewEmailTemplateData(recipient.Locale) @@ -320,81 +309,6 @@ func (a *App) getNotificationEmailBody(c request.CTX, recipient *model.User, pos return a.Srv().TemplatesContainer().RenderToString("messages_notification", data) } -func (a *App) processMessageAttachments(post *model.Post) []*EmailMessageAttachment { - emailMessageAttachments := []*EmailMessageAttachment{} - - for _, messageAttachment := range post.Attachments() { - emailMessageAttachment := &EmailMessageAttachment{ - SlackAttachment: *messageAttachment, - Pretext: a.prepareTextForEmail(messageAttachment.Pretext), - Text: a.prepareTextForEmail(messageAttachment.Text), - } - - stripedTitle, err := utils.StripMarkdown(emailMessageAttachment.Title) - if err != nil { - mlog.Warn("Failed parse to markdown from messageatatchment title", mlog.String("post_id", post.Id), mlog.Err(err)) - stripedTitle = "" - } - - emailMessageAttachment.Title = stripedTitle - - shortFieldRow := FieldRow{} - - for i := range messageAttachment.Fields { - // Create a new instance to avoid altering the original pointer reference - // We update field value to parse markdown. - // If we do that on the original pointer, the rendered text in mattermost - // becomes invalid as its no longer a markdown string, but rather an HTML string. - field := &model.SlackAttachmentField{ - Title: messageAttachment.Fields[i].Title, - Value: messageAttachment.Fields[i].Value, - Short: messageAttachment.Fields[i].Short, - } - - if stringValue, ok := field.Value.(string); ok { - field.Value = a.prepareTextForEmail(stringValue) - } - - if !field.Short { - if len(shortFieldRow.Cells) > 0 { - emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) - shortFieldRow = FieldRow{} - } - - emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, FieldRow{[]*model.SlackAttachmentField{field}}) - } else { - shortFieldRow.Cells = append(shortFieldRow.Cells, field) - - if len(shortFieldRow.Cells) == 2 { - emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) - shortFieldRow = FieldRow{} - } - } - } - - // collect any leftover short fields - if len(shortFieldRow.Cells) > 0 { - emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) - shortFieldRow = FieldRow{} - } - - emailMessageAttachments = append(emailMessageAttachments, emailMessageAttachment) - } - - return emailMessageAttachments -} - -func (a *App) prepareTextForEmail(text string) template.HTML { - escapedText := html.EscapeString(text) - markdownText, err := utils.MarkdownToHTML(escapedText) - if err != nil { - mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(err)) - return template.HTML(text) - } - - return template.HTML(markdownText) -} - type formattedPostTime struct { Time time.Time Year string diff --git a/app/onboarding.go b/app/onboarding.go index 9a9e25739c..d76525f017 100644 --- a/app/onboarding.go +++ b/app/onboarding.go @@ -53,7 +53,7 @@ func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnbo return } - hooks, err := pluginsEnvironment.HooksForPlugin(id) + hooks, err := a.ch.HooksForPluginOrProduct(id) if err != nil { mlog.Warn("Getting hooks for plugin failed", mlog.String("plugin_id", id), mlog.Err(err)) return diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index b40c1921aa..2d60825ebc 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1200,28 +1200,6 @@ func (a *OpenTracingAppLayer) CheckForClientSideCert(r *http.Request) (string, s return resultVar0, resultVar1, resultVar2 } -func (a *OpenTracingAppLayer) CheckFreemiumLimitsForConfigSave(oldConfig *model.Config, newConfig *model.Config) *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckFreemiumLimitsForConfigSave") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0 := a.app.CheckFreemiumLimitsForConfigSave(oldConfig, newConfig) - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) CheckIntegrity() <-chan model.IntegrityCheckResult { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckIntegrity") @@ -2029,6 +2007,28 @@ func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, param return resultVar0 } +func (a *OpenTracingAppLayer) CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.CreateDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateEmoji") @@ -2860,6 +2860,28 @@ func (a *OpenTracingAppLayer) DefaultChannelNames(c request.CTX) []string { return resultVar0 } +func (a *OpenTracingAppLayer) DeleteAcknowledgementForPost(c *request.Context, postID string, userID string) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteAcknowledgementForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.DeleteAcknowledgementForPost(c, postID, userID) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) DeleteAllExpiredPluginKeys() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteAllExpiredPluginKeys") @@ -2992,6 +3014,28 @@ func (a *OpenTracingAppLayer) DeleteCommand(commandID string) *model.AppError { return resultVar0 } +func (a *OpenTracingAppLayer) DeleteDraft(userID string, channelID string, rootID string, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.DeleteDraft(userID, channelID, rootID, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteEmoji") @@ -4433,6 +4477,50 @@ func (a *OpenTracingAppLayer) GenerateSupportPacket() []model.FileData { return resultVar0 } +func (a *OpenTracingAppLayer) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAcknowledgementsForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetAcknowledgementsForPost(postID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAcknowledgementsForPostList") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetAcknowledgementsForPostList(postList) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetActivePluginManifests") @@ -5804,6 +5892,50 @@ func (a *OpenTracingAppLayer) GetDeletedChannels(c request.CTX, teamID string, o return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetDraft(userID string, channelID string, rootID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetDraft(userID, channelID, rootID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetDraftsForUser") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetDraftsForUser(userID, teamID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetEmoji(c request.CTX, emojiId string) (*model.Emoji, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetEmoji") @@ -6630,28 +6762,6 @@ func (a *OpenTracingAppLayer) GetIncomingWebhooksPageByUser(userID string, page return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetIntegrationsUsage") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0, resultVar1 := a.app.GetIntegrationsUsage() - - if resultVar1 != nil { - span.LogFields(spanlog.Error(resultVar1)) - ext.Error.Set(span, true) - } - - return resultVar0, resultVar1 -} - func (a *OpenTracingAppLayer) GetJob(id string) (*model.Job, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetJob") @@ -7842,6 +7952,28 @@ func (a *OpenTracingAppLayer) GetPostIfAuthorized(c request.CTX, postID string, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetPostInfo(c request.CTX, postID string) (*model.PostInfo, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPostInfo") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetPostInfo(c, postID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetPostThread(postID string, opts model.GetPostsOptions, userID string) (*model.PostList, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPostThread") @@ -8162,6 +8294,50 @@ func (a *OpenTracingAppLayer) GetPrevPostIdFromPostList(postList *model.PostList return resultVar0 } +func (a *OpenTracingAppLayer) GetPriorityForPost(postId string) (*model.PostPriority, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPriorityForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetPriorityForPost(postId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetPriorityForPostList(list *model.PostList) (map[string]*model.PostPriority, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPriorityForPostList") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetPriorityForPostList(list) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetPrivateChannelsForTeam(c request.CTX, teamID string, offset int, limit int) (model.ChannelList, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPrivateChannelsForTeam") @@ -11060,6 +11236,50 @@ func (a *OpenTracingAppLayer) GetWarnMetricsStatus() (map[string]*model.WarnMetr return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWorkTemplateCategories") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetWorkTemplateCategories(t) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWorkTemplates") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetWorkTemplates(category, featureFlags, t) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) Handle404(w http.ResponseWriter, r *http.Request) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.Handle404") @@ -11721,23 +11941,6 @@ func (a *OpenTracingAppLayer) IsPhase2MigrationCompleted() *model.AppError { return resultVar0 } -func (a *OpenTracingAppLayer) IsUserAway(lastActivityAt int64) bool { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsUserAway") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0 := a.app.IsUserAway(lastActivityAt) - - return resultVar0 -} - func (a *OpenTracingAppLayer) IsUserSignUpAllowed() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsUserSignUpAllowed") @@ -13067,7 +13270,7 @@ func (a *OpenTracingAppLayer) PostWithProxyRemovedFromImageURLs(post *model.Post return resultVar0 } -func (a *OpenTracingAppLayer) PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost bool, isEditPost bool) *model.Post { +func (a *OpenTracingAppLayer) PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost bool, isEditPost bool, includePriority bool) *model.Post { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PreparePostForClient") @@ -13079,12 +13282,12 @@ func (a *OpenTracingAppLayer) PreparePostForClient(c request.CTX, originalPost * }() defer span.Finish() - resultVar0 := a.app.PreparePostForClient(c, originalPost, isNewPost, isEditPost) + resultVar0 := a.app.PreparePostForClient(c, originalPost, isNewPost, isEditPost, includePriority) return resultVar0 } -func (a *OpenTracingAppLayer) PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost bool, isEditPost bool) *model.Post { +func (a *OpenTracingAppLayer) PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost bool, isEditPost bool, includePriority bool) *model.Post { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PreparePostForClientWithEmbedsAndImages") @@ -13096,7 +13299,7 @@ func (a *OpenTracingAppLayer) PreparePostForClientWithEmbedsAndImages(c request. }() defer span.Finish() - resultVar0 := a.app.PreparePostForClientWithEmbedsAndImages(c, originalPost, isNewPost, isEditPost) + resultVar0 := a.app.PreparePostForClientWithEmbedsAndImages(c, originalPost, isNewPost, isEditPost, includePriority) return resultVar0 } @@ -14312,6 +14515,28 @@ func (a *OpenTracingAppLayer) SanitizeTeams(session model.Session, teams []*mode return resultVar0 } +func (a *OpenTracingAppLayer) SaveAcknowledgementForPost(c *request.Context, postID string, userID string) (*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAcknowledgementForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.SaveAcknowledgementForPost(c, postID, userID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAdminNotification") @@ -15265,6 +15490,28 @@ func (a *OpenTracingAppLayer) SendPaymentFailedEmail(failedPayment *model.Failed return resultVar0 } +func (a *OpenTracingAppLayer) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendSubscriptionHistoryEvent") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.SendSubscriptionHistoryEvent(userID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendTestPushNotification") @@ -15282,7 +15529,7 @@ func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string { return resultVar0 } -func (a *OpenTracingAppLayer) SendUpgradeConfirmationEmail() *model.AppError { +func (a *OpenTracingAppLayer) SendUpgradeConfirmationEmail(isYearly bool) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendUpgradeConfirmationEmail") @@ -15294,7 +15541,7 @@ func (a *OpenTracingAppLayer) SendUpgradeConfirmationEmail() *model.AppError { }() defer span.Finish() - resultVar0 := a.app.SendUpgradeConfirmationEmail() + resultVar0 := a.app.SendUpgradeConfirmationEmail(isYearly) if resultVar0 != nil { span.LogFields(spanlog.Error(resultVar0)) @@ -16880,6 +17127,28 @@ func (a *OpenTracingAppLayer) UpdateDNDStatusOfUsers() { a.app.UpdateDNDStatusOfUsers() } +func (a *OpenTracingAppLayer) UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.UpdateDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateEphemeralPost") @@ -17029,21 +17298,6 @@ func (a *OpenTracingAppLayer) UpdateIncomingWebhook(oldHook *model.IncomingWebho return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) UpdateLastActivityAtIfNeeded(session model.Session) { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateLastActivityAtIfNeeded") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - a.app.UpdateLastActivityAtIfNeeded(session) -} - func (a *OpenTracingAppLayer) UpdateMfa(c request.CTX, activate bool, userID string, token string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateMfa") @@ -17969,6 +18223,28 @@ func (a *OpenTracingAppLayer) UploadFileX(c *request.Context, channelID string, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpsertDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.UpsertDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpsertGroupMember") diff --git a/app/permissions_migrations.go b/app/permissions_migrations.go index 2b638be162..34661b5d11 100644 --- a/app/permissions_migrations.go +++ b/app/permissions_migrations.go @@ -10,6 +10,7 @@ import ( "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/store/sqlstore" ) type permissionTransformation struct { @@ -74,18 +75,73 @@ const ( PermissionManageRemoteClusters = "manage_remote_clusters" // deprecated; use `manage_secure_connections` ) -func isRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { +// Deprecated: This function should only be used if a case arises where team and/or channel scheme roles do not need to be migrated. +// Otherwise, use isRole. +func isExactRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { return role.Name == roleName } } -func isNotRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { +// isRole returns true if roleName matches a role's name field or if the a team +// or channel scheme role matches a "common name". A common name is one of the following role +// that is common among the system scheme and the team and/or channel schemes: +// +// TeamAdmin, +// TeamUser, +// TeamGuest, +// ChannelAdmin, +// ChannelUser, +// ChannelGuest, +// PlaybookAdmin, +// PlaybookMember, +// RunAdmin, +// RunMember +func isRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { + return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { + if role.Name == roleName { + return true + } + return isSchemeRoleAssociatedToCommonName(roleName, role) + } +} + +// Deprecated: use isNotRole instead. +func isNotExactRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { return role.Name != roleName } } +func isNotRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { + return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { + return role.Name != roleName && !isSchemeRoleAssociatedToCommonName(roleName, role) + } +} + +func isSchemeRoleAssociatedToCommonName(roleName string, role *model.Role) bool { + roleIDToSchemeRoleDisplayName := map[string]string{ + model.TeamAdminRoleId: sqlstore.SchemeRoleDisplayNameTeamAdmin, + model.TeamUserRoleId: sqlstore.SchemeRoleDisplayNameTeamUser, + model.TeamGuestRoleId: sqlstore.SchemeRoleDisplayNameTeamGuest, + + model.ChannelAdminRoleId: sqlstore.SchemeRoleDisplayNameChannelAdmin, + model.ChannelUserRoleId: sqlstore.SchemeRoleDisplayNameChannelUser, + model.ChannelGuestRoleId: sqlstore.SchemeRoleDisplayNameChannelGuest, + + model.PlaybookAdminRoleId: sqlstore.SchemeRoleDisplayNamePlaybookAdmin, + model.PlaybookMemberRoleId: sqlstore.SchemeRoleDisplayNamePlaybookMember, + + model.RunAdminRoleId: sqlstore.SchemeRoleDisplayNameRunAdmin, + model.RunMemberRoleId: sqlstore.SchemeRoleDisplayNameRunMember, + } + displayName, ok := roleIDToSchemeRoleDisplayName[roleName] + if !ok { + return false + } + return strings.HasPrefix(role.DisplayName, displayName) +} + func isNotSchemeRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { return !strings.Contains(role.DisplayName, roleName) @@ -222,12 +278,12 @@ func (a *App) getWebhooksPermissionsSplitMigration() (permissionsMap, error) { func (a *App) getListJoinPublicPrivateTeamsPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionListPrivateTeams, PermissionJoinPrivateTeams}, Remove: []string{}, }, permissionTransformation{ - On: isRole(model.SystemUserRoleId), + On: isExactRole(model.SystemUserRoleId), Add: []string{PermissionListPublicTeams, PermissionJoinPublicTeams}, Remove: []string{}, }, @@ -246,7 +302,7 @@ func (a *App) removePermanentDeleteUserMigration() (permissionsMap, error) { func (a *App) getAddBotPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionCreateBot, PermissionReadBots, PermissionReadOthersBots, PermissionManageBots, PermissionManageOthersBots}, Remove: []string{}, }, @@ -256,19 +312,19 @@ func (a *App) getAddBotPermissionsMigration() (permissionsMap, error) { func (a *App) applyChannelManageDeleteToChannelUser() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: permissionAnd(isRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionManagePrivateChannelProperties))), + On: permissionAnd(isExactRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionManagePrivateChannelProperties))), Add: []string{PermissionManagePrivateChannelProperties}, }, permissionTransformation{ - On: permissionAnd(isRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionDeletePrivateChannel))), + On: permissionAnd(isExactRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionDeletePrivateChannel))), Add: []string{PermissionDeletePrivateChannel}, }, permissionTransformation{ - On: permissionAnd(isRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionManagePublicChannelProperties))), + On: permissionAnd(isExactRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionManagePublicChannelProperties))), Add: []string{PermissionManagePublicChannelProperties}, }, permissionTransformation{ - On: permissionAnd(isRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionDeletePublicChannel))), + On: permissionAnd(isExactRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionDeletePublicChannel))), Add: []string{PermissionDeletePublicChannel}, }, }, nil @@ -277,19 +333,19 @@ func (a *App) applyChannelManageDeleteToChannelUser() (permissionsMap, error) { func (a *App) removeChannelManageDeleteFromTeamUser() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: permissionAnd(isRole(model.TeamUserRoleId), permissionExists(PermissionManagePrivateChannelProperties)), + On: permissionAnd(isExactRole(model.TeamUserRoleId), permissionExists(PermissionManagePrivateChannelProperties)), Remove: []string{PermissionManagePrivateChannelProperties}, }, permissionTransformation{ - On: permissionAnd(isRole(model.TeamUserRoleId), permissionExists(PermissionDeletePrivateChannel)), + On: permissionAnd(isExactRole(model.TeamUserRoleId), permissionExists(PermissionDeletePrivateChannel)), Remove: []string{model.PermissionDeletePrivateChannel.Id}, }, permissionTransformation{ - On: permissionAnd(isRole(model.TeamUserRoleId), permissionExists(PermissionManagePublicChannelProperties)), + On: permissionAnd(isExactRole(model.TeamUserRoleId), permissionExists(PermissionManagePublicChannelProperties)), Remove: []string{PermissionManagePublicChannelProperties}, }, permissionTransformation{ - On: permissionAnd(isRole(model.TeamUserRoleId), permissionExists(PermissionDeletePublicChannel)), + On: permissionAnd(isExactRole(model.TeamUserRoleId), permissionExists(PermissionDeletePublicChannel)), Remove: []string{PermissionDeletePublicChannel}, }, }, nil @@ -298,11 +354,11 @@ func (a *App) removeChannelManageDeleteFromTeamUser() (permissionsMap, error) { func (a *App) getViewMembersPermissionMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemUserRoleId), + On: isExactRole(model.SystemUserRoleId), Add: []string{PermissionViewMembers}, }, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionViewMembers}, }, }, nil @@ -311,7 +367,7 @@ func (a *App) getViewMembersPermissionMigration() (permissionsMap, error) { func (a *App) getAddManageGuestsPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionPromoteGuest, PermissionDemoteToGuest, PermissionInviteGuest}, }, }, nil @@ -342,7 +398,7 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { // add each moderated permission to the channel admin if channel user or guest has the permission trans := permissionTransformation{ On: permissionAnd( - isRole(channelAdminID), + isExactRole(channelAdminID), permissionOr( onOtherRole(channelUserID, permissionExists(perm)), onOtherRole(channelGuestID, permissionExists(perm)), @@ -355,7 +411,7 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { // add each moderated permission to the team admin if channel admin, user, or guest has the permission trans = permissionTransformation{ On: permissionAnd( - isRole(teamAdminID), + isExactRole(teamAdminID), permissionOr( onOtherRole(channelAdminID, permissionExists(perm)), onOtherRole(channelUserID, permissionExists(perm)), @@ -373,14 +429,14 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { for _, ts := range allTeamSchemes { // ensure all team scheme channel admins have create_post because it's not exposed via the UI trans := permissionTransformation{ - On: isRole(ts.DefaultChannelAdminRole), + On: isExactRole(ts.DefaultChannelAdminRole), Add: []string{PermissionCreatePost}, } transformations = append(transformations, trans) // ensure all team scheme team admins have create_post because it's not exposed via the UI trans = permissionTransformation{ - On: isRole(ts.DefaultTeamAdminRole), + On: isExactRole(ts.DefaultTeamAdminRole), Add: []string{PermissionCreatePost}, } transformations = append(transformations, trans) @@ -396,13 +452,13 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { // ensure team admins have create_post transformations = append(transformations, permissionTransformation{ - On: isRole(model.TeamAdminRoleId), + On: isExactRole(model.TeamAdminRoleId), Add: []string{PermissionCreatePost}, }) // ensure channel admins have create_post transformations = append(transformations, permissionTransformation{ - On: isRole(model.ChannelAdminRoleId), + On: isExactRole(model.ChannelAdminRoleId), Add: []string{PermissionCreatePost}, }) @@ -416,7 +472,7 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { // ensure system admin has all of the moderated permissions transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: append(moderatedPermissionsMinusCreatePost, PermissionCreatePost), }) @@ -433,8 +489,8 @@ func (a *App) getAddUseGroupMentionsPermissionMigration() (permissionsMap, error return permissionsMap{ permissionTransformation{ On: permissionAnd( - isNotRole(model.ChannelGuestRoleId), - isNotSchemeRole("Channel Guest Role for Scheme"), + isNotExactRole(model.ChannelGuestRoleId), + isNotSchemeRole(sqlstore.SchemeRoleDisplayNameChannelGuest), permissionOr(permissionExists(PermissionCreatePost), permissionExists(PermissionCreatePost_PUBLIC)), ), Add: []string{PermissionUseGroupMentions}, @@ -453,7 +509,7 @@ func (a *App) getAddSystemConsolePermissionsMigration() (permissionsMap, error) // add the new permissions to system admin transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: permissionsToAdd, }) @@ -502,7 +558,7 @@ func (a *App) getAddConvertChannelPermissionsMigration() (permissionsMap, error) func (a *App) getSystemRolesPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{model.PermissionSysconsoleReadUserManagementSystemRoles.Id, model.PermissionSysconsoleWriteUserManagementSystemRoles.Id}, }, }, nil @@ -511,7 +567,7 @@ func (a *App) getSystemRolesPermissionsMigration() (permissionsMap, error) { func (a *App) getAddManageSharedChannelsPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionManageSharedChannels}, }, }, nil @@ -520,7 +576,7 @@ func (a *App) getAddManageSharedChannelsPermissionsMigration() (permissionsMap, func (a *App) getBillingPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{model.PermissionSysconsoleReadBilling.Id, model.PermissionSysconsoleWriteBilling.Id}, }, }, nil @@ -532,14 +588,14 @@ func (a *App) getAddManageSecureConnectionsPermissionsMigration() (permissionsMa // add the new permission to system admin transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionManageSecureConnections}, }) // remote the deprecated permission from system admin transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Remove: []string{PermissionManageRemoteClusters}, }) @@ -555,7 +611,7 @@ func (a *App) getAddDownloadComplianceExportResult() (permissionsMap, error) { // add the new permissions to system admin transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{model.PermissionDownloadComplianceExportResult.Id}, }) @@ -926,12 +982,12 @@ func (a *App) getAddCustomUserGroupsPermissions() (permissionsMap, error) { } t = append(t, permissionTransformation{ - On: isRole(model.SystemUserRoleId), + On: isExactRole(model.SystemUserRoleId), Add: customGroupPermissions, }) t = append(t, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: customGroupPermissions, }) @@ -953,7 +1009,7 @@ func (a *App) getAddPlaybooksPermissions() (permissionsMap, error) { }) transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{ model.PermissionPublicPlaybookManageProperties.Id, model.PermissionPublicPlaybookManageMembers.Id, @@ -978,9 +1034,9 @@ func (a *App) getPlaybooksPermissionsAddManageRoles() (permissionsMap, error) { transformations = append(transformations, permissionTransformation{ On: permissionOr( - isRole(model.PlaybookAdminRoleId), - isRole(model.TeamAdminRoleId), - isRole(model.SystemAdminRoleId), + isExactRole(model.PlaybookAdminRoleId), + isExactRole(model.TeamAdminRoleId), + isExactRole(model.SystemAdminRoleId), ), Add: []string{ model.PermissionPublicPlaybookManageRoles.Id, @@ -999,13 +1055,13 @@ func (a *App) getProductsBoardsPermissions() (permissionsMap, error) { // Give the new subsection READ permissions to any user with SYSTEM_MANAGER transformations = append(transformations, permissionTransformation{ - On: permissionOr(isRole(model.SystemManagerRoleId)), + On: permissionOr(isExactRole(model.SystemManagerRoleId)), Add: permissionsProductsRead, }) // Give the new subsection WRITE permissions to any user with SYSTEM_ADMIN transformations = append(transformations, permissionTransformation{ - On: permissionOr(isRole(model.SystemAdminRoleId)), + On: permissionOr(isExactRole(model.SystemAdminRoleId)), Add: permissionsProductsWrite, }) diff --git a/app/permissions_migrations_test.go b/app/permissions_migrations_test.go index 1b20ddf1d4..29093c8cf9 100644 --- a/app/permissions_migrations_test.go +++ b/app/permissions_migrations_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store/sqlstore" ) func TestApplyPermissionsMap(t *testing.T) { @@ -137,7 +138,7 @@ func TestApplyPermissionsMap(t *testing.T) { }, }, permissionsMap{permissionTransformation{ - On: isRole("system_admin"), + On: isExactRole("system_admin"), Add: []string{"test4"}, }}, []string{"test1", "test2", "test3", "test4"}, @@ -152,7 +153,7 @@ func TestApplyPermissionsMap(t *testing.T) { }, }, permissionsMap{permissionTransformation{ - On: isRole("system_user"), + On: isExactRole("system_user"), Add: []string{"test4"}, }}, []string{"test1", "test2", "test3"}, @@ -203,3 +204,74 @@ func TestApplyPermissionsMap(t *testing.T) { }) } } + +func TestApplyPermissionsMapToSchemeRole(t *testing.T) { + schemeRoleName := model.NewId() + tt := []struct { + Name string + RoleMap map[string]map[string]bool + TranslationMap permissionsMap + ExpectedResult []string + }{ + { + "Adds a permission to a scheme role with a matching common name", + map[string]map[string]bool{ + schemeRoleName: { + "test1": true, + }, + }, + permissionsMap{permissionTransformation{ + On: isRole(model.TeamAdminRoleId), + Add: []string{"test2"}, + }}, + []string{"test1", "test2"}, + }, + { + "Doesn't add a permission to a scheme role with a different common name", + map[string]map[string]bool{ + schemeRoleName: { + "test1": true, + }, + }, + permissionsMap{permissionTransformation{ + On: isRole(model.ChannelAdminRoleId), + Add: []string{"test2"}, + }}, + []string{"test1"}, + }, + { + "Doesn't add a permission to a role with a the same exact name", + map[string]map[string]bool{ + schemeRoleName: { + "test1": true, + }, + }, + permissionsMap{permissionTransformation{ + On: isNotRole(schemeRoleName), + Add: []string{"test2"}, + }}, + []string{"test1"}, + }, + { + "Doesn't add a permission to a role with a different exact name but the same common name", + map[string]map[string]bool{ + schemeRoleName: { + "test1": true, + }, + }, + permissionsMap{permissionTransformation{ + On: isNotRole(model.TeamAdminRoleId), + Add: []string{"test2"}, + }}, + []string{"test1"}, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + result := applyPermissionsMap(&model.Role{Name: schemeRoleName, DisplayName: sqlstore.SchemeRoleDisplayNameTeamAdmin}, tc.RoleMap, tc.TranslationMap) + sort.Strings(result) + assert.Equal(t, tc.ExpectedResult, result) + }) + } +} diff --git a/app/platform/config.go b/app/platform/config.go index fd5bb05f76..e8eb425b96 100644 --- a/app/platform/config.go +++ b/app/platform/config.go @@ -75,12 +75,6 @@ func (ps *PlatformService) SaveConfig(newCfg *model.Config, sendConfigChangeClus return nil, nil, model.NewAppError("saveConfig", "app.save_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - if ps.startMetrics && *ps.Config().MetricsSettings.Enable { - ps.RestartMetrics() - } else { - ps.ShutdownMetrics() - } - if ps.clusterIFace != nil { err := ps.clusterIFace.ConfigChanged(ps.configStore.RemoveEnvironmentOverrides(oldCfg), ps.configStore.RemoveEnvironmentOverrides(newCfg), sendConfigChangeClusterMessage) diff --git a/app/platform/config_test.go b/app/platform/config_test.go index af99b47d89..8268b32929 100644 --- a/app/platform/config_test.go +++ b/app/platform/config_test.go @@ -70,4 +70,31 @@ func TestConfigSave(t *testing.T) { updatedCfg := th.Service.Config() assert.Equal(t, "http://newhost.me", *updatedCfg.ServiceSettings.SiteURL) }) + + t.Run("do not restart the metrics server on a different type of config change", func(t *testing.T) { + th := Setup(t, StartMetrics()) + defer th.TearDown() + + metricsMock := &mocks.MetricsInterface{} + metricsMock.On("IncrementWebsocketEvent", mock.AnythingOfType("string")).Return() + metricsMock.On("IncrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("DecrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("Register").Return() + th.Service.metricsIFace = metricsMock + + // Change a random config setting + cfg := th.Service.Config().Clone() + cfg.ThemeSettings.EnableThemeSelection = model.NewBool(!*cfg.ThemeSettings.EnableThemeSelection) + th.Service.SaveConfig(cfg, false) + metricsMock.AssertNumberOfCalls(t, "Register", 0) + + // Disable metrics + cfg.MetricsSettings.Enable = model.NewBool(false) + th.Service.SaveConfig(cfg, false) + + // Change the metrics setting + cfg.MetricsSettings.Enable = model.NewBool(true) + th.Service.SaveConfig(cfg, false) + metricsMock.AssertNumberOfCalls(t, "Register", 1) + }) } diff --git a/app/platform/helper_test.go b/app/platform/helper_test.go index 9402f8def3..5b1a44d344 100644 --- a/app/platform/helper_test.go +++ b/app/platform/helper_test.go @@ -181,7 +181,7 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo th.Service.SetLicense(nil) } - err = th.Service.Start(th.Suite) + err = th.Service.Start() if err != nil { panic(err) } diff --git a/app/platform/mocks/SuiteIFace.go b/app/platform/mocks/SuiteIFace.go index 6b337a8059..b919999f6e 100644 --- a/app/platform/mocks/SuiteIFace.go +++ b/app/platform/mocks/SuiteIFace.go @@ -39,20 +39,6 @@ func (_m *SuiteIFace) GetSession(token string) (*model.Session, *model.AppError) return r0, r1 } -// IsUserAway provides a mock function with given fields: lastActivityAt -func (_m *SuiteIFace) IsUserAway(lastActivityAt int64) bool { - ret := _m.Called(lastActivityAt) - - var r0 bool - if rf, ok := ret.Get(0).(func(int64) bool); ok { - r0 = rf(lastActivityAt) - } else { - r0 = ret.Get(0).(bool) - } - - return r0 -} - // RolesGrantPermission provides a mock function with given fields: roleNames, permissionId func (_m *SuiteIFace) RolesGrantPermission(roleNames []string, permissionId string) bool { ret := _m.Called(roleNames, permissionId) @@ -67,31 +53,6 @@ func (_m *SuiteIFace) RolesGrantPermission(roleNames []string, permissionId stri return r0 } -// SetStatusAwayIfNeeded provides a mock function with given fields: userID, manual -func (_m *SuiteIFace) SetStatusAwayIfNeeded(userID string, manual bool) { - _m.Called(userID, manual) -} - -// SetStatusLastActivityAt provides a mock function with given fields: userID, activityAt -func (_m *SuiteIFace) SetStatusLastActivityAt(userID string, activityAt int64) { - _m.Called(userID, activityAt) -} - -// SetStatusOffline provides a mock function with given fields: userID, manual -func (_m *SuiteIFace) SetStatusOffline(userID string, manual bool) { - _m.Called(userID, manual) -} - -// SetStatusOnline provides a mock function with given fields: userID, manual -func (_m *SuiteIFace) SetStatusOnline(userID string, manual bool) { - _m.Called(userID, manual) -} - -// UpdateLastActivityAtIfNeeded provides a mock function with given fields: session -func (_m *SuiteIFace) UpdateLastActivityAtIfNeeded(session model.Session) { - _m.Called(session) -} - // UserCanSeeOtherUser provides a mock function with given fields: userID, otherUserId func (_m *SuiteIFace) UserCanSeeOtherUser(userID string, otherUserId string) (bool, *model.AppError) { ret := _m.Called(userID, otherUserId) diff --git a/app/platform/service.go b/app/platform/service.go index 0fa635d5d8..857e14e3c8 100644 --- a/app/platform/service.go +++ b/app/platform/service.go @@ -95,7 +95,12 @@ type PlatformService struct { additionalClusterHandlers map[model.ClusterEvent]einterfaces.ClusterMessageHandler sharedChannelService SharedChannelServiceIFace - pluginEnv *plugin.Environment + pluginEnv HookRunner +} + +type HookRunner interface { + RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) + GetPluginsEnvironment() *plugin.Environment } // New creates a new PlatformService. @@ -263,6 +268,14 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { if mErr := ps.resetMetrics(); mErr != nil { return nil, mErr } + + ps.configStore.AddListener(func(oldCfg, newCfg *model.Config) { + if *oldCfg.MetricsSettings.Enable != *newCfg.MetricsSettings.Enable || *oldCfg.MetricsSettings.ListenAddress != *newCfg.MetricsSettings.ListenAddress { + if mErr := ps.resetMetrics(); mErr != nil { + mlog.Warn("Failed to reset metrics", mlog.Err(mErr)) + } + } + }) } // Step 9: Init AsymmetricSigningKey depends on step 6 (store) @@ -299,8 +312,8 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { return ps, nil } -func (ps *PlatformService) Start(suite SuiteIFace) error { - ps.hubStart(suite) +func (ps *PlatformService) Start() error { + ps.hubStart() ps.configListenerId = ps.AddConfigListener(func(_, _ *model.Config) { ps.regenerateClientConfig() @@ -426,17 +439,17 @@ func (ps *PlatformService) SetSharedChannelService(s SharedChannelServiceIFace) ps.sharedChannelService = s } -func (ps *PlatformService) SetPluginsEnvironment(env *plugin.Environment) { - ps.pluginEnv = env +func (ps *PlatformService) SetPluginsEnvironment(runner HookRunner) { + ps.pluginEnv = runner } // GetPluginStatuses meant to be used by cluster implementation func (ps *PlatformService) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { - if ps.pluginEnv == nil { + if ps.pluginEnv == nil || ps.pluginEnv.GetPluginsEnvironment() == nil { return nil, model.NewAppError("GetPluginStatuses", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } - pluginStatuses, err := ps.pluginEnv.Statuses() + pluginStatuses, err := ps.pluginEnv.GetPluginsEnvironment().Statuses() if err != nil { return nil, model.NewAppError("GetPluginStatuses", "app.plugin.get_statuses.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/app/platform/service_test.go b/app/platform/service_test.go index 11633b2d5b..8654b18685 100644 --- a/app/platform/service_test.go +++ b/app/platform/service_test.go @@ -105,10 +105,9 @@ func TestMetrics(t *testing.T) { // there is no config listener for the metrics // we handle it on config save step - th.Service.UpdateConfig(func(c *model.Config) { - c.MetricsSettings.Enable = model.NewBool(true) - }) - th.Service.SaveConfig(th.Service.Config(), false) + cfg := th.Service.Config().Clone() + cfg.MetricsSettings.Enable = model.NewBool(true) + th.Service.SaveConfig(cfg, false) require.NotNil(t, th.Service.metrics) metricsAddr := strings.Replace(th.Service.metrics.listenAddr, "[::]", "http://localhost", 1) @@ -117,17 +116,14 @@ func TestMetrics(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) - th.Service.UpdateConfig(func(c *model.Config) { - c.MetricsSettings.Enable = model.NewBool(false) - }) - th.Service.SaveConfig(th.Service.Config(), false) + cfg.MetricsSettings.Enable = model.NewBool(false) + th.Service.SaveConfig(cfg, false) _, err = http.Get(metricsAddr) require.Error(t, err) }) t.Run("ensure the metrics server is started with advanced metrics", func(t *testing.T) { - t.Skip("MM-47635") th := Setup(t, StartMetrics()) defer th.TearDown() diff --git a/app/platform/status.go b/app/platform/status.go index b133d3e2f7..70074d5d0f 100644 --- a/app/platform/status.go +++ b/app/platform/status.go @@ -212,3 +212,198 @@ func (ps *PlatformService) GetStatus(userID string) (*model.Status, *model.AppEr return status, nil } + +// SetStatusLastActivityAt sets the last activity at for a user on the local app server and updates +// status to away if needed. Used by the WS to set status to away if an 'online' device disconnects +// while an 'away' device is still connected +func (ps *PlatformService) SetStatusLastActivityAt(userID string, activityAt int64) { + var status *model.Status + var err *model.AppError + if status, err = ps.GetStatus(userID); err != nil { + return + } + + status.LastActivityAt = activityAt + + ps.AddStatusCacheSkipClusterSend(status) + ps.SetStatusAwayIfNeeded(userID, false) +} + +func (ps *PlatformService) UpdateLastActivityAtIfNeeded(session model.Session) { + now := model.GetMillis() + + ps.UpdateWebConnUserActivity(session, now) + + if now-session.LastActivityAt < model.SessionActivityTimeout { + return + } + + if err := ps.Store.Session().UpdateLastActivityAt(session.Id, now); err != nil { + mlog.Warn("Failed to update LastActivityAt", mlog.String("user_id", session.UserId), mlog.String("session_id", session.Id), mlog.Err(err)) + } + + session.LastActivityAt = now + ps.AddSessionToCache(&session) +} + +func (ps *PlatformService) SetStatusOnline(userID string, manual bool) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + broadcast := false + + var oldStatus string = model.StatusOffline + var oldTime int64 + var oldManual bool + var status *model.Status + var err *model.AppError + + if status, err = ps.GetStatus(userID); err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""} + broadcast = true + } else { + if status.Manual && !manual { + return // manually set status always overrides non-manual one + } + + if status.Status != model.StatusOnline { + broadcast = true + } + + oldStatus = status.Status + oldTime = status.LastActivityAt + oldManual = status.Manual + + status.Status = model.StatusOnline + status.Manual = false // for "online" there's no manual setting + status.LastActivityAt = model.GetMillis() + } + + ps.AddStatusCache(status) + + // Only update the database if the status has changed, the status has been manually set, + // or enough time has passed since the previous action + if status.Status != oldStatus || status.Manual != oldManual || status.LastActivityAt-oldTime > model.StatusMinUpdateTime { + if broadcast { + if err := ps.Store.Status().SaveOrUpdate(status); err != nil { + mlog.Warn("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID)) + } + } else { + if err := ps.Store.Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt); err != nil { + mlog.Error("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID)) + } + } + } + + if broadcast { + ps.BroadcastStatus(status) + } +} + +func (ps *PlatformService) SetStatusOffline(userID string, manual bool) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userID) + if err == nil && status.Manual && !manual { + return // manually set status always overrides non-manual one + } + + status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: model.GetMillis(), ActiveChannel: ""} + + ps.SaveAndBroadcastStatus(status) +} + +func (ps *PlatformService) SetStatusAwayIfNeeded(userID string, manual bool) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userID) + + if err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: 0, ActiveChannel: ""} + } + + if !manual && status.Manual { + return // manually set status always overrides non-manual one + } + + if !manual { + if status.Status == model.StatusAway { + return + } + + if !ps.isUserAway(status.LastActivityAt) { + return + } + } + + status.Status = model.StatusAway + status.Manual = manual + status.ActiveChannel = "" + + ps.SaveAndBroadcastStatus(status) +} + +// SetStatusDoNotDisturbTimed takes endtime in unix epoch format in UTC +// and sets status of given userId to dnd which will be restored back after endtime +func (ps *PlatformService) SetStatusDoNotDisturbTimed(userId string, endtime int64) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userId) + + if err != nil { + status = &model.Status{UserId: userId, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + status.PrevStatus = status.Status + status.Status = model.StatusDnd + status.Manual = true + + status.DNDEndTime = endtime + + ps.SaveAndBroadcastStatus(status) +} + +func (ps *PlatformService) SetStatusDoNotDisturb(userID string) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userID) + + if err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + status.Status = model.StatusDnd + status.Manual = true + + ps.SaveAndBroadcastStatus(status) +} + +func (ps *PlatformService) SetStatusOutOfOffice(userID string) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userID) + + if err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOutOfOffice, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + status.Status = model.StatusOutOfOffice + status.Manual = true + + ps.SaveAndBroadcastStatus(status) +} + +func (ps *PlatformService) isUserAway(lastActivityAt int64) bool { + return model.GetMillis()-lastActivityAt >= *ps.Config().TeamSettings.UserStatusAwayTimeout*1000 +} diff --git a/app/platform/web_conn.go b/app/platform/web_conn.go index 4e4fc75062..312221da50 100644 --- a/app/platform/web_conn.go +++ b/app/platform/web_conn.go @@ -72,15 +72,15 @@ type WebConnConfig struct { // It contains all the necessary state to manage sending/receiving data to/from // a websocket. type WebConn struct { - sessionExpiresAt int64 // This should stay at the top for 64-bit alignment of 64-bit words accessed atomically - Platform *PlatformService - Suite SuiteIFace - PluginsEnvironment func() *plugin.Environment - WebSocket *websocket.Conn - T i18n.TranslateFunc - Locale string - Sequence int64 - UserId string + sessionExpiresAt int64 // This should stay at the top for 64-bit alignment of 64-bit words accessed atomically + Platform *PlatformService + Suite SuiteIFace + HookRunner HookRunner + WebSocket *websocket.Conn + T i18n.TranslateFunc + Locale string + Sequence int64 + UserId string allChannelMembers map[string]string lastAllChannelMembersTime int64 @@ -162,11 +162,11 @@ func (ps *PlatformService) PopulateWebConnConfig(s *model.Session, cfg *WebConnC } // NewWebConn returns a new WebConn instance. -func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, envFn func() *plugin.Environment) *WebConn { +func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, runner HookRunner) *WebConn { if cfg.Session.UserId != "" { ps.Go(func() { - suite.SetStatusOnline(cfg.Session.UserId, false) - suite.UpdateLastActivityAtIfNeeded(cfg.Session) + ps.SetStatusOnline(cfg.Session.UserId, false) + ps.UpdateLastActivityAtIfNeeded(cfg.Session) }) } @@ -200,7 +200,7 @@ func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, envF wc := &WebConn{ Platform: ps, Suite: suite, - PluginsEnvironment: envFn, + HookRunner: runner, send: cfg.activeQueue, deadQueue: cfg.deadQueue, deadQueuePointer: cfg.deadQueuePointer, @@ -222,14 +222,12 @@ func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, envF wc.SetSessionExpiresAt(cfg.Session.ExpiresAt) wc.SetConnectionID(cfg.ConnectionID) - if pluginsEnvironment := wc.PluginsEnvironment(); pluginsEnvironment != nil { - wc.Platform.Go(func() { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.OnWebSocketConnect(wc.GetConnectionID(), wc.UserId) - return true - }, plugin.OnWebSocketConnectID) - }) - } + wc.Platform.Go(func() { + wc.HookRunner.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.OnWebSocketConnect(wc.GetConnectionID(), wc.UserId) + return true + }, plugin.OnWebSocketConnectID) + }) return wc } @@ -238,12 +236,10 @@ func (wc *WebConn) pluginPostedConsumer(wg *sync.WaitGroup) { defer wg.Done() for msg := range wc.pluginPosted { - if pluginsEnvironment := wc.PluginsEnvironment(); pluginsEnvironment != nil { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.WebSocketMessageHasBeenPosted(msg.connectionID, msg.userID, msg.req) - return true - }, plugin.WebSocketMessageHasBeenPostedID) - } + wc.HookRunner.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.WebSocketMessageHasBeenPosted(msg.connectionID, msg.userID, msg.req) + return true + }, plugin.WebSocketMessageHasBeenPostedID) } } @@ -328,14 +324,12 @@ func (wc *WebConn) Pump() { wc.Platform.HubUnregister(wc) close(wc.pumpFinished) - if pluginsEnvironment := wc.PluginsEnvironment(); pluginsEnvironment != nil { - wc.Platform.Go(func() { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.OnWebSocketDisconnect(wc.GetConnectionID(), wc.UserId) - return true - }, plugin.OnWebSocketDisconnectID) - }) - } + wc.Platform.Go(func() { + wc.HookRunner.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.OnWebSocketDisconnect(wc.GetConnectionID(), wc.UserId) + return true + }, plugin.OnWebSocketDisconnectID) + }) } func (wc *WebConn) readPump() { @@ -350,7 +344,7 @@ func (wc *WebConn) readPump() { } if wc.IsAuthenticated() { wc.Platform.Go(func() { - wc.Suite.SetStatusAwayIfNeeded(wc.UserId, false) + wc.Platform.SetStatusAwayIfNeeded(wc.UserId, false) }) } return nil @@ -753,15 +747,15 @@ func (wc *WebConn) ShouldSendEvent(msg *model.WebSocketEvent) bool { return wc.GetConnectionID() == msg.GetBroadcast().ConnectionId } + if wc.GetConnectionID() == msg.GetBroadcast().OmitConnectionId { + return false + } + // If the event is destined to a specific user if msg.GetBroadcast().UserId != "" { return wc.UserId == msg.GetBroadcast().UserId } - if wc.GetConnectionID() == msg.GetBroadcast().OmitConnectionId { - return false - } - // if the user is omitted don't send the message if len(msg.GetBroadcast().OmitUsers) > 0 { if _, ok := msg.GetBroadcast().OmitUsers[wc.UserId]; ok { diff --git a/app/platform/web_conn_test.go b/app/platform/web_conn_test.go index b12232bd61..149f7d019d 100644 --- a/app/platform/web_conn_test.go +++ b/app/platform/web_conn_test.go @@ -5,6 +5,7 @@ package platform import ( "bytes" + "errors" "net" "net/http" "net/http/httptest" @@ -18,13 +19,27 @@ import ( "github.com/mattermost/mattermost-server/v6/plugin" ) +type hookRunner struct { +} + +func (h *hookRunner) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) { + +} +func (h *hookRunner) HooksForPlugin(id string) (plugin.Hooks, error) { + return nil, errors.New("not implemented") +} + +func (h *hookRunner) GetPluginsEnvironment() *plugin.Environment { + return nil +} + func TestWebConnAddDeadQueue(t *testing.T) { th := Setup(t) defer th.TearDown() wc := th.Service.NewWebConn(&WebConnConfig{ WebSocket: &websocket.Conn{}, - }, th.Suite, func() *plugin.Environment { return nil }) + }, th.Suite, &hookRunner{}) for i := 0; i < 2; i++ { msg := &model.WebSocketEvent{} @@ -53,7 +68,7 @@ func TestWebConnIsInDeadQueue(t *testing.T) { wc := th.Service.NewWebConn(&WebConnConfig{ WebSocket: &websocket.Conn{}, - }, th.Suite, func() *plugin.Environment { return nil }) + }, th.Suite, &hookRunner{}) var i int for ; i < 2; i++ { @@ -114,7 +129,7 @@ func TestWebConnClearDeadQueue(t *testing.T) { wc := th.Service.NewWebConn(&WebConnConfig{ WebSocket: &websocket.Conn{}, - }, th.Suite, func() *plugin.Environment { return nil }) + }, th.Suite, &hookRunner{}) var i int for ; i < 2; i++ { @@ -140,7 +155,7 @@ func TestWebConnDrainDeadQueue(t *testing.T) { cfg := &WebConnConfig{ WebSocket: c, } - return th.Service.NewWebConn(cfg, th.Suite, func() *plugin.Environment { return nil }) + return th.Service.NewWebConn(cfg, th.Suite, &hookRunner{}) } t.Run("Empty Queue", func(t *testing.T) { diff --git a/app/platform/web_hub.go b/app/platform/web_hub.go index dc83d574ce..9e62da4264 100644 --- a/app/platform/web_hub.go +++ b/app/platform/web_hub.go @@ -21,12 +21,6 @@ const ( ) type SuiteIFace interface { - SetStatusLastActivityAt(userID string, activityAt int64) - SetStatusOffline(userID string, manual bool) - IsUserAway(lastActivityAt int64) bool - SetStatusOnline(userID string, manual bool) - UpdateLastActivityAtIfNeeded(session model.Session) - SetStatusAwayIfNeeded(userID string, manual bool) GetSession(token string) (*model.Session, *model.AppError) RolesGrantPermission(roleNames []string, permissionId string) bool UserCanSeeOtherUser(userID string, otherUserId string) (bool, *model.AppError) @@ -95,7 +89,7 @@ func newWebHub(ps *PlatformService) *Hub { } // hubStart starts all the hubs. -func (ps *PlatformService) hubStart(suite SuiteIFace) { +func (ps *PlatformService) hubStart() { // Total number of hubs is twice the number of CPUs. numberOfHubs := runtime.NumCPU() * 2 ps.logger.Info("Starting websocket hubs", mlog.Int("number_of_hubs", numberOfHubs)) @@ -105,7 +99,7 @@ func (ps *PlatformService) hubStart(suite SuiteIFace) { for i := 0; i < numberOfHubs; i++ { hubs[i] = newWebHub(ps) hubs[i].connectionIndex = i - hubs[i].Start(suite) + hubs[i].Start() } // Assigning to the hubs slice without any mutex is fine because it is only assigned once // during the start of the program and always read from after that. @@ -366,7 +360,7 @@ func (h *Hub) Stop() { } // Start starts the hub. -func (h *Hub) Start(suite SuiteIFace) { +func (h *Hub) Start() { var doStart func() var doRecoverableStart func() var doRecover func() @@ -439,7 +433,7 @@ func (h *Hub) Start(suite SuiteIFace) { conns := connIndex.ForUser(webConn.UserId) if len(conns) == 0 || areAllInactive(conns) { h.platform.Go(func() { - suite.SetStatusOffline(webConn.UserId, false) + h.platform.SetStatusOffline(webConn.UserId, false) }) continue } @@ -453,9 +447,9 @@ func (h *Hub) Start(suite SuiteIFace) { } } - if suite.IsUserAway(latestActivity) { + if h.platform.isUserAway(latestActivity) { h.platform.Go(func() { - suite.SetStatusLastActivityAt(webConn.UserId, latestActivity) + h.platform.SetStatusLastActivityAt(webConn.UserId, latestActivity) }) } case userID := <-h.invalidateUser: @@ -522,7 +516,7 @@ func (h *Hub) Start(suite SuiteIFace) { case <-h.stop: for webConn := range connIndex.All() { webConn.Close() - suite.SetStatusOffline(webConn.UserId, false) + h.platform.SetStatusOffline(webConn.UserId, false) } h.explicitStop = true diff --git a/app/platform/web_hub_test.go b/app/platform/web_hub_test.go index f73836e1ad..5092e6cb0f 100644 --- a/app/platform/web_hub_test.go +++ b/app/platform/web_hub_test.go @@ -17,7 +17,6 @@ import ( platform_mocks "github.com/mattermost/mattermost-server/v6/app/platform/mocks" "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/shared/i18n" "github.com/mattermost/mattermost-server/v6/store/storetest/mocks" "github.com/mattermost/mattermost-server/v6/testlib" @@ -50,7 +49,7 @@ func registerDummyWebConn(t *testing.T, th *TestHelper, addr net.Addr, session * TFunc: i18n.IdentityTfunc(), Locale: "en", } - wc := th.Service.NewWebConn(cfg, th.Suite, func() *plugin.Environment { return nil }) + wc := th.Service.NewWebConn(cfg, th.Suite, &hookRunner{}) th.Service.HubRegister(wc) go wc.Pump() return wc @@ -68,7 +67,7 @@ func TestHubStopWithMultipleConnections(t *testing.T) { }) require.NoError(t, err) - th.Service.Start(th.Suite) + th.Service.Start() wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session) wc2 := registerDummyWebConn(t, th, s.Listener.Addr(), session) wc3 := registerDummyWebConn(t, th, s.Listener.Addr(), session) @@ -91,7 +90,7 @@ func TestHubStopRaceCondition(t *testing.T) { }) require.NoError(t, err) - th.Service.Start(th.Suite) + th.Service.Start() wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session) defer wc1.Close() @@ -468,18 +467,13 @@ func TestHubIsRegistered(t *testing.T) { require.NoError(t, err) mockSuite := &platform_mocks.SuiteIFace{} - mockSuite.On("SetStatusOnline", th.BasicUser.Id, false).Return() - mockSuite.On("UpdateLastActivityAtIfNeeded", *session).Return() mockSuite.On("GetSession", session.Token).Return(session, nil) - mockSuite.On("IsUserAway", mock.Anything).Return(false) - mockSuite.On("SetStatusOffline", th.BasicUser.Id, false).Return() - th.Suite = mockSuite s := httptest.NewServer(dummyWebsocketHandler(t)) defer s.Close() - th.Service.Start(th.Suite) + th.Service.Start() wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session) wc2 := registerDummyWebConn(t, th, s.Listener.Addr(), session) wc3 := registerDummyWebConn(t, th, s.Listener.Addr(), session) @@ -487,9 +481,7 @@ func TestHubIsRegistered(t *testing.T) { defer wc2.Close() defer wc3.Close() - session1 := wc1.session.Load().(*model.Session) - - assert.True(t, th.Service.SessionIsRegistered(*session1)) + assert.True(t, th.Service.SessionIsRegistered(*wc1.session.Load().(*model.Session))) assert.True(t, th.Service.SessionIsRegistered(*wc2.session.Load().(*model.Session))) assert.True(t, th.Service.SessionIsRegistered(*wc3.session.Load().(*model.Session))) @@ -552,7 +544,7 @@ func BenchmarkGetHubForUserId(b *testing.B) { th := Setup(b).InitBasic() defer th.TearDown() - th.Service.Start(th.Suite) + th.Service.Start() b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/app/platform/websocket_router.go b/app/platform/websocket_router.go index 2b465dffb6..862f220dfb 100644 --- a/app/platform/websocket_router.go +++ b/app/platform/websocket_router.go @@ -59,8 +59,8 @@ func (wr *WebSocketRouter) ServeWebSocket(conn *WebConn, r *model.WebSocketReque conn.Platform.HubRegister(conn) conn.Platform.Go(func() { - conn.Suite.SetStatusOnline(session.UserId, false) - conn.Suite.UpdateLastActivityAtIfNeeded(*session) + conn.Platform.SetStatusOnline(session.UserId, false) + conn.Platform.UpdateLastActivityAtIfNeeded(*session) }) resp := model.NewWebSocketResponse(model.StatusOk, r.Seq, nil) diff --git a/app/plugin.go b/app/plugin.go index b9042330bf..679576e723 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -93,7 +93,7 @@ func (ch *Channels) SetPluginsEnvironment(pluginsEnvironment *plugin.Environment defer ch.pluginsLock.Unlock() ch.pluginsEnvironment = pluginsEnvironment - ch.srv.Platform().SetPluginsEnvironment(pluginsEnvironment) + ch.srv.Platform().SetPluginsEnvironment(ch) } func (ch *Channels) syncPluginsActiveState() { @@ -200,10 +200,6 @@ func (ch *Channels) syncPluginsActiveState() { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } - - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } } func (a *App) NewPluginAPI(c *request.Context, manifest *model.Manifest) plugin.API { @@ -217,7 +213,7 @@ func (a *App) InitPlugins(c *request.Context, pluginDir, webappPluginDir string) func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir string) { // Acquiring lock manually, as plugins might be disabled. See GetPluginsEnvironment. defer func() { - ch.srv.Platform().SetPluginsEnvironment(ch.pluginsEnvironment) + ch.srv.Platform().SetPluginsEnvironment(ch) }() ch.pluginsLock.RLock() @@ -247,7 +243,15 @@ func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir s return New(ServerConnector(ch)).NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newAPIFunc, NewDriverImpl(ch.srv), pluginDir, webappPluginDir, ch.srv.Log(), ch.srv.GetMetrics()) + env, err := plugin.NewEnvironment( + newAPIFunc, + NewDriverImpl(ch.srv), + pluginDir, + webappPluginDir, + *ch.cfgSvc.Config().ExperimentalSettings.PatchPluginsReactDOM, + ch.srv.Log(), + ch.srv.GetMetrics(), + ) if err != nil { mlog.Error("Failed to start up plugins", mlog.Err(err)) return @@ -282,14 +286,13 @@ func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir s ch.installFeatureFlagPlugins() ch.syncPluginsActiveState() } - if pluginsEnvironment := ch.GetPluginsEnvironment(); pluginsEnvironment != nil { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - if err := hooks.OnConfigurationChange(); err != nil { - ch.srv.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) - } - return true - }, plugin.OnConfigurationChangeID) - } + + ch.RunMultiHook(func(hooks plugin.Hooks) bool { + if err := hooks.OnConfigurationChange(); err != nil { + ch.srv.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) + } + return true + }, plugin.OnConfigurationChangeID) }) ch.pluginsLock.Unlock() @@ -422,11 +425,6 @@ func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { // activation if inactive anywhere in the cluster. // Notifies cluster peers through config change. func (a *App) EnablePlugin(id string) *model.AppError { - appErr := a.checkIfIntegrationsMeetFreemiumLimits([]string{id}) - if appErr != nil { - return appErr - } - return a.ch.enablePlugin(id) } @@ -537,20 +535,6 @@ func (ch *Channels) disablePlugin(id string) *model.AppError { return nil } -func (ch *Channels) notifyIntegrationsUsageChanged() *model.AppError { - usage, appErr := ch.getIntegrationsUsage() - if appErr != nil { - return appErr - } - - message := model.NewWebSocketEvent(model.WebsocketEventIntegrationsUsageChanged, "", "", "", nil, "") - message.Add("usage", usage) - message.GetBroadcast().ContainsSensitiveData = true - ch.Publish(message) - - return nil -} - func (a *App) GetPlugins() (*model.PluginsResponse, *model.AppError) { pluginsEnvironment := a.GetPluginsEnvironment() if pluginsEnvironment == nil { diff --git a/app/plugin_api.go b/app/plugin_api.go index e302e6a860..accedf37ba 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -1237,3 +1237,27 @@ func (api *PluginAPI) GetCloudLimits() (*model.ProductLimits, error) { func (api *PluginAPI) RegisterCollectionAndTopic(collectionType, topicType string) error { return api.app.registerCollectionAndTopic(api.id, collectionType, topicType) } + +func (api *PluginAPI) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { + us, err := api.app.CreateUploadSession(api.ctx, us) + if err != nil { + return nil, err + } + return us, nil +} + +func (api *PluginAPI) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) { + fi, err := api.app.UploadData(api.ctx, us, rd) + if err != nil { + return nil, err + } + return fi, nil +} + +func (api *PluginAPI) GetUploadSession(uploadID string) (*model.UploadSession, error) { + fi, err := api.app.GetUploadSession(uploadID) + if err != nil { + return nil, err + } + return fi, nil +} diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index 232eb9f9a4..759613781b 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -92,7 +92,7 @@ func setupMultiPluginAPITest(t *testing.T, pluginCodes []string, pluginManifests return app.NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, false, app.Log(), nil) require.NoError(t, err) require.Equal(t, len(pluginCodes), len(pluginIDs)) @@ -849,7 +849,7 @@ func TestPluginAPIGetPlugins(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, false, th.App.Log(), nil) require.NoError(t, err) pluginIDs := []string{"pluginid1", "pluginid2", "pluginid3"} @@ -937,7 +937,7 @@ func TestInstallPlugin(t *testing.T) { return app.NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, false, app.Log(), nil) require.NoError(t, err) app.ch.SetPluginsEnvironment(env) @@ -1632,7 +1632,7 @@ func TestAPIMetrics(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, false, th.App.Log(), metricsMock) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) @@ -2079,7 +2079,7 @@ func TestRegisterCollectionAndTopic(t *testing.T) { return th.App.NewPluginAPI(th.Context, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, false, th.App.Log(), nil) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) @@ -2100,3 +2100,98 @@ func TestRegisterCollectionAndTopic(t *testing.T) { err = api.RegisterCollectionAndTopic("some other collection", "topicToBeRepeated") assert.Error(t, err) } + +func TestPluginUploadsAPI(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + pluginCode := fmt.Sprintf(` + package main + + import ( + "fmt" + "bytes" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/plugin" + ) + + type TestPlugin struct { + plugin.MattermostPlugin + } + + func (p *TestPlugin) OnActivate() error { + data := []byte("some content to upload") + us, err := p.API.CreateUploadSession(&model.UploadSession{ + Id: "%s", + UserId: "%s", + ChannelId: "%s", + Type: model.UploadTypeAttachment, + FileSize: int64(len(data)), + Filename: "upload.test", + }) + if err != nil { + return fmt.Errorf("failed to create upload session: %%w", err) + } + + us2, err := p.API.GetUploadSession(us.Id) + if err != nil { + return fmt.Errorf("failed to get upload session: %%w", err) + } + + if us.Id != us2.Id { + return fmt.Errorf("upload sessions should match") + } + + fi, err := p.API.UploadData(us, bytes.NewBuffer(data)) + if err != nil { + return fmt.Errorf("failed to upload data: %%w", err) + } + + if fi == nil || fi.Id == "" { + return fmt.Errorf("fileinfo should be set") + } + + fileData, appErr := p.API.GetFile(fi.Id) + if appErr != nil { + return fmt.Errorf("failed to get file data: %%w", err) + } + + if !bytes.Equal(data, fileData) { + return fmt.Errorf("file data should match") + } + + return nil + } + + func main() { + plugin.ClientMain(&TestPlugin{}) + } + `, model.NewId(), th.BasicUser.Id, th.BasicChannel.Id) + + pluginDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + webappPluginDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + defer os.RemoveAll(pluginDir) + defer os.RemoveAll(webappPluginDir) + + newPluginAPI := func(manifest *model.Manifest) plugin.API { + return th.App.NewPluginAPI(th.Context, manifest) + } + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, false, th.App.Log(), nil) + require.NoError(t, err) + + th.App.ch.SetPluginsEnvironment(env) + + pluginID := "testplugin" + pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` + backend := filepath.Join(pluginDir, pluginID, "backend.exe") + utils.CompileGo(t, pluginCode, backend) + + os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifest), 0600) + manifest, activated, reterr := env.Activate(pluginID) + require.NoError(t, reterr) + require.NotNil(t, manifest) + require.True(t, activated) +} diff --git a/app/plugin_hooks_test.go b/app/plugin_hooks_test.go index 1b1673be93..161994a1ab 100644 --- a/app/plugin_hooks_test.go +++ b/app/plugin_hooks_test.go @@ -33,7 +33,7 @@ func SetAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, a webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) - env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, false, app.Log(), nil) require.NoError(t, err) app.ch.SetPluginsEnvironment(env) @@ -1030,7 +1030,7 @@ func TestHookMetrics(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, false, th.App.Log(), metricsMock) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) @@ -1234,7 +1234,7 @@ func TestHookRunDataRetention(t *testing.T) { require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) hookCalled := false - th.App.GetPluginsEnvironment().RunMultiPluginHook(func(hooks plugin.Hooks) bool { + th.App.Channels().RunMultiHook(func(hooks plugin.Hooks) bool { n, _ := hooks.RunDataRetention(0, 0) // Ensure return it correct assert.Equal(t, int64(100), n) @@ -1278,7 +1278,7 @@ func TestHookOnSendDailyTelemetry(t *testing.T) { require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) hookCalled := false - th.App.GetPluginsEnvironment().RunMultiPluginHook(func(hooks plugin.Hooks) bool { + th.App.Channels().RunMultiHook(func(hooks plugin.Hooks) bool { hooks.OnSendDailyTelemetry() hookCalled = true @@ -1322,7 +1322,7 @@ func TestHookOnCloudLimitsUpdated(t *testing.T) { require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) hookCalled := false - th.App.GetPluginsEnvironment().RunMultiPluginHook(func(hooks plugin.Hooks) bool { + th.App.Channels().RunMultiHook(func(hooks plugin.Hooks) bool { hooks.OnCloudLimitsUpdated(nil) hookCalled = true diff --git a/app/plugin_install.go b/app/plugin_install.go index fa076df3c4..431c3abebe 100644 --- a/app/plugin_install.go +++ b/app/plugin_install.go @@ -102,10 +102,6 @@ func (ch *Channels) installPluginFromData(data model.PluginEventData) { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Error("Failed to notify plugin status changed", mlog.Err(err)) } - - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } } func (ch *Channels) removePluginFromData(data model.PluginEventData) { @@ -118,10 +114,6 @@ func (ch *Channels) removePluginFromData(data model.PluginEventData) { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } - - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } } // InstallPluginWithSignature verifies and installs plugin. @@ -177,10 +169,6 @@ func (ch *Channels) installPlugin(pluginFile, signature io.ReadSeeker, installat mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } - return manifest, nil } @@ -455,10 +443,6 @@ func (ch *Channels) RemovePlugin(id string) *model.AppError { mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } - return nil } diff --git a/app/plugin_test.go b/app/plugin_test.go index fcc719c891..57802c67ac 100644 --- a/app/plugin_test.go +++ b/app/plugin_test.go @@ -1057,17 +1057,6 @@ func TestEnablePluginWithCloudLimits(t *testing.T) { appErr = th.App.EnablePlugin("testplugin") checkNoError(t, appErr) - appErr = th.App.EnablePlugin("testplugin2") - checkError(t, appErr) - require.Equal(t, "app.install_integration.reached_max_limit.error", appErr.Id) - - th.App.Srv().RemoveLicense() - appErr = th.App.EnablePlugin("testplugin2") - checkNoError(t, appErr) - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - appErr = th.App.EnablePlugin("testplugin2") - checkError(t, appErr) - // Let enable succeed if a CWS error occurs cloud = &mocks.CloudInterface{} th.App.Srv().Cloud = cloud diff --git a/app/post.go b/app/post.go index e2d12384f5..14a9dbe47f 100644 --- a/app/post.go +++ b/app/post.go @@ -267,30 +267,40 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel } } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var rejectionError *model.AppError - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - replacementPost, rejectionReason := hooks.MessageWillBePosted(pluginContext, post.ForPlugin()) - if rejectionReason != "" { - id := "Post rejected by plugin. " + rejectionReason - if rejectionReason == plugin.DismissPostError { - id = plugin.DismissPostError - } - rejectionError = model.NewAppError("createPost", id, nil, "", http.StatusBadRequest) - return false - } - if replacementPost != nil { - // the original post's metadata (if there ever was any) is lost, and will be rebuilt. - post = replacementPost - } + if !a.isPostPriorityEnabled() && post.GetPriority() != nil { + post.Metadata.Priority = nil + } - return true - }, plugin.MessageWillBePostedID) - - if rejectionError != nil { - return nil, rejectionError + var metadata *model.PostMetadata + if post.Metadata != nil { + metadata = post.Metadata.Copy() + } + var rejectionError *model.AppError + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + replacementPost, rejectionReason := hooks.MessageWillBePosted(pluginContext, post.ForPlugin()) + if rejectionReason != "" { + id := "Post rejected by plugin. " + rejectionReason + if rejectionReason == plugin.DismissPostError { + id = plugin.DismissPostError + } + rejectionError = model.NewAppError("createPost", id, nil, "", http.StatusBadRequest) + return false } + if replacementPost != nil { + post = replacementPost + if post.Metadata != nil && metadata != nil { + post.Metadata.Priority = metadata.Priority + } else { + post.Metadata = metadata + } + } + + return true + }, plugin.MessageWillBePostedID) + + if rejectionError != nil { + return nil, rejectionError } // Pre-fill the CreateAt field for link previews to get the correct timestamp. @@ -324,16 +334,13 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel // We make a copy of the post for the plugin hook to avoid a race condition, // and to remove the non-GOB-encodable Metadata from it. - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - pluginPost := rpost.ForPlugin() - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.MessageHasBeenPosted(pluginContext, pluginPost) - return true - }, plugin.MessageHasBeenPostedID) - }) - } + pluginPost := rpost.ForPlugin() + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.MessageHasBeenPosted(pluginContext, pluginPost) + return true + }, plugin.MessageHasBeenPostedID) + }) if a.Metrics() != nil { a.Metrics().IncrementPostCreate() @@ -351,7 +358,9 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel // Normally, we would let the API layer call PreparePostForClient, but we do it here since it also needs // to be done when we send the post over the websocket in handlePostEvents - rpost = a.PreparePostForClient(c, rpost, true, false) + // PS: we don't want to include PostPriority from the db to avoid the replica lag, + // so we just return the one that was passed with post + rpost = a.PreparePostForClient(c, rpost, true, false, false) // Make sure poster is following the thread if *a.Config().ServiceSettings.ThreadAutoFollow && rpost.RootId != "" { @@ -523,7 +532,7 @@ func (a *App) SendEphemeralPost(c request.CTX, userID string, post *model.Post) post.GenerateActionIds() message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", post.ChannelId, userID, nil, "") - post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false) + post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false, true) post = model.AddPostActionCookies(post, a.PostActionCookieSecret()) postJSON, jsonErr := post.ToJSON() @@ -546,7 +555,7 @@ func (a *App) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post post.GenerateActionIds() message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, userID, nil, "") - post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false) + post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false, true) post = model.AddPostActionCookies(post, a.PostActionCookieSecret()) postJSON, jsonErr := post.ToJSON() if jsonErr != nil { @@ -652,20 +661,18 @@ func (a *App) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) oldPost.RemoteId = model.NewString(*post.RemoteId) } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var rejectionReason string - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - newPost, rejectionReason = hooks.MessageWillBeUpdated(pluginContext, newPost.ForPlugin(), oldPost.ForPlugin()) - return post != nil - }, plugin.MessageWillBeUpdatedID) - if newPost == nil { - return nil, model.NewAppError("UpdatePost", "Post rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) - } - // Restore the post metadata that was stripped by the plugin. Set it to - // the last known good. - newPost.Metadata = oldPost.Metadata + var rejectionReason string + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + newPost, rejectionReason = hooks.MessageWillBeUpdated(pluginContext, newPost.ForPlugin(), oldPost.ForPlugin()) + return post != nil + }, plugin.MessageWillBeUpdatedID) + if newPost == nil { + return nil, model.NewAppError("UpdatePost", "Post rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) } + // Restore the post metadata that was stripped by the plugin. Set it to + // the last known good. + newPost.Metadata = oldPost.Metadata rpost, nErr := a.Srv().Store().Post().Update(newPost, oldPost) if nErr != nil { @@ -678,19 +685,16 @@ func (a *App) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) } } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - pluginOldPost := oldPost.ForPlugin() - pluginNewPost := newPost.ForPlugin() - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.MessageHasBeenUpdated(pluginContext, pluginNewPost, pluginOldPost) - return true - }, plugin.MessageHasBeenUpdatedID) - }) - } + pluginOldPost := oldPost.ForPlugin() + pluginNewPost := newPost.ForPlugin() + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.MessageHasBeenUpdated(pluginContext, pluginNewPost, pluginOldPost) + return true + }, plugin.MessageHasBeenUpdatedID) + }) - rpost = a.PreparePostForClientWithEmbedsAndImages(c, rpost, false, true) + rpost = a.PreparePostForClientWithEmbedsAndImages(c, rpost, false, true, true) // Ensure IsFollowing is nil since this updated post will be broadcast to all users // and we don't want to have to populate it for every single user and broadcast to each @@ -1449,6 +1453,24 @@ func (a *App) ComputeLastAccessiblePostTime() error { } if limit == 0 { + // All posts are accessible - we must check if a previous value was set so we can clear it + systemValue, err := a.Srv().Store().System().GetByName(model.SystemLastAccessiblePostTime) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + // There was no previous value, nothing to do + return nil + default: + return model.NewAppError("ComputeLastAccessiblePostTime", "app.system.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + if systemValue != nil { + // Previous value was set, so we must clear it + if _, err = a.Srv().Store().System().PermanentDeleteByName(model.SystemLastAccessiblePostTime); err != nil { + return model.NewAppError("ComputeLastAccessiblePostTime", "app.system.permanent_delete_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } // Cloud limit is not applicable return nil } @@ -1695,7 +1717,7 @@ func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.P posts, nErr := a.Srv().Store().Post().GetPostsByThread(post.Id, timestamp) if nErr != nil { - return 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return 0, model.NewAppError("countThreadMentions", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } count := 0 @@ -1722,7 +1744,7 @@ func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.P groups, nErr := a.getGroupsAllowedForReferenceInChannel(channel, team) if nErr != nil { - return 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return 0, model.NewAppError("countThreadMentions", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } for _, p := range posts { @@ -1739,25 +1761,33 @@ func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.P // countMentionsFromPost returns the number of posts in the post's channel that mention the user after and including the // given post. -func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model.Post) (int, int, *model.AppError) { +func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model.Post) (int, int, int, *model.AppError) { channel, err := a.GetChannel(c, post.ChannelId) if err != nil { - return 0, 0, err + return 0, 0, 0, err } if channel.Type == model.ChannelTypeDirect { // In a DM channel, every post made by the other user is a mention count, countRoot, nErr := a.Srv().Store().Channel().CountPostsAfter(post.ChannelId, post.CreateAt-1, channel.GetOtherUserIdForDM(user.Id)) if nErr != nil { - return 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } - return count, countRoot, nil + var urgentCount int + if a.isPostPriorityEnabled() { + urgentCount, nErr = a.Srv().Store().Channel().CountUrgentPostsAfter(post.ChannelId, post.CreateAt-1, channel.GetOtherUserIdForDM(user.Id)) + if nErr != nil { + return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.count_urgent_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + + return count, countRoot, urgentCount, nil } channelMember, err := a.GetChannelMember(c, channel.Id, user.Id) if err != nil { - return 0, 0, err + return 0, 0, 0, err } keywords := addMentionKeywordsForUser( @@ -1775,15 +1805,25 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model thread, err := a.GetPostThread(post.Id, model.GetPostsOptions{}, user.Id) if err != nil { - return 0, 0, err + return 0, 0, 0, err } count := 0 countRoot := 0 + urgentCount := 0 if isPostMention(user, post, keywords, thread.Posts, mentionedByThread, checkForCommentMentions) { count += 1 if post.RootId == "" { countRoot += 1 + if a.isPostPriorityEnabled() { + priority, err := a.GetPriorityForPost(post.Id) + if err != nil { + return 0, 0, 0, err + } + if priority != nil && *priority.Priority == model.PostPriorityUrgent { + urgentCount += 1 + } + } } } @@ -1797,18 +1837,32 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model PerPage: perPage, }) if err != nil { - return 0, 0, err + return 0, 0, 0, err } + mentionPostIds := make([]string, 0) for _, postID := range postList.Order { if isPostMention(user, postList.Posts[postID], keywords, postList.Posts, mentionedByThread, checkForCommentMentions) { count += 1 if postList.Posts[postID].RootId == "" { + mentionPostIds = append(mentionPostIds, postID) countRoot += 1 } } } + if a.isPostPriorityEnabled() { + priorityList, nErr := a.Srv().Store().PostPriority().GetForPosts(mentionPostIds) + if nErr != nil { + return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.get_priority_for_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + for _, priority := range priorityList { + if *priority.Priority == model.PostPriorityUrgent { + urgentCount += 1 + } + } + } + if len(postList.Order) < perPage { break } @@ -1816,7 +1870,7 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model page += 1 } - return count, countRoot, nil + return count, countRoot, urgentCount, nil } func isCommentMention(user *model.User, post *model.Post, otherPosts map[string]*model.Post, mentionedByThread map[string]bool) bool { @@ -2015,7 +2069,7 @@ func (a *App) SetPostReminder(postID, userID string, targetTime int64) *model.Ap } message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", ephemeralPost.ChannelId, userID, nil, "") - ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(request.EmptyContext(a.Log()), ephemeralPost, true, false) + ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(request.EmptyContext(a.Log()), ephemeralPost, true, false, true) ephemeralPost = model.AddPostActionCookies(ephemeralPost, a.PostActionCookieSecret()) postJSON, jsonErr := ephemeralPost.ToJSON() @@ -2095,9 +2149,77 @@ func (a *App) CheckPostReminders() { } +func (a *App) GetPostInfo(c request.CTX, postID string) (*model.PostInfo, *model.AppError) { + userID := c.Session().UserId + post, appErr := a.GetSinglePost(postID, false) + if appErr != nil { + return nil, appErr + } + + channel, appErr := a.GetChannel(c, post.ChannelId) + if appErr != nil { + return nil, appErr + } + + notFoundError := model.NewAppError("GetPostInfo", "app.post.get.app_error", nil, "", http.StatusNotFound) + + var team *model.Team + hasPermissionToAccessTeam := false + if channel.TeamId != "" { + team, appErr = a.GetTeam(channel.TeamId) + if appErr != nil { + return nil, appErr + } + + if team.Type == model.TeamOpen { + hasPermissionToAccessTeam = a.HasPermissionToTeam(userID, team.Id, model.PermissionJoinPublicTeams) + } else if team.Type == model.TeamInvite { + hasPermissionToAccessTeam = a.HasPermissionToTeam(userID, team.Id, model.PermissionJoinPrivateTeams) + } + } else { + // This happens in case of DMs and GMs. + hasPermissionToAccessTeam = true + } + + if !hasPermissionToAccessTeam { + return nil, notFoundError + } + + hasPermissionToAccessChannel := false + if channel.Type == model.ChannelTypeOpen { + hasPermissionToAccessChannel = true + } else if channel.Type == model.ChannelTypePrivate { + hasPermissionToAccessChannel = a.HasPermissionToChannel(c, userID, channel.Id, model.PermissionManagePrivateChannelMembers) + } else if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup { + hasPermissionToAccessChannel = a.HasPermissionToChannel(c, userID, channel.Id, model.PermissionReadChannel) + } + + if !hasPermissionToAccessChannel { + return nil, notFoundError + } + + _, channelMemberErr := a.GetChannelMember(c, channel.Id, userID) + + info := model.PostInfo{ + ChannelId: channel.Id, + ChannelType: channel.Type, + ChannelDisplayName: channel.DisplayName, + HasJoinedChannel: channelMemberErr == nil, + } + if team != nil { + _, teamMemberErr := a.GetTeamMember(team.Id, userID) + + info.TeamId = team.Id + info.TeamType = team.Type + info.TeamDisplayName = team.DisplayName + info.HasJoinedTeam = teamMemberErr == nil + } + return &info, nil +} + func includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThreadList, userID string) (*model.TopThreadList, error) { for _, topThread := range topThreadList.Items { - topThread.Post = a.PreparePostForClientWithEmbedsAndImages(c, topThread.Post, false, false) + topThread.Post = a.PreparePostForClientWithEmbedsAndImages(c, topThread.Post, false, false, true) sanitizedPost, err := a.SanitizePostMetadataForUser(c, topThread.Post, userID) if err != nil { return nil, err @@ -2106,3 +2228,7 @@ func includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThrea } return topThreadList, nil } + +func (a *App) isPostPriorityEnabled() bool { + return a.Config().FeatureFlags.PostPriority && *a.Config().ServiceSettings.PostPriority +} diff --git a/app/post_acknowledgements.go b/app/post_acknowledgements.go new file mode 100644 index 0000000000..a91f76d02b --- /dev/null +++ b/app/post_acknowledgements.go @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +func (a *App) SaveAcknowledgementForPost(c *request.Context, postID, userID string) (*model.PostAcknowledgement, *model.AppError) { + post, err := a.GetSinglePost(postID, false) + if err != nil { + return nil, err + } + + channel, err := a.GetChannel(c, post.ChannelId) + if err != nil { + return nil, err + } + + if channel.DeleteAt > 0 { + return nil, model.NewAppError("SaveAcknowledgementForPost", "api.acknowledgement.save.archived_channel.app_error", nil, "", http.StatusForbidden) + } + + acknowledgedAt := model.GetMillis() + acknowledgement, nErr := a.Srv().Store().PostAcknowledgement().Save(postID, userID, acknowledgedAt) + + if nErr != nil { + var appErr *model.AppError + switch { + case errors.As(nErr, &appErr): + return nil, appErr + default: + return nil, model.NewAppError("SaveAcknowledgementForPost", "app.acknowledgement.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + + a.Srv().Go(func() { + a.sendAcknowledgementEvent(model.WebsocketEventAcknowledgementAdded, acknowledgement, post) + }) + + return acknowledgement, nil +} + +func (a *App) DeleteAcknowledgementForPost(c *request.Context, postID, userID string) *model.AppError { + post, err := a.GetSinglePost(postID, false) + if err != nil { + return err + } + + channel, err := a.GetChannel(c, post.ChannelId) + if err != nil { + return err + } + + if channel.DeleteAt > 0 { + return model.NewAppError("DeleteAcknowledgementForPost", "api.acknowledgement.delete.archived_channel.app_error", nil, "", http.StatusForbidden) + } + + oldAck, nErr := a.Srv().Store().PostAcknowledgement().Get(postID, userID) + + if nErr != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(nErr, &nfErr): + return model.NewAppError("GetPostAcknowledgement", "app.acknowledgement.get.app_error", nil, "", http.StatusNotFound).Wrap(nErr) + default: + return model.NewAppError("GetPostAcknowledgement", "app.acknowledgement.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + + if model.GetMillis()-oldAck.AcknowledgedAt > 5*60*1000 { + return model.NewAppError("DeleteAcknowledgementForPost", "api.acknowledgement.delete.deadline.app_error", nil, "", http.StatusForbidden) + } + + nErr = a.Srv().Store().PostAcknowledgement().Delete(oldAck) + if nErr != nil { + return model.NewAppError("DeleteAcknowledgementForPost", "app.acknowledgement.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + + a.Srv().Go(func() { + a.sendAcknowledgementEvent(model.WebsocketEventAcknowledgementRemoved, oldAck, post) + }) + + return nil +} + +func (a *App) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) { + acknowledgements, nErr := a.Srv().Store().PostAcknowledgement().GetForPost(postID) + if nErr != nil { + return nil, model.NewAppError("GetAcknowledgementsForPost", "app.acknowledgement.getforpost.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + + return acknowledgements, nil +} + +func (a *App) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) { + acknowledgements, err := a.Srv().Store().PostAcknowledgement().GetForPosts(postList.Order) + + if err != nil { + return nil, model.NewAppError("GetPostAcknowledgementsForPostList", "app.acknowledgement.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + acknowledgementsMap := make(map[string][]*model.PostAcknowledgement) + + for _, ack := range acknowledgements { + acknowledgementsMap[ack.PostId] = append(acknowledgementsMap[ack.PostId], ack) + } + + return acknowledgementsMap, nil +} + +func (a *App) sendAcknowledgementEvent(event string, acknowledgement *model.PostAcknowledgement, post *model.Post) { + // send out that a acknowledgement has been added/removed + message := model.NewWebSocketEvent(event, "", post.ChannelId, "", nil, "") + + acknowledgementJSON, err := json.Marshal(acknowledgement) + if err != nil { + a.Log().Warn("Failed to encode acknowledgement to JSON", mlog.Err(err)) + } + message.Add("acknowledgement", string(acknowledgementJSON)) + a.Publish(message) +} diff --git a/app/post_acknowledgements_test.go b/app/post_acknowledgements_test.go new file mode 100644 index 0000000000..f1306c29fd --- /dev/null +++ b/app/post_acknowledgements_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func TestPostAcknowledgementsApp(t *testing.T) { + t.Run("SaveAcknowledgementForPost", func(t *testing.T) { testSaveAcknowledgementForPost(t) }) + t.Run("DeleteAcknowledgementForPost", func(t *testing.T) { testDeleteAcknowledgementForPost(t) }) + t.Run("GetAcknowledgementsForPostList", func(t *testing.T) { testGetAcknowledgementsForPostList(t) }) +} + +func testSaveAcknowledgementForPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("save acknowledgment for post should save acknowledgement", func(t *testing.T) { + post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "message", + }, "", true) + + require.Nil(t, err) + + acknowledgment, err := th.App.SaveAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + require.Greater(t, acknowledgment.AcknowledgedAt, int64(0)) + require.Equal(t, post.Id, acknowledgment.PostId) + require.Equal(t, th.BasicUser.Id, acknowledgment.UserId) + }) +} + +func testDeleteAcknowledgementForPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + t.Run("delete acknowledgment for post should delete acknowledgement", func(t *testing.T) { + _, err = th.App.SaveAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + acknowledgments, err := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + + err = th.App.DeleteAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + acknowledgments, err = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Empty(t, acknowledgments) + }) + + t.Run("delete acknowledgment for post after 5 min after acknowledged should not delete", func(t *testing.T) { + _, nErr := th.App.Srv().Store().PostAcknowledgement().Save(post.Id, th.BasicUser.Id, model.GetMillis()-int64(6*60*1000)) + require.NoError(t, nErr) + + acknowledgments, err := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + + err = th.App.DeleteAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.NotNil(t, err) + require.Equal(t, 403, err.StatusCode) + + acknowledgments, err = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + }) +} + +func testGetAcknowledgementsForPostList(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + p1, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + p2, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + p3, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + t.Run("get acknowledgments for post list should return a map", func(t *testing.T) { + _, err = th.App.SaveAcknowledgementForPost(th.Context, p1.Id, th.BasicUser.Id) + require.Nil(t, err) + _, err = th.App.SaveAcknowledgementForPost(th.Context, p2.Id, th.BasicUser.Id) + require.Nil(t, err) + _, err = th.App.SaveAcknowledgementForPost(th.Context, p1.Id, th.BasicUser2.Id) + require.Nil(t, err) + + postList := model.NewPostList() + postList.AddPost(p1) + postList.AddOrder(p1.Id) + postList.AddPost(p2) + postList.AddOrder(p2.Id) + postList.AddPost(p3) + postList.AddOrder(p3.Id) + + acks1, err := th.App.GetAcknowledgementsForPost(p1.Id) + require.Nil(t, err) + acks2, err := th.App.GetAcknowledgementsForPost(p2.Id) + require.Nil(t, err) + + acknowledgementsMap, err := th.App.GetAcknowledgementsForPostList(postList) + require.Nil(t, err) + + expected := map[string][]*model.PostAcknowledgement{ + p1.Id: acks1, + p2.Id: acks2, + } + require.Equal(t, expected, acknowledgementsMap) + require.Len(t, acknowledgementsMap[p1.Id], 2) + require.Len(t, acknowledgementsMap[p2.Id], 1) + require.Nil(t, acknowledgementsMap[p3.Id]) + }) +} diff --git a/app/post_metadata.go b/app/post_metadata.go index 9d57bf83aa..347dfe8708 100644 --- a/app/post_metadata.go +++ b/app/post_metadata.go @@ -56,11 +56,25 @@ func (a *App) PreparePostListForClient(c request.CTX, originalList *model.PostLi } for id, originalPost := range originalList.Posts { - post := a.PreparePostForClientWithEmbedsAndImages(c, originalPost, false, false) + post := a.PreparePostForClientWithEmbedsAndImages(c, originalPost, false, false, false) list.Posts[id] = post } + if a.isPostPriorityEnabled() { + priority, _ := a.GetPriorityForPostList(list) + acknowledgements, _ := a.GetAcknowledgementsForPostList(list) + + for _, id := range list.Order { + if _, ok := priority[id]; ok { + list.Posts[id].Metadata.Priority = priority[id] + } + if _, ok := acknowledgements[id]; ok { + list.Posts[id].Metadata.Acknowledgements = acknowledgements[id] + } + } + } + return list } @@ -90,7 +104,7 @@ func (a *App) OverrideIconURLIfEmoji(c request.CTX, post *model.Post) { } } -func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost bool) *model.Post { +func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post { post := originalPost.Clone() // Proxy image links before constructing metadata so that requests go through the proxy @@ -123,11 +137,27 @@ func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNe post.Metadata.Files = fileInfos } + if includePriority && a.isPostPriorityEnabled() && post.RootId == "" { + // Post's Priority if any + if priority, err := a.GetPriorityForPost(post.Id); err != nil { + mlog.Warn("Failed to get post priority for a post", mlog.String("post_id", post.Id), mlog.Err(err)) + } else { + post.Metadata.Priority = priority + } + + // Post's acknowledgements if any + if acknowledgements, err := a.GetAcknowledgementsForPost(post.Id); err != nil { + mlog.Warn("Failed to get post acknowledgements for a post", mlog.String("post_id", post.Id), mlog.Err(err)) + } else { + post.Metadata.Acknowledgements = acknowledgements + } + } + return post } -func (a *App) PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost bool) *model.Post { - post := a.PreparePostForClient(c, originalPost, isNewPost, isEditPost) +func (a *App) PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post { + post := a.PreparePostForClient(c, originalPost, isNewPost, isEditPost, includePriority) post = a.getEmbedsAndImages(c, post, isNewPost) return post } @@ -562,7 +592,7 @@ func (a *App) getLinkMetadata(c request.CTX, requestURL string, timestamp int64, permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPost, referencedTeam, referencedChannel)} } else { // referencedPost does not contain a permalink: we get its metadata - referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(c, referencedPost, false, false) + referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(c, referencedPost, false, false, false) permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPostWithMetadata, referencedTeam, referencedChannel)} } } else { diff --git a/app/post_metadata_test.go b/app/post_metadata_test.go index 29e9ad8c4d..50e2608ebc 100644 --- a/app/post_metadata_test.go +++ b/app/post_metadata_test.go @@ -125,7 +125,7 @@ func TestPreparePostForClient(t *testing.T) { Message: message, } - clientPost := th.App.PreparePostForClient(th.Context, post, false, true) + clientPost := th.App.PreparePostForClient(th.Context, post, false, true, false) t.Run("doesn't mutate provided post", func(t *testing.T) { assert.NotEqual(t, clientPost, post, "should've returned a new post") @@ -151,7 +151,7 @@ func TestPreparePostForClient(t *testing.T) { post := th.CreatePost(th.BasicChannel) - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) assert.False(t, clientPost == post, "should've returned a new post") assert.Equal(t, clientPost, post, "shouldn't have changed any metadata") @@ -167,7 +167,7 @@ func TestPreparePostForClient(t *testing.T) { reaction3 := th.AddReactionToPost(post, th.BasicUser2, "ice_cream") post.HasReactions = true - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) assert.Len(t, clientPost.Metadata.Reactions, 3, "should've populated Reactions") assert.Equal(t, reaction1, clientPost.Metadata.Reactions[0], "first reaction is incorrect") @@ -194,7 +194,7 @@ func TestPreparePostForClient(t *testing.T) { var clientPost *model.Post assert.Eventually(t, func() bool { - clientPost = th.App.PreparePostForClient(th.Context, post, false, false) + clientPost = th.App.PreparePostForClient(th.Context, post, false, false, false) return assert.ObjectsAreEqual([]*model.FileInfo{fileInfo}, clientPost.Metadata.Files) }, time.Second, 10*time.Millisecond) @@ -230,7 +230,7 @@ func TestPreparePostForClient(t *testing.T) { th.AddReactionToPost(post, th.BasicUser2, "angry") post.HasReactions = true - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) t.Run("populates emojis", func(t *testing.T) { assert.ElementsMatch(t, []*model.Emoji{}, clientPost.Metadata.Emojis, "should've populated empty Emojis") @@ -275,7 +275,7 @@ func TestPreparePostForClient(t *testing.T) { th.AddReactionToPost(post, th.BasicUser2, "angry") post.HasReactions = true - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) t.Run("populates emojis", func(t *testing.T) { assert.ElementsMatch(t, []*model.Emoji{emoji1, emoji2, emoji3, emoji4}, clientPost.Metadata.Emojis, "should've populated post.Emojis") @@ -307,7 +307,7 @@ func TestPreparePostForClient(t *testing.T) { post.AddProp(model.PostPropsOverrideIconURL, url) post.AddProp(model.PostPropsOverrideIconEmoji, emoji) - return th.App.PreparePostForClient(th.Context, post, false, false) + return th.App.PreparePostForClient(th.Context, post, false, false, false) } emoji := "basketball" @@ -361,7 +361,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) t.Run("populates image dimensions", func(t *testing.T) { imageDimensions := clientPost.Metadata.Images @@ -394,7 +394,7 @@ func TestPreparePostForClient(t *testing.T) { post.AddProp(model.PostPropsOverrideIconEmoji, true) require.NotPanics(t, func() { - _ = th.App.PreparePostForClient(th.Context, post, false, false) + _ = th.App.PreparePostForClient(th.Context, post, false, false, false) }) }) @@ -424,7 +424,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) post.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false) // Reminder that only the first link gets an embed and dimensions @@ -459,7 +459,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] ogData := firstEmbed.Data.(*opengraph.OpenGraph) @@ -502,7 +502,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) post.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false) t.Run("populates embeds", func(t *testing.T) { assert.ElementsMatch(t, []*model.PostEmbed{ @@ -547,7 +547,7 @@ func TestPreparePostForClient(t *testing.T) { // DeleteAt isn't set on the post returned by App.DeletePost post.DeleteAt = model.GetMillis() - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) assert.NotEqual(t, nil, clientPost.Metadata, "should've populated Metadata“") assert.Equal(t, "", clientPost.Message, "should've cleaned post content") @@ -582,7 +582,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) previewPost.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview.PostID) @@ -641,7 +641,7 @@ func TestPreparePostForClient(t *testing.T) { require.Nil(t, err) previewPost.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) @@ -679,7 +679,7 @@ func TestPreparePostForClient(t *testing.T) { require.Nil(t, err) previewPost.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) referencedPostFirstEmbed := preview.Post.Metadata.Embeds[0] @@ -726,7 +726,7 @@ func TestPreparePostForClient(t *testing.T) { require.Nil(t, err) previewPost.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) referencedPostMetadata := preview.Post.Metadata @@ -761,7 +761,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) - clientPost := th.App.PreparePostForClient(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClient(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview.PostID) @@ -770,13 +770,13 @@ func TestPreparePostForClient(t *testing.T) { *cfg.ServiceSettings.EnablePermalinkPreviews = false }) - th.App.PreparePostForClient(th.Context, previewPost, false, false) + th.App.PreparePostForClient(th.Context, previewPost, false, false, false) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePermalinkPreviews = true }) - clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, false, false) + clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, false, false, false) firstEmbed2 := clientPost2.Metadata.Embeds[0] preview2 := firstEmbed2.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview2.PostID) @@ -828,7 +828,7 @@ func testProxyLinkedImage(t *testing.T, th *TestHelper, shouldProxy bool) { Message: fmt.Sprintf(postTemplate, imageURL), } - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) if shouldProxy { assert.Equal(t, fmt.Sprintf(postTemplate, imageURL), post.Message, "should not have mutated original post") @@ -876,7 +876,7 @@ func testProxyOpenGraphImage(t *testing.T, th *TestHelper, shouldProxy bool) { require.Nil(t, err) post.Metadata.Embeds = nil - embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false).Metadata.Embeds + embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false).Metadata.Embeds require.Len(t, embeds, 1, "should have one embed") embed := embeds[0] diff --git a/app/post_priority.go b/app/post_priority.go new file mode 100644 index 0000000000..d4efd534b8 --- /dev/null +++ b/app/post_priority.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "database/sql" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func (a *App) GetPriorityForPost(postId string) (*model.PostPriority, *model.AppError) { + priority, err := a.Srv().Store().PostPriority().GetForPost(postId) + + if err != nil && err != sql.ErrNoRows { + return nil, model.NewAppError("GetPriorityForPost", "app.post_prority.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return priority, nil +} + +func (a *App) GetPriorityForPostList(list *model.PostList) (map[string]*model.PostPriority, *model.AppError) { + priority, err := a.Srv().Store().PostPriority().GetForPosts(list.Order) + if err != nil { + return nil, model.NewAppError("GetPriorityForPost", "app.post_prority.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + priorityMap := make(map[string]*model.PostPriority) + for _, p := range priority { + priorityMap[p.PostId] = p + } + + return priorityMap, nil +} diff --git a/app/post_test.go b/app/post_test.go index 2ed0092a6c..0b9c37a3f8 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -1598,7 +1598,7 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, false, true) require.Nil(t, err) - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 0, count) @@ -1637,7 +1637,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post1 and post3 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) @@ -1676,7 +1676,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post2 and post3 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) @@ -1713,7 +1713,7 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, false, true) require.Nil(t, err) - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 0, count) @@ -1755,7 +1755,7 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, false, true) require.Nil(t, err) - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 0, count) @@ -1809,7 +1809,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post2 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -1863,7 +1863,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post2 and post5 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) @@ -1912,7 +1912,7 @@ func TestCountMentionsFromPost(t *testing.T) { // should be mentioned by post2 and post3 - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) @@ -1942,12 +1942,12 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, false, true) require.Nil(t, err) - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) - count, _, err = th.App.countMentionsFromPost(th.Context, user1, post1) + count, _, _, err = th.App.countMentionsFromPost(th.Context, user1, post1) assert.Nil(t, err) assert.Equal(t, 0, count) @@ -1984,7 +1984,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post1 and post3 should mention the user, but we only count post3 - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post2) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post2) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -2015,7 +2015,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post2 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -2062,7 +2062,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post4 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post3) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post3) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -2102,7 +2102,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post3 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -2138,11 +2138,70 @@ func TestCountMentionsFromPost(t *testing.T) { // Every post should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, numPosts, count) }) + + t.Run("should count urgent mentions", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true + }) + + user1 := th.BasicUser + user2 := th.BasicUser2 + + channel := th.CreateChannel(th.Context, th.BasicTeam) + th.AddUserToChannel(user2, channel) + + user2.NotifyProps[model.MentionKeysNotifyProp] = "apple" + + post1, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: user1.Id, + ChannelId: channel.Id, + Message: fmt.Sprintf("@%s", user2.Username), + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }, channel, false, true) + require.Nil(t, err) + + _, err = th.App.CreatePost(th.Context, &model.Post{ + UserId: user1.Id, + ChannelId: channel.Id, + Message: fmt.Sprintf("@%s", user2.Username), + }, channel, false, true) + require.Nil(t, err) + + _, err = th.App.CreatePost(th.Context, &model.Post{ + UserId: user1.Id, + ChannelId: channel.Id, + Message: "apple", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }, channel, false, true) + require.Nil(t, err) + + // all posts mention the user but only post1, post3 are urgent + + _, _, count, err := th.App.countMentionsFromPost(th.Context, user2, post1) + + assert.Nil(t, err) + assert.Equal(t, 2, count) + }) } func TestFillInPostProps(t *testing.T) { @@ -2887,7 +2946,7 @@ func TestComputeLastAccessiblePostTime(t *testing.T) { mockSystemStore.AssertCalled(t, "SaveOrUpdate", mock.Anything) }) - t.Run("Do NOT update the time, if cloud limit is NOT applicable", func(t *testing.T) { + t.Run("Remove the time if cloud limit is NOT applicable", func(t *testing.T) { th := SetupWithStoreMock(t) defer th.TearDown() @@ -2901,13 +2960,15 @@ func TestComputeLastAccessiblePostTime(t *testing.T) { mockStore := th.App.Srv().Store().(*storemocks.Store) mockSystemStore := storemocks.SystemStore{} - mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil) + mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "10"}, nil) + mockSystemStore.On("PermanentDeleteByName", mock.Anything).Return(nil, nil) mockStore.On("System").Return(&mockSystemStore) err := th.App.ComputeLastAccessiblePostTime() assert.NoError(t, err) mockSystemStore.AssertNotCalled(t, "SaveOrUpdate", mock.Anything) + mockSystemStore.AssertCalled(t, "PermanentDeleteByName", mock.Anything) }) } diff --git a/app/reaction.go b/app/reaction.go index 0163c98519..fc6d54699f 100644 --- a/app/reaction.go +++ b/app/reaction.go @@ -43,15 +43,13 @@ func (a *App) SaveReactionForPost(c *request.Context, reaction *model.Reaction) // The post is always modified since the UpdateAt always changes a.invalidateCacheForChannelPosts(post.ChannelId) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.ReactionHasBeenAdded(pluginContext, reaction) - return true - }, plugin.ReactionHasBeenAddedID) - }) - } + pluginContext := pluginContext(c) + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.ReactionHasBeenAdded(pluginContext, reaction) + return true + }, plugin.ReactionHasBeenAddedID) + }) a.Srv().Go(func() { a.sendReactionEvent(model.WebsocketEventReactionAdded, reaction, post) @@ -142,15 +140,13 @@ func (a *App) DeleteReactionForPost(c *request.Context, reaction *model.Reaction // The post is always modified since the UpdateAt always changes a.invalidateCacheForChannelPosts(post.ChannelId) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.ReactionHasBeenRemoved(pluginContext, reaction) - return true - }, plugin.ReactionHasBeenRemovedID) - }) - } + pluginContext := pluginContext(c) + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.ReactionHasBeenRemoved(pluginContext, reaction) + return true + }, plugin.ReactionHasBeenRemovedID) + }) a.Srv().Go(func() { a.sendReactionEvent(model.WebsocketEventReactionRemoved, reaction, post) diff --git a/app/server.go b/app/server.go index 8699a013c6..d39e66705c 100644 --- a/app/server.go +++ b/app/server.go @@ -161,6 +161,8 @@ type Server struct { tracer *tracing.Tracer products map[string]Product + + hooksManager *product.HooksManager } func (s *Server) Store() store.Store { @@ -255,6 +257,8 @@ func NewServer(options ...Option) (*Server, error) { return nil, errors.Wrapf(err, "unable to create teams service") } + s.hooksManager = product.NewHooksManager(s.GetMetrics()) + // ensure app implements `product.UserService` var _ product.UserService = (*App)(nil) @@ -283,7 +287,7 @@ func NewServer(options ...Option) (*Server, error) { // It is important to initialize the hub only after the global logger is set // to avoid race conditions while logging from inside the hub. // Step 5: Start hub in platform which the hub depends on s.Channels() (step 4) - s.platform.Start(New(ServerConnector(s.Channels()))) + s.platform.Start() // ------------------------------------------------------------------------- // Everything below this is not order sensitive and safe to be moved around. @@ -365,7 +369,7 @@ func NewServer(options ...Option) (*Server, error) { }) s.htmlTemplateWatcher = htmlTemplateWatcher - s.telemetryService = telemetry.New(New(ServerConnector(s.Channels())), s.Store(), s.platform.SearchEngine, s.Log()) + s.telemetryService = telemetry.New(New(ServerConnector(s.Channels())), s.Store(), s.platform.SearchEngine, s.Log(), *s.Config().LogSettings.VerboseDiagnostics) s.platform.SetTelemetryId(s.TelemetryId()) // TODO: move this into platform once telemetry service moved to platform. emailService, err := email.NewService(email.ServiceConfig{ diff --git a/app/session.go b/app/session.go index 86ed75166b..5b69258baf 100644 --- a/app/session.go +++ b/app/session.go @@ -235,23 +235,6 @@ func (a *App) AttachDeviceId(sessionID string, deviceID string, expiresAt int64) return nil } -func (a *App) UpdateLastActivityAtIfNeeded(session model.Session) { - now := model.GetMillis() - - a.UpdateWebConnUserActivity(session, now) - - if now-session.LastActivityAt < model.SessionActivityTimeout { - return - } - - if err := a.Srv().Store().Session().UpdateLastActivityAt(session.Id, now); err != nil { - mlog.Warn("Failed to update LastActivityAt", mlog.String("user_id", session.UserId), mlog.String("session_id", session.Id), mlog.Err(err)) - } - - session.LastActivityAt = now - a.ch.srv.platform.AddSessionToCache(&session) -} - // ExtendSessionExpiryIfNeeded extends Session.ExpiresAt based on session lengths in config. // A new ExpiresAt is only written if enough time has elapsed since last update. // Returns true only if the session was extended. diff --git a/app/status.go b/app/status.go index cdbd0759de..ecf17cedcc 100644 --- a/app/status.go +++ b/app/status.go @@ -21,174 +21,33 @@ func (a *App) GetUserStatusesByIds(userIDs []string) ([]*model.Status, *model.Ap // status to away if needed. Used by the WS to set status to away if an 'online' device disconnects // while an 'away' device is still connected func (a *App) SetStatusLastActivityAt(userID string, activityAt int64) { - var status *model.Status - var err *model.AppError - if status, err = a.GetStatus(userID); err != nil { - return - } - - status.LastActivityAt = activityAt - - a.Srv().Platform().AddStatusCacheSkipClusterSend(status) - a.SetStatusAwayIfNeeded(userID, false) + a.Srv().Platform().SetStatusLastActivityAt(userID, activityAt) } func (a *App) SetStatusOnline(userID string, manual bool) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - broadcast := false - - var oldStatus string = model.StatusOffline - var oldTime int64 - var oldManual bool - var status *model.Status - var err *model.AppError - - if status, err = a.GetStatus(userID); err != nil { - status = &model.Status{UserId: userID, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""} - broadcast = true - } else { - if status.Manual && !manual { - return // manually set status always overrides non-manual one - } - - if status.Status != model.StatusOnline { - broadcast = true - } - - oldStatus = status.Status - oldTime = status.LastActivityAt - oldManual = status.Manual - - status.Status = model.StatusOnline - status.Manual = false // for "online" there's no manual setting - status.LastActivityAt = model.GetMillis() - } - - a.Srv().Platform().AddStatusCache(status) - - // Only update the database if the status has changed, the status has been manually set, - // or enough time has passed since the previous action - if status.Status != oldStatus || status.Manual != oldManual || status.LastActivityAt-oldTime > model.StatusMinUpdateTime { - if broadcast { - if err := a.Srv().Store().Status().SaveOrUpdate(status); err != nil { - mlog.Warn("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID)) - } - } else { - if err := a.Srv().Store().Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt); err != nil { - mlog.Error("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID)) - } - } - } - - if broadcast { - a.Srv().Platform().BroadcastStatus(status) - } + a.Srv().Platform().SetStatusOnline(userID, manual) } func (a *App) SetStatusOffline(userID string, manual bool) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userID) - if err == nil && status.Manual && !manual { - return // manually set status always overrides non-manual one - } - - status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: model.GetMillis(), ActiveChannel: ""} - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusOffline(userID, manual) } func (a *App) SetStatusAwayIfNeeded(userID string, manual bool) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userID) - - if err != nil { - status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: 0, ActiveChannel: ""} - } - - if !manual && status.Manual { - return // manually set status always overrides non-manual one - } - - if !manual { - if status.Status == model.StatusAway { - return - } - - if !a.IsUserAway(status.LastActivityAt) { - return - } - } - - status.Status = model.StatusAway - status.Manual = manual - status.ActiveChannel = "" - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusAwayIfNeeded(userID, manual) } // SetStatusDoNotDisturbTimed takes endtime in unix epoch format in UTC // and sets status of given userId to dnd which will be restored back after endtime func (a *App) SetStatusDoNotDisturbTimed(userId string, endtime int64) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userId) - - if err != nil { - status = &model.Status{UserId: userId, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} - } - - status.PrevStatus = status.Status - status.Status = model.StatusDnd - status.Manual = true - - status.DNDEndTime = endtime - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusDoNotDisturbTimed(userId, endtime) } func (a *App) SetStatusDoNotDisturb(userID string) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userID) - - if err != nil { - status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} - } - - status.Status = model.StatusDnd - status.Manual = true - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusDoNotDisturb(userID) } func (a *App) SetStatusOutOfOffice(userID string) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userID) - - if err != nil { - status = &model.Status{UserId: userID, Status: model.StatusOutOfOffice, Manual: false, LastActivityAt: 0, ActiveChannel: ""} - } - - status.Status = model.StatusOutOfOffice - status.Manual = true - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusOutOfOffice(userID) } func (a *App) GetStatusFromCache(userID string) *model.Status { @@ -199,10 +58,6 @@ func (a *App) GetStatus(userID string) (*model.Status, *model.AppError) { return a.Srv().Platform().GetStatus(userID) } -func (a *App) IsUserAway(lastActivityAt int64) bool { - return model.GetMillis()-lastActivityAt >= *a.Config().TeamSettings.UserStatusAwayTimeout*1000 -} - // UpdateDNDStatusOfUsers is a recurring task which is started when server starts // which unsets dnd status of users if needed and saves and broadcasts it func (a *App) UpdateDNDStatusOfUsers() { diff --git a/app/team.go b/app/team.go index 30020e25ef..222d10e442 100644 --- a/app/team.go +++ b/app/team.go @@ -308,6 +308,8 @@ func (a *App) UpdateTeamScheme(team *model.Team) (*model.Team, *model.AppError) return nil, model.NewAppError("UpdateTeamScheme", "app.team.clear_cache.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } + a.Srv().Store().Channel().ClearMembersForUserCache() + if appErr := a.sendTeamEvent(oldTeam, model.WebsocketEventUpdateTeamScheme); appErr != nil { return nil, appErr } @@ -844,21 +846,19 @@ func (a *App) JoinUserToTeam(c request.CTX, team *model.Team, user *model.User, a.InvalidateCacheForUser(user.Id) a.invalidateCacheForUserTeams(user.Id) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var actor *model.User - if userRequestorId != "" { - actor, _ = a.GetUser(userRequestorId) - } - - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.UserHasJoinedTeam(pluginContext, teamMember, actor) - return true - }, plugin.UserHasJoinedTeamID) - }) + var actor *model.User + if userRequestorId != "" { + actor, _ = a.GetUser(userRequestorId) } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasJoinedTeam(pluginContext, teamMember, actor) + return true + }, plugin.UserHasJoinedTeamID) + }) + message := model.NewWebSocketEvent(model.WebsocketEventAddedToTeam, "", "", user.Id, nil, "") message.Add("team_id", team.Id) message.Add("user_id", user.Id) @@ -1218,21 +1218,19 @@ func (a *App) RemoveUserFromTeam(c request.CTX, teamID string, userID string, re } func (a *App) postProcessTeamMemberLeave(c request.CTX, teamMember *model.TeamMember, requestorId string) *model.AppError { - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var actor *model.User - if requestorId != "" { - actor, _ = a.GetUser(requestorId) - } - - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.UserHasLeftTeam(pluginContext, teamMember, actor) - return true - }, plugin.UserHasLeftTeamID) - }) + var actor *model.User + if requestorId != "" { + actor, _ = a.GetUser(requestorId) } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasLeftTeam(pluginContext, teamMember, actor) + return true + }, plugin.UserHasLeftTeamID) + }) + user, nErr := a.Srv().Store().User().Get(context.Background(), teamMember.UserId) if nErr != nil { var nfErr *store.ErrNotFound @@ -1741,13 +1739,14 @@ func (a *App) GetTeamsUnreadForUser(excludeTeamId string, userID string, include } else { teamIDs = append(teamIDs, id) membersMap[id] = unreads(data[i], &model.TeamUnread{ - MsgCount: 0, - MentionCount: 0, - MentionCountRoot: 0, - MsgCountRoot: 0, - ThreadCount: 0, - ThreadMentionCount: 0, - TeamId: id, + MsgCount: 0, + MentionCount: 0, + MentionCountRoot: 0, + MsgCountRoot: 0, + ThreadCount: 0, + ThreadMentionCount: 0, + ThreadUrgentMentionCount: 0, + TeamId: id, }) } } @@ -1755,7 +1754,7 @@ func (a *App) GetTeamsUnreadForUser(excludeTeamId string, userID string, include includeCollapsedThreads = includeCollapsedThreads && *a.Config().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled if includeCollapsedThreads { - teamUnreads, err := a.Srv().Store().Thread().GetTeamsUnreadForUser(userID, teamIDs) + teamUnreads, err := a.Srv().Store().Thread().GetTeamsUnreadForUser(userID, teamIDs, a.isPostPriorityEnabled()) if err != nil { return nil, model.NewAppError("GetTeamsUnreadForUser", "app.team.get_unread.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -1763,6 +1762,7 @@ func (a *App) GetTeamsUnreadForUser(excludeTeamId string, userID string, include if _, ok := teamUnreads[teamID]; ok { member.ThreadCount = teamUnreads[teamID].ThreadCount member.ThreadMentionCount = teamUnreads[teamID].ThreadMentionCount + member.ThreadUrgentMentionCount = teamUnreads[teamID].ThreadUrgentMentionCount } } } diff --git a/app/team_test.go b/app/team_test.go index 604cef33d6..e281a1feaa 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -102,6 +102,7 @@ func TestAddUserToTeam(t *testing.T) { }) t.Run("block user by domain but allow bot", func(t *testing.T) { + t.Skip("MM-48973") th.BasicTeam.AllowedDomains = "example.com" _, err := th.App.UpdateTeam(th.BasicTeam) require.Nil(t, err, "Should update the team") @@ -1109,6 +1110,45 @@ func TestAppUpdateTeamScheme(t *testing.T) { updatedTeam, err := th.App.UpdateTeamScheme(th.BasicTeam) require.Nil(t, err) require.Equal(t, mockID, updatedTeam.SchemeId, "Wrong Team SchemeId") + + // Test that a newly applied team scheme applies the new permissions to a team member + th.App.SetPhase2PermissionsMigrationStatus(true) + + team2Scheme := th.SetupTeamScheme() + channelUser, err := th.App.GetRoleByName(context.Background(), team2Scheme.DefaultChannelUserRole) + require.Nil(t, err) + channelUser.Permissions = []string{} + _, err = th.App.UpdateRole(channelUser) // Remove all permissions from the team user role of the scheme + require.Nil(t, err) + + channelAdmin, err := th.App.GetRoleByName(context.Background(), team2Scheme.DefaultChannelAdminRole) + require.Nil(t, err) + channelAdmin.Permissions = []string{} + _, err = th.App.UpdateRole(channelAdmin) // Remove all permissions from the team admin role of the scheme + require.Nil(t, err) + + team2 := th.CreateTeam() + th.App.AddUserToTeam(th.Context, team2.Id, th.BasicUser.Id, "") + channel := th.CreateChannel(th.Context, team2) + th.App.AddUserToChannel(th.Context, th.BasicUser, channel, true) + session := model.Session{ + Roles: model.SystemUserRoleId, + UserId: th.BasicUser.Id, + TeamMembers: []*model.TeamMember{ + { + UserId: th.BasicUser.Id, + TeamId: team2.Id, + SchemeUser: true, + }, + }, + } + // ensure user can update channel properties before applying the scheme + require.True(t, th.App.SessionHasPermissionToChannel(th.Context, session, channel.Id, model.PermissionManagePublicChannelProperties)) + // apply the team scheme + team2.SchemeId = &team2Scheme.Id + _, err = th.App.UpdateTeamScheme(team2) + require.Nil(t, err) + require.False(t, th.App.SessionHasPermissionToChannel(th.Context, session, channel.Id, model.PermissionManagePublicChannelProperties)) } func TestGetTeamMembers(t *testing.T) { diff --git a/app/teams/teams.go b/app/teams/teams.go index af0ef2bccf..fb0143b35a 100644 --- a/app/teams/teams.go +++ b/app/teams/teams.go @@ -43,7 +43,6 @@ func (ts *TeamService) GetTeams(teamIDs []string) ([]*model.Team, error) { } // CreateDefaultChannels creates channels in the given team for each channel returned by (*App).DefaultChannelNames. -// func (ts *TeamService) createDefaultChannels(teamID string) ([]*model.Channel, error) { displayNames := map[string]string{ "town-square": i18n.T("api.channel.create_default_channels.town_square"), @@ -192,11 +191,25 @@ func (ts *TeamService) JoinUserToTeam(team *model.Team, user *model.User) (*mode // RemoveTeamMember removes the team member from the team. This method sends // the websocket message before actually removing so the user being removed gets it. func (ts *TeamService) RemoveTeamMember(teamMember *model.TeamMember) error { - message := model.NewWebSocketEvent(model.WebsocketEventLeaveTeam, teamMember.TeamId, "", "", nil, "") - message.Add("user_id", teamMember.UserId) - message.Add("team_id", teamMember.TeamId) - ts.wh.Publish(message) + /* + MM-43850: send leave_team event to user using `ReliableClusterSend` to improve safety + */ + // message for other team members + omitUsers := make(map[string]bool, 1) + omitUsers[teamMember.UserId] = true + messageTeam := model.NewWebSocketEvent(model.WebsocketEventLeaveTeam, teamMember.TeamId, "", "", omitUsers, "") + messageTeam.Add("user_id", teamMember.UserId) + messageTeam.Add("team_id", teamMember.TeamId) + ts.wh.Publish(messageTeam) + // message for teamMember.UserId + messageUser := model.NewWebSocketEvent(model.WebsocketEventLeaveTeam, "", "", teamMember.UserId, nil, "") + messageUser.Add("user_id", teamMember.UserId) + messageUser.Add("team_id", teamMember.TeamId) + + ts.wh.Publish(messageUser) + + // delete team member teamMember.Roles = "" teamMember.DeleteAt = model.GetMillis() diff --git a/app/teams/utils.go b/app/teams/utils.go index 2fc8e199d6..14bef9b549 100644 --- a/app/teams/utils.go +++ b/app/teams/utils.go @@ -10,11 +10,13 @@ import ( ) // By default the list will be (not necessarily in this order): +// // ['town-square', 'off-topic'] +// // However, if TeamSettings.ExperimentalDefaultChannels contains a list of channels then that list will replace // 'off-topic' and be included in the return results in addition to 'town-square'. For example: -// ['town-square', 'game-of-thrones', 'wow'] // +// ['town-square', 'game-of-thrones', 'wow'] func (ts *TeamService) DefaultChannelNames() []string { names := []string{"town-square"} diff --git a/app/upload.go b/app/upload.go index 3909f5e096..4da0b853b6 100644 --- a/app/upload.go +++ b/app/upload.go @@ -49,11 +49,6 @@ func (a *App) genFileInfoFromReader(name string, file io.ReadSeeker, size int64) } func (a *App) runPluginsHook(c *request.Context, info *model.FileInfo, file io.Reader) *model.AppError { - pluginsEnvironment := a.GetPluginsEnvironment() - if pluginsEnvironment == nil { - return nil - } - filePath := info.Path // using a pipe to avoid loading the whole file content in memory. r, w := io.Pipe() @@ -67,7 +62,7 @@ func (a *App) runPluginsHook(c *request.Context, info *model.FileInfo, file io.R var rejErr *model.AppError var once sync.Once pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { once.Do(func() { hookHasRunCh <- struct{}{} }) @@ -127,11 +122,6 @@ func (a *App) runPluginsHook(c *request.Context, info *model.FileInfo, file io.R } func (a *App) CreateUploadSession(c request.CTX, us *model.UploadSession) (*model.UploadSession, *model.AppError) { - if us.FileSize > *a.Config().FileSettings.MaxFileSize { - return nil, model.NewAppError("CreateUploadSession", "app.upload.create.upload_too_large.app_error", - map[string]any{"channelId": us.ChannelId}, "", http.StatusRequestEntityTooLarge) - } - us.FileOffset = 0 now := time.Now() us.CreateAt = model.GetMillisForTime(now) diff --git a/app/upload_test.go b/app/upload_test.go index 128f157912..7fef6a8691 100644 --- a/app/upload_test.go +++ b/app/upload_test.go @@ -32,16 +32,6 @@ func TestCreateUploadSession(t *testing.T) { FileSize: 8 * 1024 * 1024, } - t.Run("FileSize over limit", func(t *testing.T) { - maxFileSize := *th.App.Config().FileSettings.MaxFileSize - th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = us.FileSize - 1 }) - defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize }) - u, err := th.App.CreateUploadSession(th.Context, us) - require.NotNil(t, err) - require.Equal(t, "app.upload.create.upload_too_large.app_error", err.Id) - require.Nil(t, u) - }) - t.Run("invalid Id", func(t *testing.T) { u, err := th.App.CreateUploadSession(th.Context, us) require.NotNil(t, err) diff --git a/app/usage.go b/app/usage.go index 3abf9bdf64..43c71e004b 100644 --- a/app/usage.go +++ b/app/usage.go @@ -10,37 +10,6 @@ import ( "github.com/mattermost/mattermost-server/v6/utils" ) -// CheckFreemiumLimitsForConfigSave returns an error if the configuration being saved violates a cloud plan's limits -func (a *App) CheckFreemiumLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError { - appErr := a.checkIntegrationLimitsForConfigSave(oldConfig, newConfig) - if appErr != nil { - return appErr - } - - return nil -} - -// GetIntegrationsUsage returns usage information on enabled integrations -func (a *App) GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { - return a.ch.getIntegrationsUsage() -} - -func (ch *Channels) getIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { - installed, appErr := ch.getInstalledIntegrations() - if appErr != nil { - return nil, appErr - } - - var count = 0 - for _, i := range installed { - if i.Enabled { - count++ - } - } - - return &model.IntegrationsUsage{Enabled: count}, nil -} - // GetPostsUsage returns the total posts count rounded down to the most // significant digit func (a *App) GetPostsUsage() (int64, *model.AppError) { diff --git a/app/user.go b/app/user.go index 0c3f998c9c..6e12bda34b 100644 --- a/app/user.go +++ b/app/user.go @@ -308,15 +308,21 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m message.Add("user_id", ruser.Id) a.Publish(message) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.UserHasBeenCreated(pluginContext, ruser) - return true - }, plugin.UserHasBeenCreatedID) - }) - } + pluginContext := pluginContext(c) + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasBeenCreated(pluginContext, ruser) + return true + }, plugin.UserHasBeenCreatedID) + }) + + // Create/Update the subscriptionHistoryEvent + go func() { + _, err := a.SendSubscriptionHistoryEvent(ruser.Id) + if err != nil { + c.Logger().Error("Failed to create/update the SubscriptionHistoryEvent", mlog.Err(err)) + } + }() return ruser, nil } @@ -2391,6 +2397,10 @@ func (a *App) ConvertBotToUser(c request.CTX, bot *model.Bot, userPatch *model.U func (a *App) GetThreadsForUser(userID, teamID string, options model.GetUserThreadsOpts) (*model.Threads, *model.AppError) { var result model.Threads var eg errgroup.Group + postPriorityIsEnabled := a.isPostPriorityEnabled() + if postPriorityIsEnabled { + options.IncludeIsUrgent = true + } if !options.ThreadsOnly { eg.Go(func() error { @@ -2427,6 +2437,18 @@ func (a *App) GetThreadsForUser(userID, teamID string, options model.GetUserThre return nil }) + + if postPriorityIsEnabled { + eg.Go(func() error { + totalUnreadUrgentMentions, err := a.Srv().Store().Thread().GetTotalUnreadUrgentMentions(userID, teamID, options) + if err != nil { + return errors.Wrapf(err, "failed to count urgent mentioned threads for user id=%s", userID) + } + result.TotalUnreadUrgentMentions = totalUnreadUrgentMentions + + return nil + }) + } } if !options.TotalsOnly { @@ -2469,7 +2491,7 @@ func (a *App) GetThreadMembershipForUser(userId, threadId string) (*model.Thread } func (a *App) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, *model.AppError) { - thread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, extended) + thread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, extended, a.isPostPriorityEnabled()) if err != nil { return nil, model.NewAppError("GetThreadForUser", "app.user.get_threads_for_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2551,7 +2573,7 @@ func (a *App) UpdateThreadFollowForUserFromChannelAdd(c request.CTX, userID, tea } message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, teamID, "", userID, nil, "") - userThread, err := a.Srv().Store().Thread().GetThreadForUser(tm, true) + userThread, err := a.Srv().Store().Thread().GetThreadForUser(tm, true, a.isPostPriorityEnabled()) if err != nil { var errNotFound *store.ErrNotFound diff --git a/app/user_test.go b/app/user_test.go index 139cc1c1e5..80a9ffd77d 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -23,7 +23,7 @@ import ( "github.com/mattermost/mattermost-server/v6/einterfaces" "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" - oauthgitlab "github.com/mattermost/mattermost-server/v6/model/gitlab" + oauthgitlab "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" "github.com/mattermost/mattermost-server/v6/store" storemocks "github.com/mattermost/mattermost-server/v6/store/storetest/mocks" "github.com/mattermost/mattermost-server/v6/utils/testutils" @@ -1852,3 +1852,85 @@ func TestIsFirstAdmin(t *testing.T) { require.True(t, isFirstAdmin) }) } + +func TestSendSubscriptionHistoryEvent(t *testing.T) { + cloudProduct := &model.Product{ + ID: "prod_test1", + Name: "name1", + Description: "description1", + PricePerSeat: 1000, + SKU: "sku1", + PriceID: "price_id1", + Family: "family1", + RecurringInterval: "year", + BillingScheme: "billing_scheme1", + CrossSellsTo: "prod_test2", + } + + subscription := &model.Subscription{ + ID: "MySubscriptionID", + CustomerID: "MyCustomer", + ProductID: "SomeProductId", + AddOns: []string{}, + StartAt: 1000000000, + EndAt: 2000000000, + CreateAt: 1000000000, + Seats: 10, + DNS: "some.dns.server", + IsPaidTier: "false", + } + + subscriptionHistory := &model.SubscriptionHistory{ + ID: "sub_history", + SubscriptionID: "MySubscriptionID", + Seats: 10, + CreateAt: 1000000000, + } + + t.Run("Should not create SubscriptionHistoryEvent if the license is not cloud", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("")) + + userID := "123" + + subscriptionHistoryEvent, err := th.App.SendSubscriptionHistoryEvent(userID) + require.NoError(t, err) + require.Nil(t, subscriptionHistoryEvent) + }) + + t.Run("Should create SubscriptionHistoryEvent if the license is cloud and the product is yearly", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := mocks.CloudInterface{} + + // mock the cloud functions + cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil) + cloud.Mock.On("GetCloudProduct", mock.Anything, mock.Anything).Return(cloudProduct, nil) + cloud.Mock.On("CreateOrUpdateSubscriptionHistoryEvent", mock.Anything, mock.Anything).Return(subscriptionHistory, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + // Mock to get the user count + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockUserStore := storemocks.UserStore{} + mockUserStore.On("Count", mock.Anything).Return(int64(10), nil) + + mockStore.On("User").Return(&mockUserStore) + + userID := "123" + + subscriptionHistoryEvent, err := th.App.SendSubscriptionHistoryEvent(userID) + require.NoError(t, err) + require.Equal(t, subscription.ID, subscriptionHistoryEvent.SubscriptionID, "subscription ID doesn't match") + require.Equal(t, 10, subscriptionHistoryEvent.Seats, "Number of seats doesn't match") + }) +} diff --git a/app/web_conn.go b/app/web_conn.go index 0edc3504ae..cdf59eb31e 100644 --- a/app/web_conn.go +++ b/app/web_conn.go @@ -16,5 +16,5 @@ func (a *App) PopulateWebConnConfig(s *model.Session, cfg *platform.WebConnConfi // NewWebConn returns a new WebConn instance. func (a *App) NewWebConn(cfg *platform.WebConnConfig) *platform.WebConn { - return a.Srv().Platform().NewWebConn(cfg, a, a.ch.GetPluginsEnvironment) + return a.Srv().Platform().NewWebConn(cfg, a, a.ch) } diff --git a/app/web_conn_test.go b/app/web_conn_test.go index 6c05aacf56..7b30d292a7 100644 --- a/app/web_conn_test.go +++ b/app/web_conn_test.go @@ -123,6 +123,7 @@ func TestWebConnShouldSendEvent(t *testing.T) { {"should only send to non-admins", &model.WebsocketBroadcast{ContainsSanitizedData: true}, true, true, false, true}, {"should send to nobody", &model.WebsocketBroadcast{ContainsSensitiveData: true, ContainsSanitizedData: true}, false, false, false, false}, {"should omit basic user 2 by connection id", &model.WebsocketBroadcast{OmitConnectionId: user2ConnID}, true, false, true, true}, + {"should omit basic user 2 by connection id while user is set", &model.WebsocketBroadcast{UserId: th.BasicUser2.Id, OmitConnectionId: user2ConnID}, false, false, false, false}, // needs more cases to get full coverage } diff --git a/app/worktemplates.go b/app/worktemplates.go new file mode 100644 index 0000000000..e52c05a7aa --- /dev/null +++ b/app/worktemplates.go @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/i18n" +) + +func (a *App) GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) { + categories, err := worktemplates.ListCategories() + if err != nil { + return nil, model.NewAppError("GetWorkTemplateCategories", "app.worktemplates.get_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + modelCategories := make([]*model.WorkTemplateCategory, len(categories)) + for i := range categories { + modelCategories[i] = &model.WorkTemplateCategory{ + ID: categories[i].ID, + Name: t(categories[i].Name), + } + } + + return modelCategories, nil +} + +func (a *App) GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) { + templates, err := worktemplates.ListByCategory(category) + if err != nil { + return nil, model.NewAppError("GetWorkTemplates", "app.worktemplates.get_templates.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // filter out templates that are not enabled by feature Flag + enabledTemplates := []*model.WorkTemplate{} + for _, template := range templates { + mTemplate := template.ToModelWorkTemplate(t) + if template.FeatureFlag == nil { + enabledTemplates = append(enabledTemplates, mTemplate) + continue + } + + if featureFlags[template.FeatureFlag.Name] == template.FeatureFlag.Value { + enabledTemplates = append(enabledTemplates, mTemplate) + } + } + + return enabledTemplates, nil +} diff --git a/app/worktemplates/categories.yaml b/app/worktemplates/categories.yaml new file mode 100644 index 0000000000..ab76f31f1a --- /dev/null +++ b/app/worktemplates/categories.yaml @@ -0,0 +1,2 @@ +- id: product_teams + name: worktemplate.category.product_teams diff --git a/app/worktemplates/generator/main.go b/app/worktemplates/generator/main.go new file mode 100644 index 0000000000..14a447b09a --- /dev/null +++ b/app/worktemplates/generator/main.go @@ -0,0 +1,154 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "bytes" + "crypto/md5" + _ "embed" + "fmt" + "html/template" + "io" + "log" + "os" + "path" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/pkg/errors" + "golang.org/x/tools/imports" + "gopkg.in/yaml.v3" +) + +type WorkTemplateWithMD5 struct { + worktemplates.WorkTemplate + MD5 string +} + +type WorkTemplateCategoryWithMD5 struct { + worktemplates.WorkTemplateCategory `yaml:",inline"` + MD5 string +} + +func getFileContent(filename string) ([]byte, error) { + return os.ReadFile(path.Join(filename)) +} + +func main() { + // parse categories first + dat, err := getFileContent("categories.yaml") + if err != nil { + log.Fatal(errors.Wrap(err, "failed to read categories.yaml")) + } + + h := md5.New() + + cats := []WorkTemplateCategoryWithMD5{} // meow + err = yaml.Unmarshal(dat, &cats) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to unmarshal categories.yaml")) + } + + // validate categories + categoryIds := map[string]struct{}{} + for id := range cats { + cat := cats[id] + + if cat.ID == "" && cat.Name == "" { + // skip empty array element + continue + } + + if cat.ID == "" { + log.Fatal(errors.New("category ID cannot be empty")) + } + if cat.Name == "" { + log.Fatal(errors.New("category name cannot be empty")) + } + categoryIds[cat.ID] = struct{}{} + + h.Write([]byte(cat.ID)) + cats[id].MD5 = fmt.Sprintf("%x", h.Sum(nil)) + h.Reset() + } + + dat, err = getFileContent("templates.yaml") + if err != nil { + log.Fatal(errors.Wrap(err, "failed to read templates.yaml")) + } + + dec := yaml.NewDecoder(bytes.NewReader(dat)) + ts := []WorkTemplateWithMD5{} + for { + t := worktemplates.WorkTemplate{} + err = dec.Decode(&t) + if err != nil { + if err == io.EOF { + break + } + log.Fatal(err) + } + if t.ID == "" { + continue + } + + h.Write([]byte(t.ID)) + err = t.Validate(categoryIds) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to validate template")) + } + + ts = append(ts, WorkTemplateWithMD5{ + WorkTemplate: t, + MD5: fmt.Sprintf("%x", h.Sum(nil)), + }) + h.Reset() + } + + code := bytes.NewBuffer(nil) + tmpl, err := template.New("worktemplates").Parse(tpl) + if err != nil { + log.Fatal(err) + } + tmpl.Execute(code, struct { + Templates []WorkTemplateWithMD5 + Categories []WorkTemplateCategoryWithMD5 + }{ + Templates: ts, + Categories: cats, + }) + + formattedCode, err := imports.Process(path.Join("worktemplate_generated.go"), code.Bytes(), &imports.Options{Comments: true}) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to format code")) + } + + err = os.WriteFile(path.Join("worktemplate_generated.go"), formattedCode, 0644) + if err != nil { + log.Fatal(err) + } + + // print all translatable content + fmt.Println("\nTranslation helpers:\n====================") + for _, t := range ts { + translationHelper(t.Description.Channel) + translationHelper(t.Description.Board) + translationHelper(t.Description.Playbook) + translationHelper(t.Description.Integration) + } +} + +var translationHelperTemplate = `{ + "id": %q, + "translation": %q +},` + +func translationHelper(t *worktemplates.TranslatableString) { + if t != nil && t.ID != "" && t.DefaultMessage != "" { + fmt.Printf(translationHelperTemplate, t.ID, t.DefaultMessage) + fmt.Println("") + } +} + +//go:embed worktemplate.tmpl +var tpl string diff --git a/app/worktemplates/generator/worktemplate.tmpl b/app/worktemplates/generator/worktemplate.tmpl new file mode 100644 index 0000000000..60f40b5301 --- /dev/null +++ b/app/worktemplates/generator/worktemplate.tmpl @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Code generated by "make generate-worktemplates" +// DO NOT EDIT + +package worktemplates + +func init() { + {{- range .Categories}} + registerWorkTemplateCategory("{{.ID}}", wtc{{.MD5}}) + {{- end -}} + {{range .Templates}} + registerWorkTemplate("{{.ID}}", wt{{.MD5}}) + {{- end}} + + // Register categories strings + {{range .Categories -}} + _ = T("{{.Name}}") + {{end}} + + // Register translation strings + {{range .Templates -}} + {{if and (.Description.Channel) (ne .Description.Channel.ID "")}}_ = T("{{.Description.Channel.ID}}") + {{end -}} + {{if and (.Description.Board) (ne .Description.Board.ID "")}}_ = T("{{.Description.Board.ID}}") + {{end -}} + {{if and (.Description.Playbook) (ne .Description.Playbook.ID "")}}_ = T("{{.Description.Playbook.ID}}") + {{end -}} + {{if and (.Description.Integration) (ne .Description.Integration.ID "")}}_ = T("{{.Description.Integration.ID}}") + {{end -}} + {{end -}} +} + +{{range .Categories}} +var wtc{{.MD5}} = &WorkTemplateCategory{ + ID: "{{.ID}}", + Name: "{{.Name}}", +} +{{end}} + +{{range .Templates}} +var wt{{.MD5}} = &WorkTemplate{ + ID: "{{.ID}}", + Category: "{{.Category}}", + UseCase: "{{.UseCase}}", + Illustration: "{{.Illustration}}", + Visibility: "{{.Visibility}}", + {{if .FeatureFlag}}FeatureFlag: &FeatureFlag{ + Name: "{{.FeatureFlag.Name}}", + Value: "{{.FeatureFlag.Value}}", + },{{end}} + Description: Description{ + {{if .Description.Channel}}Channel: &TranslatableString{ + ID: "{{.Description.Channel.ID}}", + DefaultMessage: "{{.Description.Channel.DefaultMessage}}", + Illustration: "{{.Description.Channel.Illustration}}", + },{{end}} + {{if .Description.Board}}Board: &TranslatableString{ + ID: "{{.Description.Board.ID}}", + DefaultMessage: "{{.Description.Board.DefaultMessage}}", + Illustration: "{{.Description.Board.Illustration}}", + },{{end}} + {{if .Description.Playbook}}Playbook: &TranslatableString{ + ID: "{{.Description.Playbook.ID}}", + DefaultMessage: "{{.Description.Playbook.DefaultMessage}}", + Illustration: "{{.Description.Playbook.Illustration}}", + },{{end}} + {{if .Description.Integration}}Integration: &TranslatableString{ + ID: "{{.Description.Integration.ID}}", + DefaultMessage: "{{.Description.Integration.DefaultMessage}}", + Illustration: "{{.Description.Integration.Illustration}}", + },{{end}} + }, + Content: []Content{ + {{range .Content}}{ + {{if .Channel}}Channel: &Channel{ + ID: "{{.Channel.ID}}", + Name: "{{.Channel.Name}}", + Purpose: "{{.Channel.Purpose}}", + Playbook: "{{.Channel.Playbook}}", + Illustration: "{{.Channel.Illustration}}", + },{{end}}{{if .Board}}Board: &Board{ + ID: "{{.Board.ID}}", + Template: "{{.Board.Template}}", + Name: "{{.Board.Name}}", + Channel: "{{.Board.Channel}}", + Illustration: "{{.Board.Illustration}}", + },{{end}}{{if .Playbook}}Playbook: &Playbook{ + Template: "{{.Playbook.Template}}", + Name: "{{.Playbook.Name}}", + ID: "{{.Playbook.ID}}", + Illustration: "{{.Playbook.Illustration}}", + },{{end}}{{if .Integration}}Integration: &Integration{ + ID: "{{.Integration.ID}}", + },{{end}} + }, + {{end}} + }, +} +{{end}} diff --git a/app/worktemplates/templates.yaml b/app/worktemplates/templates.yaml new file mode 100644 index 0000000000..bf188cae73 --- /dev/null +++ b/app/worktemplates/templates.yaml @@ -0,0 +1,46 @@ +id: "product_teams/feature_release:v1" +category: product_teams +useCase: Feature Release +illustration: https://via.placeholder.com/204x123.png +visibility: public +description: + channel: + id: "worktemplate.product_teams.feature_release.description.channel" + defaultMessage: "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots." + board: + id: "worktemplate.product_teams.feature_release.description.board" + defaultMessage: "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way." + playbook: + id: "worktemplate.product_teams.feature_release.description.playbook" + defaultMessage: "Create transparent workflows across development teams to ensure your feature development process is seamless." + integration: + id: "worktemplate.product_teams.feature_release.description.integration" + defaultMessage: "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you." + illustration: "https://via.placeholder.com/509x352.png?text=Integrations" +content: + - channel: + id: feature-release + name: Feature Release + playbook: product-release-playbook # playbook id. if set the channel will be created by the playbook run. + illustration: "https://via.placeholder.com/509x352.png?text=Channel+feature+release" + - board: + id: "board-meeting-agenda" + template: "meeting agenda|bwps66irhr7b9dxgayf9kz33g5o" # <-- have to find a way to target the board template... could hardcode the ids but need to verify that they don't change? + name: Meeting Agenda + channel: feature-release # <-- optional. we use the channel "id" from above + illustration: "https://via.placeholder.com/509x352.png?text=Board+meeting+agenda" + - board: + id: "board-project-task" + template: "project task|bmttiziw35irgtmztewd9upyqdy" + name: project task board + channel: feature-release + illustration: "https://via.placeholder.com/509x352.png?text=Board+project+task" + - playbook: + template: "product release" # <-- playbooks templates don't have ids, have to rely on name + name: "Feature release" + id: product-release-playbook + illustration: "https://via.placeholder.com/509x352.png?text=Playbook+feature+release" + - integration: + id: jira + - integration: + id: github diff --git a/app/worktemplates/types.go b/app/worktemplates/types.go new file mode 100644 index 0000000000..162f569053 --- /dev/null +++ b/app/worktemplates/types.go @@ -0,0 +1,342 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package worktemplates + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/i18n" + "github.com/pkg/errors" +) + +type WorkTemplateCategory struct { + ID string `yaml:"id"` + Name string `yaml:"name"` +} + +type WorkTemplate struct { + ID string `yaml:"id"` + Category string `yaml:"category"` + UseCase string `yaml:"useCase"` + Illustration string `yaml:"illustration"` + Visibility string `yaml:"visibility"` + FeatureFlag *FeatureFlag `yaml:"featureFlag,omitempty"` + Description Description `yaml:"description"` + Content []Content `yaml:"content"` +} + +func (wt WorkTemplate) ToModelWorkTemplate(t i18n.TranslateFunc) *model.WorkTemplate { + mwt := &model.WorkTemplate{ + ID: wt.ID, + Category: wt.Category, + UseCase: wt.UseCase, + Illustration: wt.Illustration, + Visibility: wt.Visibility, + } + + if wt.FeatureFlag != nil { + mwt.FeatureFlag = &model.WorkTemplateFeatureFlag{ + Name: wt.FeatureFlag.Name, + Value: wt.FeatureFlag.Value, + } + } + + if wt.Description.Channel != nil { + mwt.Description.Channel = &model.DescriptionContent{ + Message: wt.Description.Channel.Translate(t), + Illustration: wt.Description.Channel.Illustration, + } + } + + if wt.Description.Board != nil { + mwt.Description.Board = &model.DescriptionContent{ + Message: wt.Description.Board.Translate(t), + Illustration: wt.Description.Board.Illustration, + } + } + + if wt.Description.Playbook != nil { + mwt.Description.Playbook = &model.DescriptionContent{ + Message: wt.Description.Playbook.Translate(t), + Illustration: wt.Description.Playbook.Illustration, + } + } + + if wt.Description.Integration != nil { + mwt.Description.Integration = &model.DescriptionContent{ + Message: wt.Description.Integration.Translate(t), + Illustration: wt.Description.Integration.Illustration, + } + } + + for _, content := range wt.Content { + if content.Channel != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Channel: &model.WorkTemplateChannel{ + ID: content.Channel.ID, + Name: content.Channel.Name, + Purpose: content.Channel.Purpose, + Playbook: content.Channel.Playbook, + Illustration: content.Channel.Illustration, + }, + }) + } + if content.Board != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Board: &model.WorkTemplateBoard{ + ID: content.Board.ID, + Name: content.Board.Name, + Template: content.Board.Template, + Channel: content.Board.Channel, + Illustration: content.Board.Illustration, + }, + }) + } + if content.Playbook != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Playbook: &model.WorkTemplatePlaybook{ + ID: content.Playbook.ID, + Name: content.Playbook.Name, + Template: content.Playbook.Template, + Illustration: content.Playbook.Illustration, + }, + }) + } + if content.Integration != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Integration: &model.WorkTemplateIntegration{ + ID: content.Integration.ID, + }, + }) + } + } + + return mwt +} + +func (wt WorkTemplate) Validate(categoryIds map[string]struct{}) error { + if wt.ID == "" { + return errors.New("id is required") + } + if wt.Category == "" { + return errors.New("category is required") + } + if _, ok := categoryIds[wt.Category]; !ok { + return fmt.Errorf("category %s does not exist", wt.Category) + } + if wt.UseCase == "" { + return errors.New("useCase is required") + } + if wt.Illustration == "" { + return errors.New("illustration is required") + } + if wt.Visibility == "" { + return errors.New("visibility is required") + } + hasChannel := false + hasBoard := false + hasPlaybook := false + hasIntegration := false + foundChannels := map[string]struct{}{} + foundPlaybooks := map[string]struct{}{} + foundBoards := map[string]struct{}{} + foundIntegrations := map[string]struct{}{} + mustHaveChannels := []string{} + mustHavePlaybooks := []string{} + + currentIdx := 0 + for _, content := range wt.Content { + if content.Channel != nil { + hasChannel = true + if cErr := content.Channel.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundChannels[content.Channel.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate channel %s found", content.Channel.ID), currentIdx) + } + foundChannels[content.Channel.ID] = struct{}{} + + if content.Channel.Playbook != "" { + mustHavePlaybooks = append(mustHavePlaybooks, content.Channel.Playbook) + } + } + + if content.Board != nil { + hasBoard = true + if cErr := content.Board.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundBoards[content.Board.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate board %s found", content.Board.ID), currentIdx) + } + foundBoards[content.Board.ID] = struct{}{} + + if content.Board.Channel != "" { + mustHaveChannels = append(mustHaveChannels, content.Board.Channel) + } + } + if content.Playbook != nil { + hasPlaybook = true + if cErr := content.Playbook.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundPlaybooks[content.Playbook.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate playbook %s found", content.Playbook.ID), currentIdx) + } + foundPlaybooks[content.Playbook.ID] = struct{}{} + } + if content.Integration != nil { + hasIntegration = true + if cErr := content.Integration.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundIntegrations[content.Integration.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate integration %s found", content.Integration.ID), currentIdx) + } + foundIntegrations[content.Integration.ID] = struct{}{} + } + } + + if hasChannel && wt.Description.Channel == nil { + return errors.New("description.channel is required") + } + if hasBoard && wt.Description.Board == nil { + return errors.New("description.board is required") + } + if hasPlaybook && wt.Description.Playbook == nil { + return errors.New("description.playbook is required") + } + if hasIntegration && wt.Description.Integration == nil { + return errors.New("description.integration is required") + } + + for _, channel := range mustHaveChannels { + if _, ok := foundChannels[channel]; !ok { + return fmt.Errorf("channel %s is required", channel) + } + } + + for _, playbook := range mustHavePlaybooks { + if _, ok := foundPlaybooks[playbook]; !ok { + return fmt.Errorf("playbook %s is required", playbook) + } + } + + return nil +} + +type FeatureFlag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type TranslatableString struct { + ID string `yaml:"id"` + DefaultMessage string `yaml:"defaultMessage"` + Illustration string `yaml:"illustration"` +} + +func (ts TranslatableString) Translate(t i18n.TranslateFunc) string { + if ts.ID != "" { + msg := t(ts.ID) + if msg != ts.ID && msg != "" { + return msg + } + } + + return ts.DefaultMessage +} + +type Description struct { + Channel *TranslatableString `yaml:"channel"` + Board *TranslatableString `yaml:"board"` + Playbook *TranslatableString `yaml:"playbook"` + Integration *TranslatableString `yaml:"integration"` +} + +type Channel struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Purpose string `yaml:"purpose"` + Playbook string `yaml:"playbook"` + Illustration string `yaml:"illustration"` +} + +func (c *Channel) Validate() error { + if c.ID == "" { + return errors.New("id is required") + } + if c.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Board struct { + ID string `yaml:"id"` + Template string `yaml:"template"` + Name string `yaml:"name"` + Channel string `yaml:"channel"` + Illustration string `yaml:"illustration"` +} + +func (b Board) Validate() error { + if b.ID == "" { + return errors.New("id is required") + } + if b.Template == "" { + return errors.New("template is required") + } + if b.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Playbook struct { + Template string `yaml:"template"` + Name string `yaml:"name"` + ID string `yaml:"id"` + Illustration string `yaml:"illustration"` +} + +func (p *Playbook) Validate() error { + if p.ID == "" { + return errors.New("id is required") + } + if p.Template == "" { + return errors.New("template is required") + } + if p.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Integration struct { + ID string `yaml:"id"` +} + +func (i *Integration) Validate() error { + if i.ID == "" { + return errors.New("id is required") + } + + return nil +} + +type Content struct { + Channel *Channel `yaml:"channel,omitempty"` + Board *Board `yaml:"board,omitempty"` + Playbook *Playbook `yaml:"playbook,omitempty"` + Integration *Integration `yaml:"integration,omitempty"` +} + +func wrapContentError(err error, index int) error { + return errors.Wrapf(err, "content #%d validation failed", index) +} diff --git a/app/worktemplates/worktemplate_generated.go b/app/worktemplates/worktemplate_generated.go new file mode 100644 index 0000000000..77bd419653 --- /dev/null +++ b/app/worktemplates/worktemplate_generated.go @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Code generated by "make generate-worktemplates" +// DO NOT EDIT + +package worktemplates + +func init() { + registerWorkTemplateCategory("product_teams", wtc846b565cd80043537945134a54812e07) + registerWorkTemplate("product_teams/feature_release:v1", wt00a1b44a5831c0a3acb14787b3fdd352) + + // Register categories strings + _ = T("worktemplate.category.product_teams") + + // Register translation strings + _ = T("worktemplate.product_teams.feature_release.description.channel") + _ = T("worktemplate.product_teams.feature_release.description.board") + _ = T("worktemplate.product_teams.feature_release.description.playbook") + _ = T("worktemplate.product_teams.feature_release.description.integration") +} + +var wtc846b565cd80043537945134a54812e07 = &WorkTemplateCategory{ + ID: "product_teams", + Name: "worktemplate.category.product_teams", +} + +var wt00a1b44a5831c0a3acb14787b3fdd352 = &WorkTemplate{ + ID: "product_teams/feature_release:v1", + Category: "product_teams", + UseCase: "Feature Release", + Illustration: "https://via.placeholder.com/204x123.png", + Visibility: "public", + + Description: Description{ + Channel: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.channel", + DefaultMessage: "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots.", + Illustration: "", + }, + Board: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.board", + DefaultMessage: "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way.", + Illustration: "", + }, + Playbook: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.playbook", + DefaultMessage: "Create transparent workflows across development teams to ensure your feature development process is seamless.", + Illustration: "", + }, + Integration: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.integration", + DefaultMessage: "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you.", + Illustration: "https://via.placeholder.com/509x352.png?text=Integrations", + }, + }, + Content: []Content{ + { + Channel: &Channel{ + ID: "feature-release", + Name: "Feature Release", + Purpose: "", + Playbook: "product-release-playbook", + Illustration: "https://via.placeholder.com/509x352.png?text=Channel+feature+release", + }, + }, + { + Board: &Board{ + ID: "board-meeting-agenda", + Template: "meeting agenda|bwps66irhr7b9dxgayf9kz33g5o", + Name: "Meeting Agenda", + Channel: "feature-release", + Illustration: "https://via.placeholder.com/509x352.png?text=Board+meeting+agenda", + }, + }, + { + Board: &Board{ + ID: "board-project-task", + Template: "project task|bmttiziw35irgtmztewd9upyqdy", + Name: "project task board", + Channel: "feature-release", + Illustration: "https://via.placeholder.com/509x352.png?text=Board+project+task", + }, + }, + { + Playbook: &Playbook{ + Template: "product release", + Name: "Feature release", + ID: "product-release-playbook", + Illustration: "https://via.placeholder.com/509x352.png?text=Playbook+feature+release", + }, + }, + { + Integration: &Integration{ + ID: "jira", + }, + }, + { + Integration: &Integration{ + ID: "github", + }, + }, + }, +} diff --git a/app/worktemplates/worktemplates.go b/app/worktemplates/worktemplates.go new file mode 100644 index 0000000000..44e7931844 --- /dev/null +++ b/app/worktemplates/worktemplates.go @@ -0,0 +1,37 @@ +//go:generate go run generator/main.go + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package worktemplates + +var OrderedWorkTemplates = []*WorkTemplate{} +var OrderedWorkTemplateCategories = []*WorkTemplateCategory{} + +// T is a placeholder to allow the translation tool to register the strings +func T(id string) string { + return id +} + +func registerWorkTemplate(id string, wt *WorkTemplate) { + OrderedWorkTemplates = append(OrderedWorkTemplates, wt) +} + +func registerWorkTemplateCategory(id string, wtc *WorkTemplateCategory) { + OrderedWorkTemplateCategories = append(OrderedWorkTemplateCategories, wtc) +} + +func ListCategories() ([]*WorkTemplateCategory, error) { + return OrderedWorkTemplateCategories, nil +} + +func ListByCategory(category string) ([]*WorkTemplate, error) { + wts := []*WorkTemplate{} + for i := range OrderedWorkTemplates { + if OrderedWorkTemplates[i].Category == category { + wts = append(wts, OrderedWorkTemplates[i]) + } + } + + return wts, nil +} diff --git a/app/worktemplates_test.go b/app/worktemplates_test.go new file mode 100644 index 0000000000..eb0342fa99 --- /dev/null +++ b/app/worktemplates_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" +) + +func TestGetWorkTemplateCategories(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = wtGetCategories() + + categories, appErr := th.App.GetWorkTemplateCategories(wtTranslationFunc) + assert.Nil(appErr) + assert.Len(categories, 2) + assert.Equal("Translated test.1", categories[0].Name) + assert.Equal("Translated test.2", categories[1].Name) +} + +func TestGetWorkTemplatesByCategory(t *testing.T) { + // Setup + th := SetupWithStoreMock(t) + defer th.TearDown() + assert := require.New(t) + + existingFFkey := "test-feature-flag" + existingFFvalue := "true" + ff := map[string]string{ + existingFFkey: existingFFvalue, + } + + worktemplates.OrderedWorkTemplateCategories = wtGetCategories() + firstCat := worktemplates.OrderedWorkTemplateCategories[0] + worktemplates.OrderedWorkTemplates = []*worktemplates.WorkTemplate{ + { + ID: "test-template", + Category: firstCat.ID, + UseCase: "test use case", + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "test-template-channel-description", + DefaultMessage: "test template channel description", + }, + }, + }, + { // this one should not be returned because of the FF + ID: "test-template-2", + Category: firstCat.ID, + UseCase: "test use case 2", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: "nonexistant-random-test-feature-flag", + Value: "hi", + }, + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "test-template-2-channel-description", + DefaultMessage: "test template 2 channel description", + }, + }, + }, + { // this one should be present and match the FF + ID: "test-template-3", + Category: firstCat.ID, + UseCase: "test use case 3", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: existingFFkey, + Value: existingFFvalue, + }, + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "unknown", // simulating an unknown translation, we return the default message in this case + DefaultMessage: "default message picked for unknown", + }, + }, + }, + { // this one should not be returned because of the category + ID: "test-template-4", + Category: "cat-test2", + UseCase: "test use case 4", + }, + } + + // Act + worktemplates, appErr := th.App.GetWorkTemplates(firstCat.ID, ff, wtTranslationFunc) + + // Assert + assert.Nil(appErr) + assert.Len(worktemplates, 2) + // assert the correct work templates have been returned + assert.Equal("test-template", worktemplates[0].ID) + assert.Equal("test-template-3", worktemplates[1].ID) + // assert the descriptions have been translated + assert.Equal("Translated test-template-channel-description", worktemplates[0].Description.Channel.Message) + assert.Equal("default message picked for unknown", worktemplates[1].Description.Channel.Message) +} + +// helpers +func wtTranslationFunc(id string, args ...interface{}) string { + if id == "unknown" { + return "" + } + + return "Translated " + id +} + +func wtGetCategories() []*worktemplates.WorkTemplateCategory { + return []*worktemplates.WorkTemplateCategory{ + { + ID: "cat-test1", + Name: "test.1", + }, + { + ID: "cat-test2", + Name: "test.2", + }, + } +} diff --git a/build/release.mk b/build/release.mk index e48c3d70b1..46b5c5fcb9 100644 --- a/build/release.mk +++ b/build/release.mk @@ -1,6 +1,8 @@ dist: | check-style test package -build-linux: +build-linux: build-linux-amd64 build-linux-arm64 + +build-linux-amd64: @echo Build Linux amd64 ifeq ($(BUILDER_GOOS_GOARCH),"linux_amd64") env GOOS=linux GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -ldflags '$(LDFLAGS)' ./... @@ -8,6 +10,8 @@ else mkdir -p $(GOBIN)/linux_amd64 env GOOS=linux GOARCH=amd64 $(GO) build -o $(GOBIN)/linux_amd64 $(GOFLAGS) -trimpath -ldflags '$(LDFLAGS)' ./... endif + +build-linux-arm64: @echo Build Linux arm64 ifeq ($(BUILDER_GOOS_GOARCH),"linux_arm64") env GOOS=linux GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -ldflags '$(LDFLAGS)' ./... diff --git a/cmd/mattermost/main.go b/cmd/mattermost/main.go index e39ed27059..441ed9a752 100644 --- a/cmd/mattermost/main.go +++ b/cmd/mattermost/main.go @@ -10,7 +10,8 @@ import ( // Import and register app layer slash commands _ "github.com/mattermost/mattermost-server/v6/app/slashcommands" // Plugins - _ "github.com/mattermost/mattermost-server/v6/model/gitlab" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" + // Enterprise Imports _ "github.com/mattermost/mattermost-server/v6/imports" ) diff --git a/config/client.go b/config/client.go index 1eaef48c60..b8160993fd 100644 --- a/config/client.go +++ b/config/client.go @@ -132,6 +132,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["EnableCustomGroups"] = "false" props["InsightsEnabled"] = strconv.FormatBool(c.FeatureFlags.InsightsEnabled) props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority) + props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts) if license != nil { props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer) @@ -206,6 +207,10 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li if license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise { props["EnableCustomGroups"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomGroups) } + + if (license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise) && c.FeatureFlags.PostPriority { + props["PostAcknowledgements"] = "true" + } } return props @@ -329,6 +334,22 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["SamlLoginButtonTextColor"] = *c.SamlSettings.LoginButtonTextColor } + if *license.Features.CustomTermsOfService { + props["EnableCustomTermsOfService"] = strconv.FormatBool(*c.SupportSettings.CustomTermsOfServiceEnabled) + props["CustomTermsOfServiceReAcceptancePeriod"] = strconv.FormatInt(int64(*c.SupportSettings.CustomTermsOfServiceReAcceptancePeriod), 10) + } + + if *license.Features.MFA { + props["EnforceMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnforceMultifactorAuthentication) + } + + if license.IsCloud() { + // MM-48727: enable SSO options for free cloud - not in self hosted + *license.Features.GoogleOAuth = true + *license.Features.Office365OAuth = true + *license.Features.OpenId = true + } + if *license.Features.GoogleOAuth { props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable) } @@ -342,15 +363,6 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText } - - if *license.Features.CustomTermsOfService { - props["EnableCustomTermsOfService"] = strconv.FormatBool(*c.SupportSettings.CustomTermsOfServiceEnabled) - props["CustomTermsOfServiceReAcceptancePeriod"] = strconv.FormatInt(int64(*c.SupportSettings.CustomTermsOfServiceReAcceptancePeriod), 10) - } - - if *license.Features.MFA { - props["EnforceMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnforceMultifactorAuthentication) - } } for key, value := range c.FeatureFlags.ToMap() { diff --git a/config/client_test.go b/config/client_test.go index 4c23801424..a13b9cea77 100644 --- a/config/client_test.go +++ b/config/client_test.go @@ -216,11 +216,7 @@ func TestGetClientConfig(t *testing.T) { }, { "Custom groups professional license", - &model.Config{ - FeatureFlags: &model.FeatureFlags{ - CustomGroups: true, - }, - }, + &model.Config{}, "", &model.License{ Features: &model.Features{}, @@ -232,11 +228,7 @@ func TestGetClientConfig(t *testing.T) { }, { "Custom groups enterprise license", - &model.Config{ - FeatureFlags: &model.FeatureFlags{ - CustomGroups: true, - }, - }, + &model.Config{}, "", &model.License{ Features: &model.Features{}, diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index 641cd312af..a7fffb38f3 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -192,6 +192,14 @@ db/migrations/mysql/000095_remove_posts_parentid.down.sql db/migrations/mysql/000095_remove_posts_parentid.up.sql db/migrations/mysql/000096_threads_threadteamid.down.sql db/migrations/mysql/000096_threads_threadteamid.up.sql +db/migrations/mysql/000097_create_posts_priority.down.sql +db/migrations/mysql/000097_create_posts_priority.up.sql +db/migrations/mysql/000098_create_post_acknowledgements.down.sql +db/migrations/mysql/000098_create_post_acknowledgements.up.sql +db/migrations/mysql/000099_create_drafts.down.sql +db/migrations/mysql/000099_create_drafts.up.sql +db/migrations/mysql/000100_add_draft_priority_column.down.sql +db/migrations/mysql/000100_add_draft_priority_column.up.sql db/migrations/postgres/000001_create_teams.down.sql db/migrations/postgres/000001_create_teams.up.sql db/migrations/postgres/000002_create_team_members.down.sql @@ -384,3 +392,11 @@ db/migrations/postgres/000095_remove_posts_parentid.down.sql db/migrations/postgres/000095_remove_posts_parentid.up.sql db/migrations/postgres/000096_threads_threadteamid.down.sql db/migrations/postgres/000096_threads_threadteamid.up.sql +db/migrations/postgres/000097_create_posts_priority.down.sql +db/migrations/postgres/000097_create_posts_priority.up.sql +db/migrations/postgres/000098_create_post_acknowledgements.down.sql +db/migrations/postgres/000098_create_post_acknowledgements.up.sql +db/migrations/postgres/000099_create_drafts.down.sql +db/migrations/postgres/000099_create_drafts.up.sql +db/migrations/postgres/000100_add_draft_priority_column.down.sql +db/migrations/postgres/000100_add_draft_priority_column.up.sql diff --git a/db/migrations/mysql/000097_create_posts_priority.down.sql b/db/migrations/mysql/000097_create_posts_priority.down.sql new file mode 100644 index 0000000000..a0294c918a --- /dev/null +++ b/db/migrations/mysql/000097_create_posts_priority.down.sql @@ -0,0 +1,16 @@ +DROP TABLE IF EXISTS PostsPriority; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'UrgentMentionCount' + ) > 0, + 'ALTER TABLE ChannelMembers DROP COLUMN UrgentMentionCount;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; diff --git a/db/migrations/mysql/000097_create_posts_priority.up.sql b/db/migrations/mysql/000097_create_posts_priority.up.sql new file mode 100644 index 0000000000..b8b44d0544 --- /dev/null +++ b/db/migrations/mysql/000097_create_posts_priority.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS PostsPriority ( + PostId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + Priority varchar(32) NOT NULL, + RequestedAck tinyint(1), + PersistentNotifications tinyint(1), + PRIMARY KEY (PostId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'UrgentMentionCount' + ), + 'ALTER TABLE ChannelMembers ADD COLUMN UrgentMentionCount bigint(20);', + 'SELECT 1;' +)); + +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; diff --git a/db/migrations/mysql/000098_create_post_acknowledgements.down.sql b/db/migrations/mysql/000098_create_post_acknowledgements.down.sql new file mode 100644 index 0000000000..2360ca5a6e --- /dev/null +++ b/db/migrations/mysql/000098_create_post_acknowledgements.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS PostAcknowledgements; diff --git a/db/migrations/mysql/000098_create_post_acknowledgements.up.sql b/db/migrations/mysql/000098_create_post_acknowledgements.up.sql new file mode 100644 index 0000000000..9eb7567a85 --- /dev/null +++ b/db/migrations/mysql/000098_create_post_acknowledgements.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS PostAcknowledgements ( + PostId varchar(26) NOT NULL, + UserId varchar(26) NOT NULL, + AcknowledgedAt bigint(20) DEFAULT NULL, + PRIMARY KEY (PostId, UserId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/db/migrations/mysql/000099_create_drafts.down.sql b/db/migrations/mysql/000099_create_drafts.down.sql new file mode 100644 index 0000000000..02a72a656f --- /dev/null +++ b/db/migrations/mysql/000099_create_drafts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS Drafts; diff --git a/db/migrations/mysql/000099_create_drafts.up.sql b/db/migrations/mysql/000099_create_drafts.up.sql new file mode 100644 index 0000000000..d580fcb70e --- /dev/null +++ b/db/migrations/mysql/000099_create_drafts.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS Drafts ( + CreateAt bigint(20) DEFAULT NULL, + UpdateAt bigint(20) DEFAULT NULL, + DeleteAt bigint(20) DEFAULT NULL, + UserId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + RootId varchar(26) DEFAULT '', + Message text, + Props text, + FileIds text, + PRIMARY KEY (UserId, ChannelId, RootId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/db/migrations/mysql/000100_add_draft_priority_column.down.sql b/db/migrations/mysql/000100_add_draft_priority_column.down.sql new file mode 100644 index 0000000000..a4f15cde40 --- /dev/null +++ b/db/migrations/mysql/000100_add_draft_priority_column.down.sql @@ -0,0 +1,14 @@ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Drafts' + AND table_schema = DATABASE() + AND column_name = 'Priority' + ) > 0, + 'ALTER TABLE Drafts DROP COLUMN Priority;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; diff --git a/db/migrations/mysql/000100_add_draft_priority_column.up.sql b/db/migrations/mysql/000100_add_draft_priority_column.up.sql new file mode 100644 index 0000000000..134cc86f39 --- /dev/null +++ b/db/migrations/mysql/000100_add_draft_priority_column.up.sql @@ -0,0 +1,14 @@ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Drafts' + AND table_schema = DATABASE() + AND column_name = 'Priority' + ) > 0, + 'SELECT 1', + 'ALTER TABLE Drafts ADD COLUMN Priority text;' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; diff --git a/db/migrations/postgres/000097_create_posts_priority.down.sql b/db/migrations/postgres/000097_create_posts_priority.down.sql new file mode 100644 index 0000000000..55974765fa --- /dev/null +++ b/db/migrations/postgres/000097_create_posts_priority.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS postspriority; + +ALTER TABLE channelmembers DROP COLUMN IF EXISTS urgentmentioncount; diff --git a/db/migrations/postgres/000097_create_posts_priority.up.sql b/db/migrations/postgres/000097_create_posts_priority.up.sql new file mode 100644 index 0000000000..cc22b1e96d --- /dev/null +++ b/db/migrations/postgres/000097_create_posts_priority.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS postspriority ( + postid VARCHAR(26) PRIMARY KEY, + channelid VARCHAR(26) NOT NULL, + priority VARCHAR(32) NOT NULL, + requestedack boolean, + persistentnotifications boolean +); + +ALTER TABLE channelmembers ADD COLUMN IF NOT EXISTS urgentmentioncount bigint; diff --git a/db/migrations/postgres/000098_create_post_acknowledgements.down.sql b/db/migrations/postgres/000098_create_post_acknowledgements.down.sql new file mode 100644 index 0000000000..dc5e96624d --- /dev/null +++ b/db/migrations/postgres/000098_create_post_acknowledgements.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS postacknowledgements; diff --git a/db/migrations/postgres/000098_create_post_acknowledgements.up.sql b/db/migrations/postgres/000098_create_post_acknowledgements.up.sql new file mode 100644 index 0000000000..8f35e7b685 --- /dev/null +++ b/db/migrations/postgres/000098_create_post_acknowledgements.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS postacknowledgements( + postid VARCHAR(26) NOT NULL, + userid VARCHAR(26) NOT NULL, + acknowledgedat bigint, + PRIMARY KEY (postid, userid) +); diff --git a/db/migrations/postgres/000099_create_drafts.down.sql b/db/migrations/postgres/000099_create_drafts.down.sql new file mode 100644 index 0000000000..d8c5bb3d4f --- /dev/null +++ b/db/migrations/postgres/000099_create_drafts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS drafts; diff --git a/db/migrations/postgres/000099_create_drafts.up.sql b/db/migrations/postgres/000099_create_drafts.up.sql new file mode 100644 index 0000000000..6896cf1b9c --- /dev/null +++ b/db/migrations/postgres/000099_create_drafts.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS drafts ( + createat bigint, + updateat bigint, + deleteat bigint, + userid VARCHAR(26), + channelid VARCHAR(26), + rootid VARCHAR(26) DEFAULT '', + message VARCHAR(65535), + props VARCHAR(8000), + fileids VARCHAR(300), + PRIMARY KEY (userid, channelid, rootid) +); diff --git a/db/migrations/postgres/000100_add_draft_priority_column.down.sql b/db/migrations/postgres/000100_add_draft_priority_column.down.sql new file mode 100644 index 0000000000..db071074d2 --- /dev/null +++ b/db/migrations/postgres/000100_add_draft_priority_column.down.sql @@ -0,0 +1 @@ +ALTER TABLE drafts DROP COLUMN IF EXISTS priority; diff --git a/db/migrations/postgres/000100_add_draft_priority_column.up.sql b/db/migrations/postgres/000100_add_draft_priority_column.up.sql new file mode 100644 index 0000000000..4ef6f9e991 --- /dev/null +++ b/db/migrations/postgres/000100_add_draft_priority_column.up.sql @@ -0,0 +1 @@ +ALTER TABLE drafts ADD COLUMN IF NOT EXISTS priority text; diff --git a/docker-compose.yaml b/docker-compose.yaml index 61fb04b5a5..d83054b5c6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -103,11 +103,13 @@ services: dockerfile: ./build/Dockerfile.buildenv working_dir: '/home/mattermost-server' environment: + - "MM_SQLSETTINGS_DRIVERNAME=postgres" - "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10" - "MM_NO_DOCKER=true" - "RUN_SERVER_IN_BACKGROUND=false" - "MM_CLUSTERSETTINGS_ENABLE=true" - "MM_CLUSTERSETTINGS_CLUSTERNAME=mm_dev_cluster" + - "MM_FEATUREFLAGS_BoardsProduct=true" networks: - mm-test depends_on: @@ -116,6 +118,7 @@ services: - './:/home/mattermost-server' - './../mattermost-webapp:/home/mattermost-webapp' - './../enterprise:/home/enterprise' + - './../focalboard:/home/focalboard' restart: on-failure healthcheck: test: ["CMD", "curl", "-f", "http://leader:8065/api/v4/system/ping"] @@ -139,11 +142,13 @@ services: dockerfile: ./build/Dockerfile.buildenv working_dir: '/home/mattermost-server' environment: + - "MM_SQLSETTINGS_DRIVERNAME=postgres" - "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10" - "MM_NO_DOCKER=true" - "RUN_SERVER_IN_BACKGROUND=false" - "MM_CLUSTERSETTINGS_ENABLE=true" - "MM_CLUSTERSETTINGS_CLUSTERNAME=mm_dev_cluster" + - "MM_FEATUREFLAGS_BoardsProduct=true" networks: - mm-test depends_on: @@ -152,6 +157,7 @@ services: - './:/home/mattermost-server' - './../mattermost-webapp:/home/mattermost-webapp' - './../enterprise:/home/enterprise' + - './../focalboard:/home/focalboard' healthcheck: test: ["CMD", "curl", "-f", "http://follower:8065/api/v4/system/ping"] interval: 5s @@ -175,11 +181,13 @@ services: dockerfile: ./build/Dockerfile.buildenv working_dir: '/home/mattermost-server' environment: + - "MM_SQLSETTINGS_DRIVERNAME=postgres" - "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10" - "MM_NO_DOCKER=true" - "RUN_SERVER_IN_BACKGROUND=false" - "MM_CLUSTERSETTINGS_ENABLE=true" - "MM_CLUSTERSETTINGS_CLUSTERNAME=mm_dev_cluster" + - "MM_FEATUREFLAGS_BoardsProduct=true" networks: - mm-test depends_on: @@ -188,6 +196,7 @@ services: - './:/home/mattermost-server' - './../mattermost-webapp:/home/mattermost-webapp' - './../enterprise:/home/enterprise' + - './../focalboard:/home/focalboard' healthcheck: test: ["CMD", "curl", "-f", "http://follower2:8065/api/v4/system/ping"] interval: 5s diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 26e7f3c5ab..46f5783225 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -8,9 +8,10 @@ import ( ) type CloudInterface interface { + GetCloudProduct(userID string, productID string) (*model.Product, error) GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) + GetSelfHostedProducts(userID string) ([]*model.Product, error) GetCloudLimits(userID string) (*model.ProductLimits, error) - UpdateSubscriptionFromHook(*model.ProductLimits, *model.Subscription) error CreateCustomerPayment(userID string) (*model.StripeSetupIntent, error) ConfirmCustomerPayment(userID string, confirmRequest *model.ConfirmPaymentMethodRequest) error @@ -31,4 +32,8 @@ type CloudInterface interface { // GetLicenseRenewalStatus checks on the portal whether it is possible to use token to renew a license GetLicenseRenewalStatus(userID, token string) error InvalidateCaches() error + + BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) + CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) + HandleLicenseChange() error } diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index 1e5ba2b0a1..141823c834 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -14,6 +14,29 @@ type CloudInterface struct { mock.Mock } +// BootstrapSelfHostedSignup provides a mock function with given fields: req +func (_m *CloudInterface) BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) { + ret := _m.Called(req) + + var r0 *model.BootstrapSelfHostedSignupResponse + if rf, ok := ret.Get(0).(func(model.BootstrapSelfHostedSignupRequest) *model.BootstrapSelfHostedSignupResponse); ok { + r0 = rf(req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.BootstrapSelfHostedSignupResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(model.BootstrapSelfHostedSignupRequest) error); ok { + r1 = rf(req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ChangeSubscription provides a mock function with given fields: userID, subscriptionID, subscriptionChange func (_m *CloudInterface) ChangeSubscription(userID string, subscriptionID string, subscriptionChange *model.SubscriptionChange) (*model.Subscription, error) { ret := _m.Called(userID, subscriptionID, subscriptionChange) @@ -74,6 +97,29 @@ func (_m *CloudInterface) CreateCustomerPayment(userID string) (*model.StripeSet return r0, r1 } +// CreateOrUpdateSubscriptionHistoryEvent provides a mock function with given fields: userID, userCount +func (_m *CloudInterface) CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) { + ret := _m.Called(userID, userCount) + + var r0 *model.SubscriptionHistory + if rf, ok := ret.Get(0).(func(string, int) *model.SubscriptionHistory); ok { + r0 = rf(userID, userCount) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SubscriptionHistory) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int) error); ok { + r1 = rf(userID, userCount) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCloudCustomer provides a mock function with given fields: userID func (_m *CloudInterface) GetCloudCustomer(userID string) (*model.CloudCustomer, error) { ret := _m.Called(userID) @@ -120,6 +166,29 @@ func (_m *CloudInterface) GetCloudLimits(userID string) (*model.ProductLimits, e return r0, r1 } +// GetCloudProduct provides a mock function with given fields: userID, productID +func (_m *CloudInterface) GetCloudProduct(userID string, productID string) (*model.Product, error) { + ret := _m.Called(userID, productID) + + var r0 *model.Product + if rf, ok := ret.Get(0).(func(string, string) *model.Product); ok { + r0 = rf(userID, productID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Product) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(userID, productID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCloudProducts provides a mock function with given fields: userID, includeLegacyProducts func (_m *CloudInterface) GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) { ret := _m.Called(userID, includeLegacyProducts) @@ -210,6 +279,29 @@ func (_m *CloudInterface) GetLicenseRenewalStatus(userID string, token string) e return r0 } +// GetSelfHostedProducts provides a mock function with given fields: userID +func (_m *CloudInterface) GetSelfHostedProducts(userID string) ([]*model.Product, error) { + ret := _m.Called(userID) + + var r0 []*model.Product + if rf, ok := ret.Get(0).(func(string) []*model.Product); ok { + r0 = rf(userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Product) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetSubscription provides a mock function with given fields: userID func (_m *CloudInterface) GetSubscription(userID string) (*model.Subscription, error) { ret := _m.Called(userID) @@ -233,6 +325,20 @@ func (_m *CloudInterface) GetSubscription(userID string) (*model.Subscription, e return r0, r1 } +// HandleLicenseChange provides a mock function with given fields: +func (_m *CloudInterface) HandleLicenseChange() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + // InvalidateCaches provides a mock function with given fields: func (_m *CloudInterface) InvalidateCaches() error { ret := _m.Called() @@ -316,20 +422,6 @@ func (_m *CloudInterface) UpdateCloudCustomerAddress(userID string, address *mod return r0, r1 } -// UpdateSubscriptionFromHook provides a mock function with given fields: _a0, _a1 -func (_m *CloudInterface) UpdateSubscriptionFromHook(_a0 *model.ProductLimits, _a1 *model.Subscription) error { - ret := _m.Called(_a0, _a1) - - var r0 error - if rf, ok := ret.Get(0).(func(*model.ProductLimits, *model.Subscription) error); ok { - r0 = rf(_a0, _a1) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // ValidateBusinessEmail provides a mock function with given fields: userID, email func (_m *CloudInterface) ValidateBusinessEmail(userID string, email string) error { ret := _m.Called(userID, email) diff --git a/go.mod b/go.mod index ad8a4fbb28..c157155e51 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( golang.org/x/tools v0.3.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -178,7 +179,6 @@ require ( gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.1.1 // indirect modernc.org/cc/v3 v3.36.0 // indirect modernc.org/ccgo/v3 v3.16.6 // indirect diff --git a/i18n/bg.json b/i18n/bg.json index 4031bf4bf6..1e2ac3b534 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -3667,10 +3667,6 @@ "id": "app.upload.get.app_error", "translation": "Не можа да се вземе качването." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Не може да се качи файл. Файлът е прекалено голям." - }, { "id": "app.upload.create.save.app_error", "translation": "Не можа да се запише качването." diff --git a/i18n/de.json b/i18n/de.json index 4c409f0669..6b8f7e7db0 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -4789,7 +4789,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -6603,10 +6603,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} Dimensionen ({{.Width}} mal {{.Height}} Pixel) überschreiten die Limits." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Konnte Datei nicht hochladen. Datei ist zu groß." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Kann nicht in gelöschten Kanal senden." @@ -9194,18 +9190,10 @@ "id": "api.file.cloud_upload.app_error", "translation": "Hochladen über mmctl zu einer Cloud Instanz wird nicht unterstützt. Bitte prüfe die Dokumentation: https://docs.mattermost.com/manage/cloud-data-export.html." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Fehler bei der Aktualisierung des Abonnements über Webhook." - }, { "id": "app.recent_searches.app_error", "translation": "Fehler beim Holen der letzten Suchen" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Du hast das Maximum von {{.NumIntegrations}} aktivierten Integrationen erreicht. Um Integrationen ohne Beschränkungen zu installieren, upgrade auf ein bezahltes Abonnements." - }, { "id": "app.teams.analytics_teams_count.app_error", "translation": "Kann Team-Zähler nicht abfragen" @@ -9308,7 +9296,7 @@ }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "Dein Arbeitsbereich wird auf Cloud Free herabgestuft. Deins {{.Plan}}-Funktionen werden gesperrt und einige deiner Arbeitsbereichsdaten können archiviert werden, bis dein ausstehender Betrag vollständig beglichen ist." + "translation": "Dein Arbeitsbereich wird auf Cloud Free herabgestuft. Deine {{.Plan}}-Funktionen werden gesperrt und einige deiner Arbeitsbereichsdaten können archiviert werden, bis dein ausstehender Betrag vollständig beglichen ist." }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9569,5 +9557,13 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Collection Typ existiert schon." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "Kein LDAP-Benutzer" + }, + { + "id": "api.admin.syncables_error", + "translation": "Fehlschlag beim Hinzufügen des Benutzers zu Gruppen-Teams und -Kanälen" } ] diff --git a/i18n/en.json b/i18n/en.json index b1c57495a7..d1399190f6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -59,6 +59,18 @@ "id": "September", "translation": "September" }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "You cannot remove an acknowledgment in an archived channel." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "You cannot delete an acknowledgment after 5min have passed." + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "You cannot acknowledgment in an archived channel." + }, { "id": "api.admin.add_certificate.array.app_error", "translation": "No file under 'certificate' in request." @@ -487,10 +499,6 @@ "id": "api.cloud.request_error", "translation": "Error processing request to CWS." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Error updating subscription from webhook." - }, { "id": "api.cloud.teams_limit_reached.create", "translation": "Unable to create team because teams limit has been reached" @@ -1634,6 +1642,14 @@ "id": "api.custom_status.set_custom_statuses.update.app_error", "translation": "Failed to update the custom status. Please add either emoji or custom text status or both." }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Can not save draft to deleted channel" + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Drafts feature is disabled." + }, { "id": "api.elasticsearch.test_elasticsearch_settings_nil.app_error", "translation": "Elasticsearch settings has unset values." @@ -3148,12 +3164,16 @@ "translation": "Mattermost Upgrade Confirmation" }, { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", + "id": "api.templates.cloud_upgrade_confirmation.title", + "translation": "You are now upgraded!" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", "translation": "Your {{.WorkspaceName}} workspace has now been upgraded. You'll be billed from {{.Date}}" }, { - "id": "api.templates.cloud_upgrade_confirmation.title", - "translation": "You are now upgraded!" + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "Your {{.WorkspaceName}} workspace has now been upgraded." }, { "id": "api.templates.cloud_welcome_email.add_apps_info", @@ -3223,6 +3243,10 @@ "id": "api.templates.cloud_welcome_email.title", "translation": "Your workspace is ready to go!" }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "View your invoice" + }, { "id": "api.templates.copyright", "translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" @@ -3249,7 +3273,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Payment is overdue for your Mattermost {{.Plan}}." + "translation": "Payment is overdue for your Mattermost {{.Plan}}" }, { "id": "api.templates.delinquency_14.subtitle1", @@ -3297,7 +3321,7 @@ }, { "id": "api.templates.delinquency_30.subtitle2", - "translation": "if no action is taken, your workspace will be downgraded and the following data may be archived:" + "translation": "If no action is taken, your workspace will be downgraded and the following data may be archived:" }, { "id": "api.templates.delinquency_30.title", @@ -3313,7 +3337,7 @@ }, { "id": "api.templates.delinquency_45.subtitle1", - "translation": "We've been unable to collect payment for outstanding invoices dated {{.DelinquencyDate}}. Your workspace is at risk of being downgraded." + "translation": "We've been unable to collect payment for outstanding invoices since {{.DelinquencyDate}}. Your workspace is at risk of being downgraded." }, { "id": "api.templates.delinquency_45.subtitle2", @@ -3361,7 +3385,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "We couldn't process your most recent payment" + "translation": "We couldn't process your most recent payment." }, { "id": "api.templates.delinquency_7.subtitle2", @@ -3385,7 +3409,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "This is a final reminder that we haven’t received payment for your Mattermost Cloud workspace since {{.DelinquencyDate}}" + "translation": "This is a final reminder that we haven’t received payment for your Mattermost Cloud workspace since {{.DelinquencyDate}}." }, { "id": "api.templates.delinquency_75.subtitle2", @@ -3911,6 +3935,10 @@ "id": "api.upgrade_to_enterprise_status.signature.app_error", "translation": "Mattermost was unable to upgrade to Enterprise Edition. The digital signature of the downloaded binary file could not be verified." }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Unable to upload file. File is too large." + }, { "id": "api.upload.get_upload.forbidden.app_error", "translation": "Failed to get upload." @@ -4103,6 +4131,10 @@ "id": "api.user.get_user_by_email.permissions.app_error", "translation": "Unable to get user by email." }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Error fetching roles during validation." + }, { "id": "api.user.invalidate_verify_email_tokens.error", "translation": "Unable to get tokens by type when invalidating email verification tokens" @@ -4459,6 +4491,22 @@ "id": "api.websocket_handler.server_busy.app_error", "translation": "Server is busy, non-critical services are temporarily unavailable." }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Unable to delete acknowledgement." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Unable to get acknowledgement." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "Unable to get acknowledgement for post." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "Unable to save acknowledgement for post." + }, { "id": "app.admin.saml.failure_decode_metadata_xml_from_idp.app_error", "translation": "Could not decode the XML metadata information received from the Identity Provider." @@ -4551,6 +4599,10 @@ "id": "app.channel.count_posts_since.app_error", "translation": "Unable to count messages since given date." }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "Unable to count urgent posts since given date." + }, { "id": "app.channel.create_channel.internal_error", "translation": "Unable to save channel." @@ -4679,6 +4731,10 @@ "id": "app.channel.get_pinnedpost_count.app_error", "translation": "Unable to get the channel pinned post count." }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "Unable to get the priority for posts" + }, { "id": "app.channel.get_private_channels.get.app_error", "translation": "Unable to get private channels." @@ -4923,6 +4979,34 @@ "id": "app.custom_group.unique_name", "translation": "group name is not unique" }, + { + "id": "app.draft.delete.app_error", + "translation": "Unable to delete the Draft." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Drafts feature is disabled." + }, + { + "id": "app.draft.get.app_error", + "translation": "Unable to get the Draft." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Unable to get user's Drafts." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Unable to get files for Draft." + }, + { + "id": "app.draft.save.app_error", + "translation": "Unable to save the Draft." + }, + { + "id": "app.draft.update.app_error", + "translation": "Unable to update the Draft." + }, { "id": "app.email.no_rate_limiter.app_error", "translation": "Rate limiter is not set up." @@ -5619,10 +5703,6 @@ "id": "app.insights.feature_disabled", "translation": "Insights feature is disabled." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "You've reached the max limit of {{.NumIntegrations}} enabled integrations. To install unlimited integrations, upgrade to one of our paid plans." - }, { "id": "app.job.download_export_results_not_enabled", "translation": "DownloadExportResults in config.json is false. Please set this to true to download the results of this job." @@ -6115,6 +6195,10 @@ "id": "app.post.update.app_error", "translation": "Unable to update the Post." }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "Unable to get postpriority for post" + }, { "id": "app.post_reminder_dm", "translation": "Hi there, here's your reminder about this message from @{{.Username}}: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" @@ -6543,10 +6627,6 @@ "id": "app.upload.create.save.app_error", "translation": "Failed to save upload." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Unable to upload file. File is too large." - }, { "id": "app.upload.get.app_error", "translation": "Failed to get upload." @@ -6923,6 +7003,14 @@ "id": "app.webhooks.update_outgoing.app_error", "translation": "Unable to update the webhook." }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "Unable to get work template categories" + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "Unable to get work templates" + }, { "id": "bleveengine.already_started.error", "translation": "Bleve is already started." @@ -7923,6 +8011,14 @@ "id": "model.access.is_valid.user_id.app_error", "translation": "Invalid user id." }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Invalid post id." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Invalid user id." + }, { "id": "model.authorize.is_valid.auth_code.app_error", "translation": "Invalid authorization code." @@ -8587,6 +8683,42 @@ "id": "model.config.is_valid.write_timeout.app_error", "translation": "Invalid value for write timeout." }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Invalid channel id." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Create at must be a valid time." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Invalid file ids." + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "Invalid message." + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Invalid priority" + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Invalid props." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Invalid root id." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "Update at must be a valid time." + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Invalid user id." + }, { "id": "model.emoji.create_at.app_error", "translation": "Create at must be a valid time." @@ -9509,7 +9641,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_os_version.windows", @@ -9574,5 +9706,25 @@ { "id": "web.incoming_webhook.user.app_error", "translation": "Couldn't find the user." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Product Teams" + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you." + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Create transparent workflows across development teams to ensure your feature development process is seamless." } ] diff --git a/i18n/en_AU.json b/i18n/en_AU.json index 64ea23c44d..169abdd703 100644 --- a/i18n/en_AU.json +++ b/i18n/en_AU.json @@ -65,7 +65,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -4103,10 +4103,6 @@ "id": "app.upload.get.app_error", "translation": "Failed to get upload." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Unable to upload file. File is too large." - }, { "id": "app.upload.create.save.app_error", "translation": "Failed to save upload." @@ -9186,10 +9182,6 @@ "id": "api.templates.server_inactivity_footer_disclaimer", "translation": "You received this one-time email because your Mattermost server was inactive for more than {{.Hours}} hours. This email was automatically generated by your Mattermost server." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Error updating subscription from webhook." - }, { "id": "app.recent_searches.app_error", "translation": "An error occurred while fetching recent searches" @@ -9198,10 +9190,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "Uploading via mmctl to a Cloud instance is not supported. Please check the documentation here: https://docs.mattermost.com/manage/cloud-data-export.html." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "You've reached the limit of {{.NumIntegrations}} enabled integrations. To install unlimited integrations, upgrade to one of the paid plans." - }, { "id": "app.usage.get_storage_usage.app_error", "translation": "Failed to get storage usage." @@ -9308,7 +9296,7 @@ }, { "id": "api.templates.delinquency_90.subtitle2", - "translation": "In addition, your data may have been archived due to Cloud Starter limitations." + "translation": "In addition, your data may have been archived due to Cloud Free limitations." }, { "id": "api.templates.delinquency_90.subtitle1", @@ -9332,11 +9320,11 @@ }, { "id": "api.templates.delinquency_75.subtitle3", - "translation": "Update your payment information now, or downgrade to Cloud Starter." + "translation": "Update your payment information now, or downgrade to Cloud Free." }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "Your workspace will be downgraded to Cloud Starter. Your {{.Plan}} features will be locked and some of your workspace data may be archived until your full outstanding balance is settled." + "translation": "Your workspace will be downgraded to Cloud Free. Your {{.Plan}} features will be locked and some of your workspace data may be archived until your full outstanding balance is settled." }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9372,7 +9360,7 @@ }, { "id": "api.templates.delinquency_60.subtitle3", - "translation": "Update your payment information now or downgrade to Cloud Starter below." + "translation": "Update your payment information now or downgrade to Cloud Free below." }, { "id": "api.templates.delinquency_60.subtitle2", @@ -9464,7 +9452,7 @@ }, { "id": "api.templates.delinquency_14.subtitle1", - "translation": "The credit card on record wasn't able to be charged. This means your workspace is at risk of being downgraded to Cloud Starter." + "translation": "The credit card on record wasn't able to be charged. This means your workspace is at risk of being downgraded to Cloud Free." }, { "id": "api.templates.delinquency_14.subject", @@ -9569,5 +9557,17 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Collection type already exists." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "not an LDAP user" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Unable to upload file. File is too large." + }, + { + "id": "api.admin.syncables_error", + "translation": "Failed to add user to group-teams and group-channels" } ] diff --git a/i18n/es.json b/i18n/es.json index 65593d7226..b3682a2730 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -4757,7 +4757,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -6751,10 +6751,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "Las dimensiones de {{.Filename}} son ({{.Width}} por {{.Height}} pixels) exceden el limite." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "No se pudo cargar el archivo. El archivo es muy grande." - }, { "id": "app.team.user_belongs_to_teams.app_error", "translation": "No se puede determinar si el usuario pertenece a la lista de equipos." @@ -8995,10 +8991,6 @@ "id": "api.custom_groups.no_remote_id", "translation": " " }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Error al actualizar la suscripción desde el webhook." - }, { "id": "app.system.get_onboarding_request.app_error", "translation": "No se pudo obtener el estado de finalización de inducción." @@ -9151,10 +9143,6 @@ "id": "api.templates.invite_team_and_channels_body.title", "translation": " " }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Has alcanzado el límite máximo de {{.NumIntegrations}} integraciones activas. Para instalar integraciones ilimitadas, actualiza a uno de unos planes de pago." - }, { "id": "model.channel.is_valid.1_or_more.app_error", "translation": "El Nombre debe tener 1 o más caracteres alfanuméricos en minúsculas." @@ -9570,5 +9558,17 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "El tipo de colección ya existe." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "no es un usuario ldap" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "No es posible subir el archivo. El archivo es muy grande." + }, + { + "id": "api.admin.syncables_error", + "translation": "Error al agregar usuario a grupo-equipos y grupo-canales" } ] diff --git a/i18n/fa.json b/i18n/fa.json index 88186a1d34..cdee577b6c 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -5359,10 +5359,6 @@ "id": "app.upload.get.app_error", "translation": "بارگیری انجام نشد." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "بارگذاری پرونده امکان پذیر نیست. پرونده خیلی بزرگ است." - }, { "id": "app.upload.create.save.app_error", "translation": "بارگذاری ذخیره نشد." diff --git a/i18n/fr.json b/i18n/fr.json index c94978c9da..2600e826fa 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -6591,10 +6591,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "La taille de {{.Filename}} ({{.Width}} sur {{.Height}} pixels) dépasse la limitée autorisée." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Impossible d'envoyer le fichier. Le fichier est trop volumineux." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Impossible d'envoyer un message dans un canal supprimé." @@ -8679,10 +8675,6 @@ "id": "api.cloud.teams_limit_reached.create", "translation": "Impossible de créer l'équipe, car la limite des équipes a été atteinte" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Erreur de mise à jour de l'abonnement à partir du point d'ancrage Web." - }, { "id": "app.user.store_is_empty.app_error", "translation": "Impossible de vérifier si le magasin de l'utilisateur est vide." @@ -8747,10 +8739,6 @@ "id": "app.job.get_all_jobs_by_type_and_status.app_error", "translation": "Impossible d'obtenir tous les travaux par type et statuts." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Vous avez atteint la limite maximale de {{.NumIntegrations}} d'intégrations activées. Pour installer un nombre illimité d'intégrations, effectuez une mise à niveau vers l'un de nos plans payants." - }, { "id": "app.insights.feature_disabled", "translation": "La fonctionnalité des aperçus est désactivée." diff --git a/i18n/hu.json b/i18n/hu.json index 930d7fd5d3..1ccbb817e8 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -4115,10 +4115,6 @@ "id": "app.upload.get.app_error", "translation": "Nem sikerült lekérni a feltöltést." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Nem sikerült feltölteni a fájlt. A fájl túl nagy." - }, { "id": "app.upload.create.save.app_error", "translation": "Nem sikerült elmenteni a feltöltést." @@ -9183,10 +9179,6 @@ "id": "api.templates.server_inactivity_footer_disclaimer", "translation": "Ezt az egyszeri e-mailt azért kapta, mert a Mattermost szervere több mint {{.Hours}} órán keresztül inaktív volt. Ezt az e-mailt a Mattermost szervere automatikusan generálta." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "HIba történt a webhorogról az előfizetés frissítése közben." - }, { "id": "api.file.cloud_upload.app_error", "translation": "Az mmctl segítségével történő feltöltés egy felhő alapú példányra nem támogatott. Kérjük, tekintse meg a dokumentációt itt: https://docs.mattermost.com/manage/cloud-data-export.html." @@ -9203,10 +9195,6 @@ "id": "app.teams.analytics_teams_count.app_error", "translation": "Nem kérdezhető le a csapatok száma" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Elérte az engedélyezett integrációk maximális számát {{.NumIntegrations}}. Korlátlan számú integráció telepítéséhez frissítsen valamelyik fizetős csomagunkra." - }, { "id": "app.post.analytics_teams_count.app_error", "translation": "Nem kérdezhető le a csapat használtság" diff --git a/i18n/it.json b/i18n/it.json index f9c6ae0911..1773114ab7 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -6803,10 +6803,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}}: le dimensioni ({{.Width}} per {{.Height}} pixel) superano i limiti." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Impossibile caricare il file. Il file è troppo grande." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Impossibile inviare ad un canale eliminato." @@ -8383,10 +8379,6 @@ "id": "app.notification.body.group.title", "translation": " " }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": " " - }, { "id": "app.user.missing_account.const", "translation": " " @@ -8711,10 +8703,6 @@ "id": "api.command_share.uninvite_remote.help", "translation": " " }, - { - "id": "api.cloud.subscription.update_error", - "translation": " " - }, { "id": "model.member.is_valid.channel.app_error", "translation": " " diff --git a/i18n/ja.json b/i18n/ja.json index ff427d87e3..cc82a4b220 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -6635,10 +6635,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}}の大きさ({{.Width}} x {{.Height}} ピクセル)が制限を超えています。" }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "画像ファイルをアップロードできません。ファイルが大き過ぎます。" - }, { "id": "app.team.user_belongs_to_teams.app_error", "translation": "ユーザーがチームリストに所属するかどうか確認できませんでした。" @@ -9191,10 +9187,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "クラウドインスタンスへのmmctlによるアップロードはサポートされていません。こちらのドキュメントを確認してください:https://docs.mattermost.com/manage/cloud-data-export.html。" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "ウェブフックからサブスクリプションを更新する際にエラーが発生しました。" - }, { "id": "app.install_integration.reached_max_limit.error", "translation": "有効な統合機能数の上限 {{.NumIntegrations}} に達しました。無制限に統合機能をインストールするには、いずれかの有料プランにアップグレードしてください。" @@ -9281,7 +9273,7 @@ }, { "id": "app.cloud.get_cloud_products.app_error", - "translation": "クラウド製品を取得できませんでした" + "translation": "クラウドプロダクトを取得できませんでした" }, { "id": "api.templates.delinquency_90.title", @@ -9293,7 +9285,7 @@ }, { "id": "api.templates.delinquency_90.subtitle2", - "translation": "加えて、Cloud Starterの制限により、あなたのデータがアーカイブされている可能性があります。" + "translation": "加えて、Cloud Freeの制限により、あなたのデータがアーカイブされている可能性があります。" }, { "id": "api.templates.delinquency_90.subtitle1", @@ -9313,11 +9305,11 @@ }, { "id": "api.templates.delinquency_75.subtitle3", - "translation": "今すぐ支払い情報を更新するか、Cloud Starterにダウングレードしてください。" + "translation": "今すぐ支払い情報を更新するか、Cloud Freeにダウングレードしてください。" }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "ワークスペースは Cloud Starter にダウングレードされます。{{.Plan}}の機能はロックされ、ワークスペースのデータの一部は未払い金の全額が支払われるまでアーカイブされる場合があります。" + "translation": "ワークスペースは Cloud Free にダウングレードされます。{{.Plan}}の機能はロックされ、ワークスペースのデータの一部は未払い金の全額が支払われるまでアーカイブされる場合があります。" }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9349,7 +9341,7 @@ }, { "id": "api.templates.delinquency_60.subtitle3", - "translation": "今すぐ支払い情報を更新するか、以下よりCloud Starterにダウングレードしてください。" + "translation": "今すぐ支払い情報を更新するか、以下よりCloud Freeにダウングレードしてください。" }, { "id": "api.templates.delinquency_60.subtitle2", @@ -9429,7 +9421,7 @@ }, { "id": "api.templates.delinquency_14.subtitle1", - "translation": "登録されているクレジットカードに課金することができませんでした。これは、お客様のワークスペースがCloud Starterにダウングレードされる危険性があることを意味します。" + "translation": "登録されているクレジットカードに課金することができませんでした。これは、お客様のワークスペースがCloud Freeにダウングレードされる危険性があることを意味します。" }, { "id": "api.templates.delinquency_14.subject", @@ -9554,5 +9546,13 @@ { "id": "api.team.invite_guests_to_channels.disabled.error", "translation": "ゲストアカウントは無効化されています" + }, + { + "id": "app.collection.add_topic.exists.app_error", + "translation": "Topic type はすでに存在しています。" + }, + { + "id": "app.collection.add_collection.exists.app_error", + "translation": "Collection typeはすでに存在しています。" } ] diff --git a/i18n/ko.json b/i18n/ko.json index 7d4b36ccb3..82c9a20ffd 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -6607,10 +6607,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}}의 해상도({{.Width}} x {{.Height}} 픽셀)가 제한 사항을 초과했습니다." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "파일을 업로드할 수 없습니다. 파일 크기가 너무 큽니다." - }, { "id": "app.team.user_belongs_to_teams.app_error", "translation": "사용자가 팀 목록에 속하는지 확인할 수 없습니다." @@ -7895,10 +7891,6 @@ "id": "api.custom_groups.license_error", "translation": "사용자 정의 그룹을 위한 라이선스가 없습니다" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "웹훅에서 구독을 업데이트하는 동안 오류가 발생했습니다." - }, { "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", "translation": "이미 관리자에게 통지됨" @@ -7962,5 +7954,9 @@ { "id": "api.command_marketplace.name", "translation": "마켓플레이스" + }, + { + "id": "sharedchannel.cannot_deliver_post", + "translation": "{{.Remote}} 원격 사이트가 오프라인이기 때문에 하나 또는 그 이상의 포스트가 전송되지 않았습니다. 포스트는 해당 사이트가 온라인일 때 전송될 것입니다." } ] diff --git a/i18n/nl.json b/i18n/nl.json index dedae87b78..2191b1f2bc 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -4333,11 +4333,11 @@ }, { "id": "model.post.is_valid.filenames.app_error", - "translation": "Ongeldige bestandsnaamen" + "translation": "Ongeldige bestandsnaamen." }, { "id": "model.post.is_valid.hashtags.app_error", - "translation": "Ongeldige hashtags" + "translation": "Ongeldige hashtags." }, { "id": "model.post.is_valid.id.app_error", @@ -7203,10 +7203,6 @@ "id": "app.upload.get_for_user.app_error", "translation": "Fout bij het ophalen van de uploads van de gebruiker." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Fout bij het uploaden van bestanden. Bestand is te groot." - }, { "id": "app.upload.create.incorrect_channel_id.app_error", "translation": "Kan niet uploaden naar het opgegeven kanaal." @@ -9190,10 +9186,6 @@ "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Bevestiging Mattermost upgrade" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Fout met bijwerken inschrijving vanuit webhook." - }, { "id": "app.recent_searches.app_error", "translation": "Fout bij ophalen van de recente zoekopdrachten" @@ -9218,10 +9210,6 @@ "id": "app.teams.analytics_teams_count.app_error", "translation": "Niet gelukt om het aan aantal teams op te halen" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Je bereikte de maximumlimiet van {{.NumIntegrations}} ingeschakelde integraties. Om onbeperkte integraties te installeren, upgrade naar een van onze betaalde plannen." - }, { "id": "api.cloud.teams_limit_reached.restore", "translation": "Kan het team niet herstellen omdat de teamlimiet bereikt is" @@ -9328,7 +9316,7 @@ }, { "id": "api.templates.delinquency_60.subtitle3", - "translation": "Werk nu jouw betalingsgegevens bij of downgrade naar Cloud Starter hieronder." + "translation": "Werk nu jouw betalingsgegevens bij of downgrade naar Cloud Free hieronder." }, { "id": "api.templates.delinquency_60.subtitle2", @@ -9424,7 +9412,7 @@ }, { "id": "api.templates.delinquency_14.subtitle1", - "translation": "We waren niet in staat om de kredietkaart die we in ons bestand hebben in rekening te brengen. Dit betekent dat jouw werkruimte het risico loopt te worden gedegradeerd naar Cloud Starter." + "translation": "We waren niet in staat om de kredietkaart die we in ons bestand hebben in rekening te brengen. Dit betekent dat jouw werkruimte het risico loopt te worden gedegradeerd naar Cloud Free." }, { "id": "api.templates.delinquency_90.subject", @@ -9444,11 +9432,11 @@ }, { "id": "api.templates.delinquency_75.subtitle3", - "translation": "Werk nu jouw betalingsgegevens bij, of downgrade naar Cloud Starter." + "translation": "Werk nu jouw betalingsgegevens bij, of downgrade naar Cloud Free." }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "Jouw werkruimte zal worden gedowngrade naar Cloud Starter. Jouw {{.Plan}} functies zullen worden vergrendeld en sommige van jouw werkruimtegegevens kunnen worden gearchiveerd totdat je jouw volledige uitstaande saldo hebt voldaan." + "translation": "Jouw werkruimte zal worden gedowngrade naar Cloud Free. Jouw {{.Plan}} functies zullen worden vergrendeld en sommige van jouw werkruimtegegevens kunnen worden gearchiveerd totdat je jouw volledige uitstaande saldo hebt voldaan." }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9472,7 +9460,7 @@ }, { "id": "api.templates.delinquency_90.subtitle2", - "translation": "Bovendien kunnen jouw gegevens gearchiveerd worden als gevolg van de beperkingen bij Cloud Starter." + "translation": "Bovendien kunnen jouw gegevens gearchiveerd worden als gevolg van de beperkingen bij Cloud Free." }, { "id": "api.templates.delinquency_90.subtitle1", @@ -9569,5 +9557,13 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Collectietype bestaat al." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "geen ldap-gebruiker" + }, + { + "id": "api.admin.syncables_error", + "translation": "kon gebruiker niet toevoegen aan groep-teams en groep-kanalen" } ] diff --git a/i18n/pl.json b/i18n/pl.json index 64c2419ae0..1a7d81b48a 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -4749,7 +4749,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -6599,10 +6599,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} wymiary ({{.width}} według {{.Height}} pikseli) przekraczają limity." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Nie udało się wgrać obrazu. Plik jest zbyt duży." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Nie można utworzyć wiadomości na usuniętym kanale." @@ -9191,10 +9187,6 @@ "id": "api.templates.server_inactivity_footer_disclaimer", "translation": "Otrzymałeś tę jednorazową wiadomość e-mail, ponieważ Twój serwer Mattermost był nieaktywny przez ponad {{.Hours}} godzin. Ta wiadomość e-mail została automatycznie wygenerowana przez serwer Mattermost." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Błąd aktualizacji subskrypcji z webhooka." - }, { "id": "api.file.cloud_upload.app_error", "translation": "Przesyłanie danych do instancji Chmury za pomocą mmctl nie jest obsługiwane. Proszę sprawdzić dokumentację tutaj: https://docs.mattermost.com/manage/cloud-data-export.html." @@ -9203,10 +9195,6 @@ "id": "app.recent_searches.app_error", "translation": "Błąd pobierania ostatnich wyszukiwań" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Osiągnąłeś maksymalny limit {{.NumIntegrations}} włączonych integracji. Aby zainstalować nieograniczoną liczbę integracji, uaktualnij do jednego z naszych płatnych planów." - }, { "id": "app.teams.analytics_teams_count.app_error", "translation": "Nie można uzyskać liczby zespołów" @@ -9570,5 +9558,17 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Typ kolekcji już istnieje." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "nie jest użytkownikiem ldap" + }, + { + "id": "api.admin.syncables_error", + "translation": "nie udało się dodać użytkownika do group-teams i group-channels" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Nie można przesłać pliku. Plik jest zbyt duży." } ] diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index 2873fe7a1d..134b851682 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -6911,10 +6911,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} dimensão ({{.Width}} por {{.Height}} pixels) excede os limites." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Não foi possível enviar o arquivo. Arquivo é muito grande." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Não é possível publicar em um canal excluído." @@ -8627,10 +8623,6 @@ "id": "api.cloud.teams_limit_reached.create", "translation": "Não foi possível criar equipe porque o limite de equipes foi atingido" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Erro ao atualizar a assinatura do webhook." - }, { "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", "translation": "Administrador já notificado" diff --git a/i18n/ro.json b/i18n/ro.json index 6c9fe29085..f2686f070c 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -6699,10 +6699,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} dimensiuni ({{.Width}} de {{.Height}} pixeli) depășesc limitele." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Imposibil de încărcat fișierul. Fișierul este prea mare." - }, { "id": "app.team.user_belongs_to_teams.app_error", "translation": "Nu se poate stabili dacă utilizatorul aparține unei liste de echipe." diff --git a/i18n/ru.json b/i18n/ru.json index a04c03a356..ce6fdce143 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -5573,7 +5573,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.no_longer_support_version", @@ -6975,10 +6975,6 @@ "id": "app.upload.get.app_error", "translation": "Не удалось получить загрузки." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Невозможно загрузить файл. Он слишком большой." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Невозможно создать пост в удаленном канале." @@ -9243,10 +9239,6 @@ "id": "api.cloud.teams_limit_reached.create", "translation": "Невозможно создать команду, потому что достигнут лимит команд" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Ошибка обновления подписки из webhook." - }, { "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", "translation": "Администратор уже уведомлен" @@ -9570,5 +9562,17 @@ { "id": "api.templates.delinquency_60.downgrade_to_free", "translation": "Понижение статуса до Cloud Free" + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "не является пользователем ldap" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Невозможно загрузить файл. Он слишком большой." + }, + { + "id": "api.admin.syncables_error", + "translation": "не удалось добавить пользователя в group-teams и group-channels" } ] diff --git a/i18n/sv.json b/i18n/sv.json index 04d40500d7..1a23d896c6 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -2123,10 +2123,6 @@ "id": "app.upload.get.app_error", "translation": "Kunde inte ta fram den uppladdade flen." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Kunde inte ladda upp. Filen är för stor." - }, { "id": "app.upload.create.save.app_error", "translation": "Misslyckades att spara det uppladdade." @@ -9190,10 +9186,6 @@ "id": "api.elasticsearch.test_elasticsearch_settings_nil.app_error", "translation": "Elasticsearch-inställningarna har odefinierade värden." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Fel vid uppdatering av prenumeration från webhook." - }, { "id": "app.recent_searches.app_error", "translation": "Fel när senaste sökningar skulle hämtas" @@ -9202,10 +9194,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "Uppladdning via mmctl till en molninstans stöds inte. Se dokumentationen här: https://docs.mattermost.com/manage/cloud-data-export.html." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Du har nått maxgränsen {{.NumIntegrations}} aktiva integrationer. Om du vill installera obegränsat antal integrationer kan du uppgradera till en av våra betal-abonnemang." - }, { "id": "app.usage.get_storage_usage.app_error", "translation": "Det gick inte att få fram lagringsvolym." @@ -9364,7 +9352,7 @@ }, { "id": "api.templates.delinquency_14.subtitle1", - "translation": "Vi kunde inte debitera det kreditkort som vi har registrerat. Detta innebär att din arbetsyta riskerar att nedgraderas till Cloud Starter." + "translation": "Vi kunde inte debitera det kreditkort som vi har registrerat. Detta innebär att din arbetsyta riskerar att nedgraderas till Cloud Free." }, { "id": "api.templates.delinquency_14.subject", @@ -9428,7 +9416,7 @@ }, { "id": "api.templates.delinquency_90.subtitle2", - "translation": "Dessutom kan dina data ha arkiverats på grund av begränsningar i Cloud Starter." + "translation": "Dessutom kan dina data ha arkiverats på grund av begränsningar i Cloud Free." }, { "id": "api.templates.delinquency_90.subtitle1", @@ -9452,11 +9440,11 @@ }, { "id": "api.templates.delinquency_75.subtitle3", - "translation": "Uppdatera din betalningsinformation nu, eller nedgradera till Cloud Starter." + "translation": "Uppdatera din betalningsinformation nu, eller nedgradera till Cloud Free." }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "Din arbetsplats kommer att nedgraderas till Cloud Starter. Dina {{.Plan}}-funktioner kommer att spärras och delar av dina arbetsytedata kan komma att arkiveras tills hela ditt utestående belopp är betalt." + "translation": "Din arbetsplats kommer att nedgraderas till Cloud Free. Dina {{.Plan}}-funktioner kommer att spärras och delar av dina arbetsytedata kan komma att arkiveras tills hela ditt utestående belopp är betalt." }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9496,7 +9484,7 @@ }, { "id": "api.templates.delinquency_60.subtitle3", - "translation": "Uppdatera din betalningsinformation nu eller nedgradera till Cloud Starter nedan." + "translation": "Uppdatera din betalningsinformation nu eller nedgradera till Cloud Free nedan." }, { "id": "api.templates.delinquency_60.subtitle2", diff --git a/i18n/tr.json b/i18n/tr.json index 680520e2fd..82331240fc 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -6783,10 +6783,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} boyutları ({{.Width}} x {{.Height}} piksel) sınırını aşıyor." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Dosya yüklenemedi. Dosya çok büyük." - }, { "id": "app.team.get_by_scheme.app_error", "translation": "Belirtilen şemanın uygulanabileceği kanallar alınamadı." @@ -9190,10 +9186,6 @@ "id": "api.elasticsearch.test_elasticsearch_settings_nil.app_error", "translation": "Elasticsearch yapılandırmasında ayarlanmamış değerler var." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Abonelik web bağlantısından güncellenirken sorun çıktı." - }, { "id": "app.recent_searches.app_error", "translation": "Son aramalar alınırken sorun çıktı" @@ -9202,10 +9194,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "Bir Bulut kopyasına mmctl ile yükleme desteklenmiyor. Lütfen şu makaleye bakın: https://docs.mattermost.com/manage/cloud-data-export.html." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Kullanabileceğiniz en fazla {{.NumIntegrations}} bütünleştirme sınırına ulaştınız. Sınırsız bütünleştirme için ücretli tarifelerimizden birine geçin." - }, { "id": "app.teams.analytics_teams_count.app_error", "translation": "Takım sayısı alınamadı" diff --git a/i18n/uk.json b/i18n/uk.json index c05c7895b3..cbba647d9d 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -6583,10 +6583,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} dimensions ({{.Width}} by {{.Height}} pixels) exceed the limits." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Неможливо завантажити файл. Він занадто великий." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Неможливо розмістити публікацію на видаленому каналі." diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 9d3b1ef5fe..a04ead8fd9 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -6635,10 +6635,6 @@ "id": "app.upload.get.app_error", "translation": "获取上传失败。" }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "无法上传文件。文件过大。" - }, { "id": "app.upload.create.incorrect_channel_id.app_error", "translation": "无法上传到指定的频道。" @@ -9203,10 +9199,6 @@ "id": "api.cloud.teams_limit_reached.create", "translation": "无法创建团队,因为已达到团队限制" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "从 webhook 更新订阅时出错。" - }, { "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", "translation": "已通知管理员" @@ -9231,10 +9223,6 @@ "id": "app.post_reminder_dm", "translation": "您好,这是您关于此消息的提醒 @{{.Username}}: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "您已达到启用 {{.NumIntegrations}} 集成的最大限制。 要安装无限集成,请升级到我们的付费计划之一。" - }, { "id": "app.usage.get_storage_usage.app_error", "translation": "无法获取存储使用情况。" diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index 11b8fcb9ef..e1a171691e 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -6771,10 +6771,6 @@ "id": "app.upload.get.app_error", "translation": "無法取得上傳檔。" }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "無法上傳檔案。檔案過大。" - }, { "id": "app.upload.create.incorrect_channel_id.app_error", "translation": "無法上傳至指定頻道。" @@ -7271,10 +7267,6 @@ "id": "api.channel.patch_channel_moderations.cache_invalidation.error", "translation": "錯誤 無效快取" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "從 Webhook 更新訂閱時發生錯誤。" - }, { "id": "api.cloud.request_error", "translation": "CWS 處理請求錯誤。" diff --git a/model/channel_member.go b/model/channel_member.go index d79695ada5..dbe0014eac 100644 --- a/model/channel_member.go +++ b/model/channel_member.go @@ -22,60 +22,64 @@ const ( ) type ChannelUnread struct { - TeamId string `json:"team_id"` - ChannelId string `json:"channel_id"` - MsgCount int64 `json:"msg_count"` - MentionCount int64 `json:"mention_count"` - MentionCountRoot int64 `json:"mention_count_root"` - MsgCountRoot int64 `json:"msg_count_root"` - NotifyProps StringMap `json:"-"` + TeamId string `json:"team_id"` + ChannelId string `json:"channel_id"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + UrgentMentionCount int64 `json:"urgent_mention_count"` + MsgCountRoot int64 `json:"msg_count_root"` + NotifyProps StringMap `json:"-"` } type ChannelUnreadAt struct { - TeamId string `json:"team_id"` - UserId string `json:"user_id"` - ChannelId string `json:"channel_id"` - MsgCount int64 `json:"msg_count"` - MentionCount int64 `json:"mention_count"` - MentionCountRoot int64 `json:"mention_count_root"` - MsgCountRoot int64 `json:"msg_count_root"` - LastViewedAt int64 `json:"last_viewed_at"` - NotifyProps StringMap `json:"-"` + TeamId string `json:"team_id"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + UrgentMentionCount int64 `json:"urgent_mention_count"` + MsgCountRoot int64 `json:"msg_count_root"` + LastViewedAt int64 `json:"last_viewed_at"` + NotifyProps StringMap `json:"-"` } type ChannelMember struct { - ChannelId string `json:"channel_id"` - UserId string `json:"user_id"` - Roles string `json:"roles"` - LastViewedAt int64 `json:"last_viewed_at"` - MsgCount int64 `json:"msg_count"` - MentionCount int64 `json:"mention_count"` - MentionCountRoot int64 `json:"mention_count_root"` - MsgCountRoot int64 `json:"msg_count_root"` - NotifyProps StringMap `json:"notify_props"` - LastUpdateAt int64 `json:"last_update_at"` - SchemeGuest bool `json:"scheme_guest"` - SchemeUser bool `json:"scheme_user"` - SchemeAdmin bool `json:"scheme_admin"` - ExplicitRoles string `json:"explicit_roles"` + ChannelId string `json:"channel_id"` + UserId string `json:"user_id"` + Roles string `json:"roles"` + LastViewedAt int64 `json:"last_viewed_at"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + UrgentMentionCount int64 `json:"urgent_mention_count"` + MsgCountRoot int64 `json:"msg_count_root"` + NotifyProps StringMap `json:"notify_props"` + LastUpdateAt int64 `json:"last_update_at"` + SchemeGuest bool `json:"scheme_guest"` + SchemeUser bool `json:"scheme_user"` + SchemeAdmin bool `json:"scheme_admin"` + ExplicitRoles string `json:"explicit_roles"` } func (o *ChannelMember) Auditable() map[string]interface{} { return map[string]interface{}{ - "channel_id": o.ChannelId, - "user_id": o.UserId, - "roles": o.Roles, - "last_viewed_at": o.LastViewedAt, - "msg_count": o.MsgCount, - "mention_count": o.MentionCount, - "mention_count_root": o.MentionCountRoot, - "msg_count_root": o.MsgCountRoot, - "notify_props": o.NotifyProps, - "last_update_at": o.LastUpdateAt, - "scheme_guest": o.SchemeGuest, - "scheme_user": o.SchemeUser, - "scheme_admin": o.SchemeAdmin, - "explicit_roles": o.ExplicitRoles, + "channel_id": o.ChannelId, + "user_id": o.UserId, + "roles": o.Roles, + "last_viewed_at": o.LastViewedAt, + "msg_count": o.MsgCount, + "mention_count": o.MentionCount, + "mention_count_root": o.MentionCountRoot, + "urgent_mention_count": o.UrgentMentionCount, + "msg_count_root": o.MsgCountRoot, + "notify_props": o.NotifyProps, + "last_update_at": o.LastUpdateAt, + "scheme_guest": o.SchemeGuest, + "scheme_user": o.SchemeUser, + "scheme_admin": o.SchemeAdmin, + "explicit_roles": o.ExplicitRoles, } } @@ -100,6 +104,10 @@ func (o *ChannelMember) MentionCountRoot_() float64 { return float64(o.MentionCountRoot) } +func (o *ChannelMember) UrgentMentionCount_() float64 { + return float64(o.UrgentMentionCount) +} + func (o *ChannelMember) MsgCountRoot_() float64 { return float64(o.MsgCountRoot) } diff --git a/model/client4.go b/model/client4.go index 24ffc859ca..511bc44a3f 100644 --- a/model/client4.go +++ b/model/client4.go @@ -42,6 +42,7 @@ const ( StatusFail = "FAIL" StatusUnhealthy = "UNHEALTHY" StatusRemove = "REMOVE" + ConnectionId = "Connection-Id" ClientDir = "client" @@ -325,6 +326,10 @@ func (c *Client4) cloudRoute() string { return "/cloud" } +func (c *Client4) hostedCustomerRoute() string { + return "/hosted_customer" +} + func (c *Client4) testEmailRoute() string { return "/email/test" } @@ -433,6 +438,10 @@ func (c *Client4) commandMoveRoute(commandId string) string { return fmt.Sprintf(c.commandsRoute()+"/%v/move", commandId) } +func (c *Client4) draftsRoute() string { + return "/drafts" +} + func (c *Client4) emojisRoute() string { return "/emoji" } @@ -1051,6 +1060,24 @@ func (c *Client4) GetUsers(page int, perPage int, etag string) ([]*User, *Respon return list, BuildResponse(r), nil } +// GetUsersWithChannelRoles returns a page of users on the system. Page counting starts at 0. +func (c *Client4) GetUsersWithCustomQueryParameters(page int, perPage int, queryParameters, etag string) ([]*User, *Response, error) { + query := fmt.Sprintf("?page=%v&per_page=%v&%v", page, perPage, queryParameters) + r, err := c.DoAPIGet(c.usersRoute()+query, etag) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var list []*User + if r.StatusCode == http.StatusNotModified { + return list, BuildResponse(r), nil + } + if err := json.NewDecoder(r.Body).Decode(&list); err != nil { + return nil, nil, NewAppError("GetUsers", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return list, BuildResponse(r), nil +} + // GetUsersInTeam returns a page of users on a team. Page counting starts at 0. func (c *Client4) GetUsersInTeam(teamId string, page int, perPage int, etag string) ([]*User, *Response, error) { query := fmt.Sprintf("?in_team=%v&page=%v&per_page=%v", teamId, page, perPage) @@ -5515,7 +5542,7 @@ func (c *Client4) GetGroupsAssociatedToChannelsByTeam(teamId string, opts GroupS // GetGroups retrieves Mattermost Groups func (c *Client4) GetGroups(opts GroupSearchOpts) ([]*Group, *Response, error) { path := fmt.Sprintf( - "%s?include_member_count=%v¬_associated_to_team=%v¬_associated_to_channel=%v&filter_allow_reference=%v&q=%v&filter_parent_team_permitted=%v&group_source=%v", + "%s?include_member_count=%v¬_associated_to_team=%v¬_associated_to_channel=%v&filter_allow_reference=%v&q=%v&filter_parent_team_permitted=%v&group_source=%v&include_channel_member_count=%v&include_timezones=%v", c.groupsRoute(), opts.IncludeMemberCount, opts.NotAssociatedToTeam, @@ -5524,6 +5551,8 @@ func (c *Client4) GetGroups(opts GroupSearchOpts) ([]*Group, *Response, error) { opts.Q, opts.FilterParentTeamPermitted, opts.Source, + opts.IncludeChannelMemberCount, + opts.IncludeTimezones, ) if opts.Since > 0 { path = fmt.Sprintf("%s&since=%v", path, opts.Since) @@ -6227,6 +6256,59 @@ func (c *Client4) GetChannelPoliciesForUser(userID string, offset, limit int) (* return &channels, BuildResponse(r), nil } +// Drafts Sections + +// UpsertDraft will create a new draft or update a draft if it already exists +func (c *Client4) UpsertDraft(draft *Draft) (*Draft, *Response, error) { + buf, err := json.Marshal(draft) + if err != nil { + return nil, nil, NewAppError("UpsertDraft", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(c.draftsRoute(), buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var df Draft + err = json.NewDecoder(r.Body).Decode(&df) + if err != nil { + return nil, nil, NewAppError("UpsertDraft", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &df, BuildResponse(r), err +} + +// GetDrafts will get all drafts for a user +func (c *Client4) GetDrafts(userId, teamId string) ([]*Draft, *Response, error) { + r, err := c.DoAPIGet(c.userRoute(userId)+c.teamRoute(teamId)+"/drafts", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var drafts []*Draft + err = json.NewDecoder(r.Body).Decode(&drafts) + if err != nil { + return nil, nil, NewAppError("GetDrafts", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return drafts, BuildResponse(r), nil +} + +func (c *Client4) DeleteDraft(userId, channelId, rootId string) (*Draft, *Response, error) { + r, err := c.DoAPIDelete(c.userRoute(userId) + c.channelRoute(channelId) + "/drafts") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var df *Draft + err = json.NewDecoder(r.Body).Decode(&df) + if err != nil { + return nil, BuildResponse(r), NewAppError("DeleteDraft", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return df, BuildResponse(r), nil +} + // Commands Section // CreateCommand will create a new command if the user have the right permissions. @@ -7977,6 +8059,19 @@ func (c *Client4) GetCloudProducts() ([]*Product, *Response, error) { return cloudProducts, BuildResponse(r), nil } +func (c *Client4) GetSelfHostedProducts() ([]*Product, *Response, error) { + r, err := c.DoAPIGet(c.cloudRoute()+"/products/selfhosted", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var products []*Product + json.NewDecoder(r.Body).Decode(&products) + + return products, BuildResponse(r), nil +} + func (c *Client4) GetProductLimits() (*ProductLimits, *Response, error) { r, err := c.DoAPIGet(c.cloudRoute()+"/limits", "") if err != nil { @@ -8160,6 +8255,23 @@ func (c *Client4) UpdateCloudCustomerAddress(address *Address) (*CloudCustomer, return customer, BuildResponse(r), nil } +func (c *Client4) BootstrapSelfHostedSignup(req BootstrapSelfHostedSignupRequest) (*BootstrapSelfHostedSignupResponse, *Response, error) { + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, nil, NewAppError("BootstrapSelfHostedSignup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + r, err := c.DoAPIPostBytes(c.hostedCustomerRoute()+"/bootstrap", reqBytes) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var res *BootstrapSelfHostedSignupResponse + json.NewDecoder(r.Body).Decode(&res) + + return res, BuildResponse(r), nil +} + func (c *Client4) ListImports() ([]string, *Response, error) { r, err := c.DoAPIGet(c.importsRoute(), "") if err != nil { @@ -8432,19 +8544,6 @@ func (c *Client4) GetTeamsUsage() (*TeamsUsage, *Response, error) { return usage, BuildResponse(r), err } -// GetIntegrationsUsage returns usage information on integrations, including the count of enabled integrations -func (c *Client4) GetIntegrationsUsage() (*IntegrationsUsage, *Response, error) { - r, err := c.DoAPIGet(c.usageRoute()+"/integrations", "") - if err != nil { - return nil, BuildResponse(r), err - } - defer closeBody(r) - - var usage *IntegrationsUsage - err = json.NewDecoder(r.Body).Decode(&usage) - return usage, BuildResponse(r), err -} - func (c *Client4) GetNewTeamMembersSince(teamID string, timeRange string, page int, perPage int) (*NewTeamMembersList, *Response, error) { query := fmt.Sprintf("?time_range=%v&page=%v&per_page=%v", timeRange, page, perPage) r, err := c.DoAPIGet(c.teamRoute(teamID)+"/top/team_members"+query, "") @@ -8459,6 +8558,42 @@ func (c *Client4) GetNewTeamMembersSince(teamID string, timeRange string, page i return newTeamMembersList, BuildResponse(r), nil } +func (c *Client4) GetPostInfo(postId string) (*PostInfo, *Response, error) { + r, err := c.DoAPIGet(c.postRoute(postId)+"/info", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var info *PostInfo + if err = json.NewDecoder(r.Body).Decode(&info); err != nil { + return nil, nil, NewAppError("GetPostInfo", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return info, BuildResponse(r), nil +} + +func (c *Client4) AcknowledgePost(postId, userId string) (*PostAcknowledgement, *Response, error) { + r, err := c.DoAPIPost(c.userRoute(userId)+c.postRoute(postId)+"/ack", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var ack *PostAcknowledgement + if jsonErr := json.NewDecoder(r.Body).Decode(&ack); jsonErr != nil { + return nil, nil, NewAppError("AcknowledgePost", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return ack, BuildResponse(r), nil +} + +func (c *Client4) UnacknowledgePost(postId, userId string) (*Response, error) { + r, err := c.DoAPIDelete(c.userRoute(userId) + c.postRoute(postId) + "/ack") + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} + func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) { r, err := c.DoAPIPost(c.ldapRoute()+"/users/"+userID+"/group_sync_memberships", "") if err != nil { @@ -8467,3 +8602,34 @@ func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) { defer closeBody(r) return BuildResponse(r), nil } + +// Worktemplates sections + +func (c *Client4) worktemplatesRoute() string { + return "/worktemplates" +} + +// GetWorktemplateCategories returns categories of worktemplates +func (c *Client4) GetWorktemplateCategories() ([]*WorkTemplateCategory, *Response, error) { + r, err := c.DoAPIGet(c.worktemplatesRoute()+"/categories", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var categories []*WorkTemplateCategory + err = json.NewDecoder(r.Body).Decode(&categories) + return categories, BuildResponse(r), err +} + +func (c *Client4) GetWorkTemplatesByCategory(category string) ([]*WorkTemplate, *Response, error) { + r, err := c.DoAPIGet(c.worktemplatesRoute()+"/categories/"+category+"/templates", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var templates []*WorkTemplate + err = json.NewDecoder(r.Body).Decode(&templates) + return templates, BuildResponse(r), err +} diff --git a/model/cloud.go b/model/cloud.go index 2b072e1f00..6e66015a87 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -16,6 +16,8 @@ const ( EventTypeTriggerDelinquencyEmail = "trigger-delinquency-email" ) +const UpcomingInvoice = "upcoming" + var MockCWS string type BillingScheme string @@ -40,6 +42,20 @@ const ( SubscriptionFamilyOnPrem = SubscriptionFamily("on-prem") ) +type ProductSku string + +const ( + SkuStarterGov = ProductSku("starter-gov") + SkuProfessionalGov = ProductSku("professional-gov") + SkuEnterpriseGov = ProductSku("enterprise-gov") + SkuStarter = ProductSku("starter") + SkuProfessional = ProductSku("professional") + SkuEnterprise = ProductSku("enterprise") + SkuCloudStarter = ProductSku("cloud-starter") + SkuCloudProfessional = ProductSku("cloud-professional") + SkuCloudEnterprise = ProductSku("cloud-enterprise") +) + // Product model represents a product on the cloud system. type Product struct { ID string `json:"id"` @@ -140,21 +156,37 @@ type PaymentMethod struct { // Subscription model represents a subscription on the system. type Subscription struct { - ID string `json:"id"` - CustomerID string `json:"customer_id"` - ProductID string `json:"product_id"` - AddOns []string `json:"add_ons"` - StartAt int64 `json:"start_at"` - EndAt int64 `json:"end_at"` - CreateAt int64 `json:"create_at"` - Seats int `json:"seats"` - Status string `json:"status"` - DNS string `json:"dns"` - IsPaidTier string `json:"is_paid_tier"` - LastInvoice *Invoice `json:"last_invoice"` - IsFreeTrial string `json:"is_free_trial"` - TrialEndAt int64 `json:"trial_end_at"` - DelinquentSince *int64 `json:"delinquent_since"` + ID string `json:"id"` + CustomerID string `json:"customer_id"` + ProductID string `json:"product_id"` + AddOns []string `json:"add_ons"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` + CreateAt int64 `json:"create_at"` + Seats int `json:"seats"` + Status string `json:"status"` + DNS string `json:"dns"` + IsPaidTier string `json:"is_paid_tier"` + LastInvoice *Invoice `json:"last_invoice"` + UpcomingInvoice *Invoice `json:"upcoming_invoice"` + IsFreeTrial string `json:"is_free_trial"` + TrialEndAt int64 `json:"trial_end_at"` + DelinquentSince *int64 `json:"delinquent_since"` + OriginallyLicensedSeats int `json:"originally_licensed_seats"` +} + +// Subscription History model represents true up event in a yearly subscription +type SubscriptionHistory struct { + ID string `json:"id"` + SubscriptionID string `json:"subscription_id"` + Seats int `json:"seats"` + CreateAt int64 `json:"create_at"` +} + +type SubscriptionHistoryChange struct { + SubscriptionID string `json:"subscription_id"` + Seats int `json:"seats"` + CreateAt int64 `json:"create_at"` } // GetWorkSpaceNameFromDNS returns the work space name. For example from test.mattermost.cloud.com, it returns test @@ -227,6 +259,7 @@ type CloudWorkspaceOwner struct { } type SubscriptionChange struct { ProductID string `json:"product_id"` + Seats int `json:"seats"` } type BoardsLimits struct { @@ -257,3 +290,24 @@ type ProductLimits struct { Messages *MessagesLimits `json:"messages,omitempty"` Teams *TeamsLimits `json:"teams,omitempty"` } + +type BootstrapSelfHostedSignupRequest struct { + Email string `json:"email"` +} + +type BootstrapSelfHostedSignupResponse struct { + Progress string `json:"progress"` +} + +type BootstrapSelfHostedSignupResponseInternal struct { + Progress string `json:"progress"` + License string `json:"license"` +} + +func (p *Product) IsYearly() bool { + return p.RecurringInterval == RecurringIntervalYearly +} + +func (p *Product) IsMonthly() bool { + return p.RecurringInterval == RecurringIntervalMonthly +} diff --git a/model/config.go b/model/config.go index be7a88b61d..7b9913e976 100644 --- a/model/config.go +++ b/model/config.go @@ -383,6 +383,8 @@ type ServiceSettings struct { CollapsedThreads *string `access:"experimental_features"` ManagedResourcePaths *string `access:"environment_web_server,write_restrictable,cloud_restrictable"` EnableCustomGroups *bool `access:"site_users_and_teams"` + SelfHostedFirstTimePurchase *bool `access:"write_restrictable,cloud_restrictable"` + AllowSyncedDrafts *bool `access:"site_posts"` } func (s *ServiceSettings) SetDefaults(isUpdate bool) { @@ -845,7 +847,15 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { } if s.PostPriority == nil { - s.PostPriority = NewBool(false) + s.PostPriority = NewBool(true) + } + + if s.AllowSyncedDrafts == nil { + s.AllowSyncedDrafts = NewBool(true) + } + + if s.SelfHostedFirstTimePurchase == nil { + s.SelfHostedFirstTimePurchase = NewBool(false) } } @@ -958,6 +968,7 @@ type ExperimentalSettings struct { EnableSharedChannels *bool `access:"experimental_features"` EnableRemoteClusterService *bool `access:"experimental_features"` EnableAppBar *bool `access:"experimental_features"` + PatchPluginsReactDOM *bool `access:"experimental_features"` } func (s *ExperimentalSettings) SetDefaults() { @@ -992,6 +1003,10 @@ func (s *ExperimentalSettings) SetDefaults() { if s.EnableAppBar == nil { s.EnableAppBar = NewBool(false) } + + if s.PatchPluginsReactDOM == nil { + s.PatchPluginsReactDOM = NewBool(false) + } } type AnalyticsSettings struct { @@ -1220,6 +1235,7 @@ type LogSettings struct { FileLocation *string `access:"environment_logging,write_restrictable,cloud_restrictable"` EnableWebhookDebugging *bool `access:"environment_logging,write_restrictable,cloud_restrictable"` EnableDiagnostics *bool `access:"environment_logging,write_restrictable,cloud_restrictable"` // telemetry: none + VerboseDiagnostics *bool `access:"environment_logging,write_restrictable,cloud_restrictable"` // telemetry: none EnableSentry *bool `access:"environment_logging,write_restrictable,cloud_restrictable"` // telemetry: none AdvancedLoggingConfig *string `access:"environment_logging,write_restrictable,cloud_restrictable"` } @@ -1263,6 +1279,10 @@ func (s *LogSettings) SetDefaults() { s.EnableDiagnostics = NewBool(true) } + if s.VerboseDiagnostics == nil { + s.VerboseDiagnostics = NewBool(false) + } + if s.EnableSentry == nil { s.EnableSentry = NewBool(*s.EnableDiagnostics) } diff --git a/model/draft.go b/model/draft.go new file mode 100644 index 0000000000..a9741e5727 --- /dev/null +++ b/model/draft.go @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/http" + "sync" + "unicode/utf8" +) + +type Draft struct { + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + RootId string `json:"root_id"` + + Message string `json:"message"` + + propsMu sync.RWMutex `db:"-"` // Unexported mutex used to guard Draft.Props. + Props StringInterface `json:"props"` // Deprecated: use GetProps() + FileIds StringArray `json:"file_ids,omitempty"` + Metadata *PostMetadata `json:"metadata,omitempty"` + Priority StringInterface `json:"priority,omitempty"` +} + +func (o *Draft) IsValid(maxDraftSize int) *AppError { + if o.CreateAt == 0 { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.create_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if o.UpdateAt == 0 { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.update_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if !IsValidId(o.UserId) { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.user_id.app_error", nil, "", http.StatusBadRequest) + } + + if !IsValidId(o.ChannelId) { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest) + } + + if !(IsValidId(o.RootId) || o.RootId == "") { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.root_id.app_error", nil, "", http.StatusBadRequest) + } + + if utf8.RuneCountInString(o.Message) > maxDraftSize { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.msg.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if utf8.RuneCountInString(ArrayToJSON(o.FileIds)) > PostFileidsMaxRunes { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.file_ids.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if utf8.RuneCountInString(StringInterfaceToJSON(o.GetProps())) > PostPropsMaxRunes { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.props.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if utf8.RuneCountInString(StringInterfaceToJSON(o.Priority)) > PostPropsMaxRunes { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.priority.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + return nil +} + +func (o *Draft) SetProps(props StringInterface) { + o.propsMu.Lock() + defer o.propsMu.Unlock() + o.Props = props +} + +func (o *Draft) GetProps() StringInterface { + o.propsMu.RLock() + defer o.propsMu.RUnlock() + return o.Props +} + +func (o *Draft) PreSave() { + if o.CreateAt == 0 { + o.CreateAt = GetMillis() + } + + o.UpdateAt = o.CreateAt + o.DeleteAt = 0 + o.PreCommit() +} + +func (o *Draft) PreCommit() { + if o.GetProps() == nil { + o.SetProps(make(map[string]interface{})) + } + + if o.FileIds == nil { + o.FileIds = []string{} + } + + // There's a rare bug where the client sends up duplicate FileIds so protect against that + o.FileIds = RemoveDuplicateStrings(o.FileIds) +} + +func (o *Draft) PreUpdate() { + o.UpdateAt = GetMillis() + o.PreCommit() +} diff --git a/model/draft_test.go b/model/draft_test.go new file mode 100644 index 0000000000..2e931c31dd --- /dev/null +++ b/model/draft_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDraftIsValid(t *testing.T) { + o := Draft{} + maxDraftSize := 10000 + + err := o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.CreateAt = GetMillis() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.UpdateAt = GetMillis() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.UserId = NewId() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.ChannelId = NewId() + o.RootId = "123" + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.RootId = "" + + o.Message = strings.Repeat("0", maxDraftSize+1) + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.Message = strings.Repeat("0", maxDraftSize) + err = o.IsValid(maxDraftSize) + assert.Nil(t, err) + + o.Message = "test" + err = o.IsValid(maxDraftSize) + assert.Nil(t, err) + + o.FileIds = StringArray{strings.Repeat("0", maxDraftSize+1)} + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) +} + +func TestDraftPreSave(t *testing.T) { + o := Draft{Message: "test"} + o.PreSave() + + assert.NotEqual(t, 0, o.CreateAt) + + past := GetMillis() - 1 + o = Draft{Message: "test", CreateAt: past} + o.PreSave() + + assert.LessOrEqual(t, o.CreateAt, past) +} + +func TestDraftPreUpdate(t *testing.T) { + o := Draft{Message: "test"} + o.PreUpdate() + + assert.NotEqual(t, 0, o.UpdateAt) + + past := GetMillis() - 1 + o = Draft{Message: "test", UpdateAt: past} + o.PreSave() + + assert.GreaterOrEqual(t, o.UpdateAt, past) +} diff --git a/model/feature_flags.go b/model/feature_flags.go index 29d4c015fd..9356df0b89 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -33,17 +33,12 @@ type FeatureFlags struct { PermalinkPreviews bool - // Enable Calls plugin support in the mobile app - CallsMobile bool - // CallsEnabled controls whether or not the Calls plugin should be enabled CallsEnabled bool // A dash separated list for feature flags to turn on for Boards BoardsFeatureFlags string - CustomGroups bool - // Enable DataRetention for Boards BoardsDataRetention bool @@ -79,6 +74,8 @@ type FeatureFlags struct { ReduceOnBoardingTaskList bool ThreadsEverywhere bool + + GlobalDrafts bool } func (f *FeatureFlags) SetDefaults() { @@ -90,9 +87,7 @@ func (f *FeatureFlags) SetDefaults() { f.PluginApps = "" f.PluginFocalboard = "" f.PermalinkPreviews = true - f.CallsMobile = false f.BoardsFeatureFlags = "" - f.CustomGroups = true f.BoardsDataRetention = false f.NormalizeLdapDNs = false f.EnableInactivityCheckJob = true @@ -103,12 +98,13 @@ func (f *FeatureFlags) SetDefaults() { f.CallsEnabled = true f.BoardsProduct = false f.SendWelcomePost = true - f.PostPriority = false + f.PostPriority = true f.PeopleProduct = false f.WorkTemplate = false f.AnnualSubscription = false f.ReduceOnBoardingTaskList = false f.ThreadsEverywhere = false + f.GlobalDrafts = false } func (f *FeatureFlags) Plugins() map[string]string { diff --git a/model/group.go b/model/group.go index 20d9533401..530eab88f7 100644 --- a/model/group.go +++ b/model/group.go @@ -31,18 +31,20 @@ var groupSourcesRequiringRemoteID = []GroupSource{ } type Group struct { - Id string `json:"id"` - Name *string `json:"name,omitempty"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - Source GroupSource `json:"source"` - RemoteId *string `json:"remote_id"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - HasSyncables bool `db:"-" json:"has_syncables"` - MemberCount *int `db:"-" json:"member_count,omitempty"` - AllowReference bool `json:"allow_reference"` + Id string `json:"id"` + Name *string `json:"name,omitempty"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Source GroupSource `json:"source"` + RemoteId *string `json:"remote_id"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + HasSyncables bool `db:"-" json:"has_syncables"` + MemberCount *int `db:"-" json:"member_count,omitempty"` + AllowReference bool `json:"allow_reference"` + ChannelMemberCount *int `db:"-" json:"channel_member_count,omitempty"` + ChannelMemberTimezonesCount *int `db:"-" json:"channel_member_timezones_count,omitempty"` } func (group *Group) Auditable() map[string]interface{} { @@ -113,6 +115,9 @@ type GroupSearchOpts struct { // FilterHasMember filters the groups to the intersect of the // set returned by the query and those that have the given user as a member. FilterHasMember string + + IncludeChannelMemberCount string + IncludeTimezones bool } type GetGroupOpts struct { diff --git a/model/insights.go b/model/insights.go index d66d48c283..22d105f867 100644 --- a/model/insights.go +++ b/model/insights.go @@ -118,13 +118,14 @@ type NewTeamMembersList struct { } type NewTeamMember struct { - Id string `json:"id"` - Username string `json:"username"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Position string `json:"position"` - Nickname string `json:"nickname"` - CreateAt int64 `json:"create_at"` + Id string `json:"id"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Position string `json:"position"` + Nickname string `json:"nickname"` + LastPictureUpdate int64 `json:"last_picture_update,omitempty"` + CreateAt int64 `json:"create_at"` } type DurationPostCount struct { @@ -247,6 +248,9 @@ func ToDailyPostCountViewModel(dpc []*DurationPostCount, startTime *time.Time, n return viewModel } +// Deprecated: This method doesn't perform error checking. +// Use GetStartOfDayForTimeRange instead. +// // StartOfDayForTimeRange gets the unix start time in milliseconds from the given time range. // Time range can be one of: "today", "7_day", or "28_day". func StartOfDayForTimeRange(timeRange string, location *time.Location) *time.Time { diff --git a/model/license.go b/model/license.go index ba0797c168..94f0b81da4 100644 --- a/model/license.go +++ b/model/license.go @@ -56,6 +56,7 @@ type License struct { SkuShortName string `json:"sku_short_name"` IsTrial bool `json:"is_trial"` IsGovSku bool `json:"is_gov_sku"` + SignupJWT *string `json:"signup_jwt"` } type Customer struct { diff --git a/model/license_test.go b/model/license_test.go index 62bba9439c..6319ccc8e9 100644 --- a/model/license_test.go +++ b/model/license_test.go @@ -158,6 +158,11 @@ func TestIsCloud(t *testing.T) { l1.Features = nil assert.False(t, l1.IsCloud()) + + t.Run("false if license is nil", func(t *testing.T) { + var license *License + assert.False(t, license.IsCloud()) + }) } func TestLicenseRecordIsValid(t *testing.T) { diff --git a/model/gitlab/gitlab.go b/model/oauthproviders/gitlab/gitlab.go similarity index 100% rename from model/gitlab/gitlab.go rename to model/oauthproviders/gitlab/gitlab.go diff --git a/model/post.go b/model/post.go index 3f9c5a630c..992188ac8e 100644 --- a/model/post.go +++ b/model/post.go @@ -71,6 +71,10 @@ const ( PostPropsGroupHighlightDisabled = "disable_group_highlight" PostPropsPreviewedPost = "previewed_post" + + PostPriorityUrgent = "urgent" + PostPropsRequestedAck = "requested_ack" + PostPropsPersistentNotifications = "persistent_notifications" ) const ( @@ -158,6 +162,15 @@ type PostReminder struct { UserId string `json:",omitempty"` } +type PostPriority struct { + Priority *string `json:"priority"` + RequestedAck *bool `json:"requested_ack"` + PersistentNotifications *bool `json:"persistent_notifications"` + // These fields are only used internally for interacting with DB. + PostId string `json:",omitempty"` + ChannelId string `json:",omitempty"` +} + type SearchParameter struct { Terms *string `json:"terms"` IsOrSearch *bool `json:"is_or_search"` @@ -306,6 +319,7 @@ type GetPostsOptions struct { FromCreateAt int64 // CreateAt after which to send the items Direction string // Only accepts up|down. Indicates the order in which to send the items. IncludeDeleted bool + IncludePostPriority bool } type PostCountOptions struct { @@ -770,3 +784,20 @@ func (o *Post) GetPreviewedPostProp() string { } return "" } + +func (o *Post) GetPriority() *PostPriority { + if o.Metadata != nil && o.Metadata.Priority != nil { + return o.Metadata.Priority + } + + return nil +} + +func (o *Post) IsUrgent() bool { + postPriority := o.GetPriority() + if postPriority == nil { + return false + } + + return *postPriority.Priority == PostPriorityUrgent +} diff --git a/model/post_acknowledgement.go b/model/post_acknowledgement.go new file mode 100644 index 0000000000..227a678e6b --- /dev/null +++ b/model/post_acknowledgement.go @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import "net/http" + +type PostAcknowledgement struct { + UserId string `json:"user_id"` + PostId string `json:"post_id"` + AcknowledgedAt int64 `json:"acknowledged_at"` +} + +func (o *PostAcknowledgement) IsValid() *AppError { + if !IsValidId(o.UserId) { + return NewAppError("PostAcknowledgement.IsValid", "model.acknowledgement.is_valid.user_id.app_error", nil, "user_id="+o.UserId, http.StatusBadRequest) + } + + if !IsValidId(o.PostId) { + return NewAppError("PostAcknowledgement.IsValid", "model.acknowledgement.is_valid.post_id.app_error", nil, "post_id="+o.PostId, http.StatusBadRequest) + } + + return nil +} diff --git a/model/post_info.go b/model/post_info.go new file mode 100644 index 0000000000..0a48ae9a36 --- /dev/null +++ b/model/post_info.go @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type PostInfo struct { + ChannelId string `json:"channel_id"` + ChannelType ChannelType `json:"channel_type"` + ChannelDisplayName string `json:"channel_display_name"` + HasJoinedChannel bool `json:"has_joined_channel"` + TeamId string `json:"team_id"` + TeamType string `json:"team_type"` + TeamDisplayName string `json:"team_display_name"` + HasJoinedTeam bool `json:"has_joined_team"` +} diff --git a/model/post_metadata.go b/model/post_metadata.go index 6ccc1ebffc..ee49eb4f48 100644 --- a/model/post_metadata.go +++ b/model/post_metadata.go @@ -22,6 +22,12 @@ type PostMetadata struct { // Reactions holds reactions made to the post. Reactions []*Reaction `json:"reactions,omitempty"` + + // Priority holds info about priority settings for the post. + Priority *PostPriority `json:"priority,omitempty"` + + // Acknowledgements holds acknowledgements made by users to the post + Acknowledgements []*PostAcknowledgement `json:"acknowledgements,omitempty"` } type PostImage struct { @@ -54,11 +60,27 @@ func (p *PostMetadata) Copy() *PostMetadata { reactionsCopy := make([]*Reaction, len(p.Reactions)) copy(reactionsCopy, p.Reactions) + acknowledgementsCopy := make([]*PostAcknowledgement, len(p.Acknowledgements)) + copy(acknowledgementsCopy, p.Acknowledgements) + + var postPriorityCopy *PostPriority + if p.Priority != nil { + postPriorityCopy = &PostPriority{ + Priority: p.Priority.Priority, + RequestedAck: p.Priority.RequestedAck, + PersistentNotifications: p.Priority.PersistentNotifications, + PostId: p.Priority.PostId, + ChannelId: p.Priority.ChannelId, + } + } + return &PostMetadata{ - Embeds: embedsCopy, - Emojis: emojisCopy, - Files: filesCopy, - Images: imagesCopy, - Reactions: reactionsCopy, + Embeds: embedsCopy, + Emojis: emojisCopy, + Files: filesCopy, + Images: imagesCopy, + Reactions: reactionsCopy, + Priority: postPriorityCopy, + Acknowledgements: acknowledgementsCopy, } } diff --git a/model/team_member.go b/model/team_member.go index f38c29ed56..fbcecadd73 100644 --- a/model/team_member.go +++ b/model/team_member.go @@ -45,13 +45,14 @@ func (o *TeamMember) Auditable() map[string]interface{} { //msgp:ignore TeamUnread type TeamUnread struct { - TeamId string `json:"team_id"` - MsgCount int64 `json:"msg_count"` - MentionCount int64 `json:"mention_count"` - MentionCountRoot int64 `json:"mention_count_root"` - MsgCountRoot int64 `json:"msg_count_root"` - ThreadCount int64 `json:"thread_count"` - ThreadMentionCount int64 `json:"thread_mention_count"` + TeamId string `json:"team_id"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + MsgCountRoot int64 `json:"msg_count_root"` + ThreadCount int64 `json:"thread_count"` + ThreadMentionCount int64 `json:"thread_mention_count"` + ThreadUrgentMentionCount int64 `json:"thread_urgent_mention_count"` } //msgp:ignore TeamMemberForExport diff --git a/model/thread.go b/model/thread.go index 6513f41b40..ce8ebcca3c 100644 --- a/model/thread.go +++ b/model/thread.go @@ -41,14 +41,16 @@ type ThreadResponse struct { Post *Post `json:"post"` UnreadReplies int64 `json:"unread_replies"` UnreadMentions int64 `json:"unread_mentions"` + IsUrgent bool `json:"is_urgent"` DeleteAt int64 `json:"delete_at"` } type Threads struct { - Total int64 `json:"total"` - TotalUnreadThreads int64 `json:"total_unread_threads"` - TotalUnreadMentions int64 `json:"total_unread_mentions"` - Threads []*ThreadResponse `json:"threads"` + Total int64 `json:"total"` + TotalUnreadThreads int64 `json:"total_unread_threads"` + TotalUnreadMentions int64 `json:"total_unread_mentions"` + TotalUnreadUrgentMentions int64 `json:"total_unread_urgent_mentions"` + Threads []*ThreadResponse `json:"threads"` } type GetUserThreadsOpts struct { @@ -81,6 +83,9 @@ type GetUserThreadsOpts struct { // TeamOnly will only fetch threads and unreads for the specified team and excludes DMs/GMs TeamOnly bool + + // IncludeIsUrgent will return IsUrgent field as well to assert is the thread is urgent or not + IncludeIsUrgent bool } func (o *Thread) Etag() string { diff --git a/model/usage.go b/model/usage.go index 6ee0c4383f..413ee2b8e6 100644 --- a/model/usage.go +++ b/model/usage.go @@ -16,10 +16,6 @@ type TeamsUsage struct { CloudArchived int64 `json:"cloud_archived"` } -type IntegrationsUsage struct { - Enabled int `json:"enabled"` -} - var InstalledIntegrationsIgnoredPlugins = map[string]struct{}{ PluginIdPlaybooks: {}, PluginIdFocalboard: {}, diff --git a/model/websocket_message.go b/model/websocket_message.go index 41c802dbfc..1ad2a3b8b0 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -76,7 +76,11 @@ const ( WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadReadChanged = "thread_read_changed" WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received" - WebsocketEventIntegrationsUsageChanged = "integrations_usage_changed" + WebsocketEventDraftCreated = "draft_created" + WebsocketEventDraftUpdated = "draft_updated" + WebsocketEventDraftDeleted = "draft_deleted" + WebsocketEventAcknowledgementAdded = "post_acknowledgement_added" + WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed" ) type WebSocketMessage interface { diff --git a/model/websocket_message_test.go b/model/websocket_message_test.go index 14c49fa3ff..e8f9c17ecd 100644 --- a/model/websocket_message_test.go +++ b/model/websocket_message_test.go @@ -215,9 +215,10 @@ func TestWebSocketEventDeepCopy(t *testing.T) { TeamId: "ccc", ContainsSanitizedData: true, ContainsSensitiveData: true, + OmitConnectionId: "ddd", } - ev := NewWebSocketEvent("test", "team", "channel", "user", omitUsers, "") + ev := NewWebSocketEvent("test", "team", "channel", "user", omitUsers, "ddd") ev.Add("post", &Post{}) ev.SetBroadcast(broadcast) diff --git a/model/worktemplate.go b/model/worktemplate.go new file mode 100644 index 0000000000..0e8add822b --- /dev/null +++ b/model/worktemplate.go @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type WorkTemplateCategory struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type WorkTemplate struct { + ID string `json:"id"` + Category string `json:"category"` + UseCase string `json:"useCase"` + Illustration string `json:"illustration"` + Visibility string `json:"visibility"` + FeatureFlag *WorkTemplateFeatureFlag `json:"featureFlag,omitempty"` + Description Description `json:"description"` + Content []WorkTemplateContent `json:"content"` +} + +type WorkTemplateFeatureFlag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type DescriptionContent struct { + Message string `json:"message"` + Illustration string `json:"illustration"` +} + +type Description struct { + Channel *DescriptionContent `json:"channel"` + Board *DescriptionContent `json:"board"` + Playbook *DescriptionContent `json:"playbook"` + Integration *DescriptionContent `json:"integration"` +} + +type WorkTemplateChannel struct { + ID string `json:"id"` + Name string `json:"name"` + Purpose string `json:"purpose"` + Playbook string `json:"playbook"` + Illustration string `json:"illustration"` +} + +type WorkTemplateBoard struct { + ID string `json:"id"` + Template string `json:"template"` + Name string `json:"name"` + Channel string `json:"channel"` + Illustration string `json:"illustration"` +} + +type WorkTemplatePlaybook struct { + Template string `json:"template"` + Name string `json:"name"` + ID string `json:"id"` + Illustration string `json:"illustration"` +} + +type WorkTemplateIntegration struct { + ID string `json:"id"` +} + +type WorkTemplateContent struct { + Channel *WorkTemplateChannel `json:"channel,omitempty"` + Board *WorkTemplateBoard `json:"board,omitempty"` + Playbook *WorkTemplatePlaybook `json:"playbook,omitempty"` + Integration *WorkTemplateIntegration `json:"integration,omitempty"` +} diff --git a/plugin/api.go b/plugin/api.go index d3b49da50c..f777539d04 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -1168,6 +1168,24 @@ type API interface { // // Minimum server version: 7.6 RegisterCollectionAndTopic(collectionType, topicType string) error + + // CreateUploadSession creates and returns a new (resumable) upload session. + // + // @tag Upload + // Minimum server version: 7.6 + CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) + + // UploadData uploads the data for a given upload session. + // + // @tag Upload + // Minimum server version: 7.6 + UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) + + // GetUploadSession returns the upload session for the provided id. + // + // @tag Upload + // Minimum server version: 7.6 + GetUploadSession(uploadID string) (*model.UploadSession, error) } var handshake = plugin.HandshakeConfig{ diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index b64a2229ce..91303da798 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -1246,3 +1246,24 @@ func (api *apiTimerLayer) RegisterCollectionAndTopic(collectionType, topicType s api.recordTime(startTime, "RegisterCollectionAndTopic", _returnsA == nil) return _returnsA } + +func (api *apiTimerLayer) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.CreateUploadSession(us) + api.recordTime(startTime, "CreateUploadSession", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.UploadData(us, rd) + api.recordTime(startTime, "UploadData", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) GetUploadSession(uploadID string) (*model.UploadSession, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetUploadSession(uploadID) + api.recordTime(startTime, "GetUploadSession", _returnsB == nil) + return _returnsA, _returnsB +} diff --git a/plugin/client_rpc.go b/plugin/client_rpc.go index feea87005a..86805e1a3e 100644 --- a/plugin/client_rpc.go +++ b/plugin/client_rpc.go @@ -866,3 +866,55 @@ func (s *apiRPCServer) InstallPlugin(args *Z_InstallPluginArgs, returns *Z_Insta returns.A, returns.B = hook.InstallPlugin(pluginReader, args.B) return nil } + +type Z_UploadDataArgs struct { + A *model.UploadSession + PluginStreamID uint32 +} + +type Z_UploadDataReturns struct { + A *model.FileInfo + B error +} + +func (g *apiRPCClient) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) { + pluginStreamID := g.muxBroker.NextId() + + go func() { + pluginConnection, err := g.muxBroker.Accept(pluginStreamID) + if err != nil { + log.Print("Failed to upload data. MuxBroker could not Accept connection", mlog.Err(err)) + return + } + defer pluginConnection.Close() + serveIOReader(rd, pluginConnection) + }() + + _args := &Z_UploadDataArgs{us, pluginStreamID} + _returns := &Z_UploadDataReturns{} + if err := g.client.Call("Plugin.UploadData", _args, _returns); err != nil { + log.Print("RPC call UploadData to plugin failed.", mlog.Err(err)) + } + + return _returns.A, _returns.B +} + +func (s *apiRPCServer) UploadData(args *Z_UploadDataArgs, returns *Z_UploadDataReturns) error { + hook, ok := s.impl.(interface { + UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) + }) + if !ok { + return encodableError(fmt.Errorf("API UploadData called but not implemented")) + } + + receivePluginConnection, err := s.muxBroker.Dial(args.PluginStreamID) + if err != nil { + fmt.Fprintf(os.Stderr, "[ERROR] Can't connect to remote plugin stream, error: %v", err.Error()) + return err + } + pluginReader := connectIOReader(receivePluginConnection) + defer pluginReader.Close() + + returns.A, returns.B = hook.UploadData(args.A, pluginReader) + return nil +} diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index b442898080..c47e9958fe 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -5968,3 +5968,63 @@ func (s *apiRPCServer) RegisterCollectionAndTopic(args *Z_RegisterCollectionAndT } return nil } + +type Z_CreateUploadSessionArgs struct { + A *model.UploadSession +} + +type Z_CreateUploadSessionReturns struct { + A *model.UploadSession + B error +} + +func (g *apiRPCClient) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { + _args := &Z_CreateUploadSessionArgs{us} + _returns := &Z_CreateUploadSessionReturns{} + if err := g.client.Call("Plugin.CreateUploadSession", _args, _returns); err != nil { + log.Printf("RPC call to CreateUploadSession API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) CreateUploadSession(args *Z_CreateUploadSessionArgs, returns *Z_CreateUploadSessionReturns) error { + if hook, ok := s.impl.(interface { + CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) + }); ok { + returns.A, returns.B = hook.CreateUploadSession(args.A) + returns.B = encodableError(returns.B) + } else { + return encodableError(fmt.Errorf("API CreateUploadSession called but not implemented.")) + } + return nil +} + +type Z_GetUploadSessionArgs struct { + A string +} + +type Z_GetUploadSessionReturns struct { + A *model.UploadSession + B error +} + +func (g *apiRPCClient) GetUploadSession(uploadID string) (*model.UploadSession, error) { + _args := &Z_GetUploadSessionArgs{uploadID} + _returns := &Z_GetUploadSessionReturns{} + if err := g.client.Call("Plugin.GetUploadSession", _args, _returns); err != nil { + log.Printf("RPC call to GetUploadSession API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetUploadSession(args *Z_GetUploadSessionArgs, returns *Z_GetUploadSessionReturns) error { + if hook, ok := s.impl.(interface { + GetUploadSession(uploadID string) (*model.UploadSession, error) + }); ok { + returns.A, returns.B = hook.GetUploadSession(args.A) + returns.B = encodableError(returns.B) + } else { + return encodableError(fmt.Errorf("API GetUploadSession called but not implemented.")) + } + return nil +} diff --git a/plugin/environment.go b/plugin/environment.go index fa737f5ecf..4ccbbdf023 100644 --- a/plugin/environment.go +++ b/plugin/environment.go @@ -4,10 +4,12 @@ package plugin import ( + "bytes" "fmt" "hash/fnv" "os" "path/filepath" + "strings" "sync" "time" @@ -50,7 +52,6 @@ type PrepackagedPlugin struct { // of active plugins. type Environment struct { registeredPlugins sync.Map - registeredProducts sync.Map pluginHealthCheckJob *PluginHealthCheckJob logger *mlog.Logger metrics einterfaces.MetricsInterface @@ -58,15 +59,20 @@ type Environment struct { dbDriver Driver pluginDir string webappPluginDir string + patchReactDOM bool prepackagedPlugins []*PrepackagedPlugin prepackagedPluginsLock sync.RWMutex } -func NewEnvironment(newAPIImpl apiImplCreatorFunc, +func NewEnvironment( + newAPIImpl apiImplCreatorFunc, dbDriver Driver, - pluginDir string, webappPluginDir string, + pluginDir string, + webappPluginDir string, + patchReactDOM bool, logger *mlog.Logger, - metrics einterfaces.MetricsInterface) (*Environment, error) { + metrics einterfaces.MetricsInterface, +) (*Environment, error) { return &Environment{ logger: logger, metrics: metrics, @@ -74,6 +80,7 @@ func NewEnvironment(newAPIImpl apiImplCreatorFunc, dbDriver: dbDriver, pluginDir: pluginDir, webappPluginDir: webappPluginDir, + patchReactDOM: patchReactDOM, }, nil } @@ -326,26 +333,6 @@ func (env *Environment) Activate(id string) (manifest *model.Manifest, activated return pluginInfo.Manifest, true, nil } -func (env *Environment) AddProduct(productID string, hooks any) error { - prod, err := newAdapter(hooks) - if err != nil { - return err - } - - rp := ®isteredProduct{ - productID: productID, - adapter: prod, - } - - env.registeredProducts.Store(productID, rp) - - return nil -} - -func (env *Environment) RemoveProduct(productID string) { - env.registeredProducts.Delete(productID) -} - func (env *Environment) RemovePlugin(id string) { if _, ok := env.registeredPlugins.Load(id); ok { env.registeredPlugins.Delete(id) @@ -472,6 +459,17 @@ func (env *Environment) UnpackWebappBundle(id string) (*model.Manifest, error) { return nil, errors.Wrapf(err, "unable to read webapp bundle: %v", id) } + if env.patchReactDOM { + newContents, changed := patchReactDOM(sourceBundleFileContents) + if changed { + sourceBundleFileContents = newContents + err = os.WriteFile(sourceBundleFilepath, sourceBundleFileContents, 0644) + if err != nil { + return nil, errors.Wrapf(err, "unable to overwrite webapp bundle: %v", id) + } + } + } + hash := fnv.New64a() if _, err = hash.Write(sourceBundleFileContents); err != nil { return nil, errors.Wrapf(err, "unable to generate hash for webapp bundle: %v", id) @@ -488,6 +486,52 @@ func (env *Environment) UnpackWebappBundle(id string) (*model.Manifest, error) { return manifest, nil } +func patchReactDOM(initialBytes []byte) ([]byte, bool) { + if !bytes.Contains(initialBytes, []byte("react-dom.production.min.js")) { + return initialBytes, false + } + + initial := string(initialBytes) + nameIndex := strings.Index(initial, "react-dom.production.min.js") + + beginning := strings.LastIndex(initial[:nameIndex], "{") + var end int + + argDefBeginning := strings.LastIndex(initial[:beginning], "function") + 9 + argDefEnd := strings.LastIndex(initial[:beginning], ")") - 1 + argsNames := strings.Split(initial[argDefBeginning:argDefEnd], ",") + if len(argsNames) != 3 { + return initialBytes, false + } + + exportsArgName := strings.TrimSpace(argsNames[1]) + + numOpenBraces := 0 + for i, c := range initial[beginning:] { + if end != 0 { + break + } + switch c { + case '}': + numOpenBraces-- + + if numOpenBraces == 0 { + end = beginning + i + } + case '{': + numOpenBraces++ + } + } + + beforePatch := initial[:end] + afterPatch := initial[end:] + + patch := fmt.Sprintf("; Object.assign(%s, window.ReactDOM)", exportsArgName) + + result := fmt.Sprintf("%s%s%s", beforePatch, patch, afterPatch) + return []byte(result), true +} + // HooksForPlugin returns the hooks API for the plugin with the given id. // // Consider using RunMultiPluginHook instead. @@ -527,24 +571,6 @@ func (env *Environment) RunMultiPluginHook(hookRunnerFunc func(hooks Hooks) bool return result }) - env.registeredProducts.Range(func(key, value any) bool { - rp := value.(*registeredProduct) - - if !rp.Implements(hookId) { - return true - } - - hookStartTime := time.Now() - result := hookRunnerFunc(rp.adapter) - - if env.metrics != nil { - elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second) - env.metrics.ObservePluginMultiHookIterationDuration(rp.productID, elapsedTime) - } - - return result - }) - if env.metrics != nil { elapsedTime := float64(time.Since(startTime)) / float64(time.Second) env.metrics.ObservePluginMultiHookDuration(elapsedTime) diff --git a/plugin/interface_generator/main.go b/plugin/interface_generator/main.go index 0a14a092b9..3afcb4d559 100644 --- a/plugin/interface_generator/main.go +++ b/plugin/interface_generator/main.go @@ -35,6 +35,7 @@ var excludedPluginHooks = []string{ "OnActivate", "PluginHTTP", "ServeHTTP", + "UploadData", } var excludedProductHooks = []string{ @@ -398,13 +399,13 @@ type {{.Name}}IFace interface { {{end}} -type hooksAdapter struct { +type HooksAdapter struct { implemented map[int]struct{} productHooks any } -func newAdapter(productHooks any) (*hooksAdapter, error) { - a := &hooksAdapter{ +func NewAdapter(productHooks any) (*HooksAdapter, error) { + a := &HooksAdapter{ implemented: make(map[int]struct{}), productHooks: productHooks, } @@ -426,7 +427,7 @@ func newAdapter(productHooks any) (*hooksAdapter, error) { } {{range .HooksMethods}} -func (a *hooksAdapter) {{.Name}}{{funcStyle .Params}} {{funcStyle .Return}} { +func (a *HooksAdapter) {{.Name}}{{funcStyle .Params}} {{funcStyle .Return}} { if _, ok := a.implemented[{{.Name}}ID]; !ok { panic("product hooks must implement {{.Name}}") } diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index a7982c650c..a7948cfd4b 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -391,6 +391,29 @@ func (_m *API) CreateTeamMembersGracefully(teamID string, userIds []string, requ return r0, r1 } +// CreateUploadSession provides a mock function with given fields: us +func (_m *API) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { + ret := _m.Called(us) + + var r0 *model.UploadSession + if rf, ok := ret.Get(0).(func(*model.UploadSession) *model.UploadSession); ok { + r0 = rf(us) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.UploadSession) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.UploadSession) error); ok { + r1 = rf(us) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateUser provides a mock function with given fields: user func (_m *API) CreateUser(user *model.User) (*model.User, *model.AppError) { ret := _m.Called(user) @@ -2181,6 +2204,29 @@ func (_m *API) GetUnsanitizedConfig() *model.Config { return r0 } +// GetUploadSession provides a mock function with given fields: uploadID +func (_m *API) GetUploadSession(uploadID string) (*model.UploadSession, error) { + ret := _m.Called(uploadID) + + var r0 *model.UploadSession + if rf, ok := ret.Get(0).(func(string) *model.UploadSession); ok { + r0 = rf(uploadID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.UploadSession) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(uploadID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetUser provides a mock function with given fields: userID func (_m *API) GetUser(userID string) (*model.User, *model.AppError) { ret := _m.Called(userID) @@ -3717,6 +3763,29 @@ func (_m *API) UpdateUserStatus(userID string, status string) (*model.Status, *m return r0, r1 } +// UploadData provides a mock function with given fields: us, rd +func (_m *API) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) { + ret := _m.Called(us, rd) + + var r0 *model.FileInfo + if rf, ok := ret.Get(0).(func(*model.UploadSession, io.Reader) *model.FileInfo); ok { + r0 = rf(us, rd) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.FileInfo) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.UploadSession, io.Reader) error); ok { + r1 = rf(us, rd) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // UploadFile provides a mock function with given fields: data, channelId, filename func (_m *API) UploadFile(data []byte, channelId string, filename string) (*model.FileInfo, *model.AppError) { ret := _m.Called(data, channelId, filename) diff --git a/plugin/product.go b/plugin/product.go index cd56dec569..557b7546af 100644 --- a/plugin/product.go +++ b/plugin/product.go @@ -7,13 +7,13 @@ import ( "net/http" ) -type registeredProduct struct { - productID string - adapter Hooks +type RegisteredProduct struct { + ProductID string + Adapter Hooks } -func (rp *registeredProduct) Implements(hookId int) bool { - adapter, ok := rp.adapter.(*hooksAdapter) +func (rp *RegisteredProduct) Implements(hookId int) bool { + adapter, ok := rp.Adapter.(*HooksAdapter) if !ok { return false } @@ -23,19 +23,19 @@ func (rp *registeredProduct) Implements(hookId int) bool { } // Implemented method is overridden intentionally to prevent calling it from outside. -func (a *hooksAdapter) Implemented() ([]string, error) { +func (a *HooksAdapter) Implemented() ([]string, error) { return nil, nil } // OnActivate is overridden intentionally as product should not call it. -func (a *hooksAdapter) OnActivate() error { +func (a *HooksAdapter) OnActivate() error { return nil } // OnDeactivate is overridden intentionally as product should not call it. -func (a *hooksAdapter) OnDeactivate() error { +func (a *HooksAdapter) OnDeactivate() error { return nil } // ServeHTTP is overridden intentionally as product should not call it. -func (a *hooksAdapter) ServeHTTP(c *Context, w http.ResponseWriter, r *http.Request) {} +func (a *HooksAdapter) ServeHTTP(c *Context, w http.ResponseWriter, r *http.Request) {} diff --git a/plugin/product_hooks_generated.go b/plugin/product_hooks_generated.go index 7f00e543aa..d3d51a930f 100644 --- a/plugin/product_hooks_generated.go +++ b/plugin/product_hooks_generated.go @@ -138,13 +138,13 @@ type GetTopicMetadataByIdsIFace interface { GetTopicMetadataByIds(c *Context, topicType string, topicIds []string) (map[string]*model.TopicMetadata, error) } -type hooksAdapter struct { +type HooksAdapter struct { implemented map[int]struct{} productHooks any } -func newAdapter(productHooks any) (*hooksAdapter, error) { - a := &hooksAdapter{ +func NewAdapter(productHooks any) (*HooksAdapter, error) { + a := &HooksAdapter{ implemented: make(map[int]struct{}), productHooks: productHooks, } @@ -433,7 +433,7 @@ func newAdapter(productHooks any) (*hooksAdapter, error) { return a, nil } -func (a *hooksAdapter) OnConfigurationChange() error { +func (a *HooksAdapter) OnConfigurationChange() error { if _, ok := a.implemented[OnConfigurationChangeID]; !ok { panic("product hooks must implement OnConfigurationChange") } @@ -442,7 +442,7 @@ func (a *hooksAdapter) OnConfigurationChange() error { } -func (a *hooksAdapter) ExecuteCommand(c *Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { +func (a *HooksAdapter) ExecuteCommand(c *Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { if _, ok := a.implemented[ExecuteCommandID]; !ok { panic("product hooks must implement ExecuteCommand") } @@ -451,7 +451,7 @@ func (a *hooksAdapter) ExecuteCommand(c *Context, args *model.CommandArgs) (*mod } -func (a *hooksAdapter) UserHasBeenCreated(c *Context, user *model.User) { +func (a *HooksAdapter) UserHasBeenCreated(c *Context, user *model.User) { if _, ok := a.implemented[UserHasBeenCreatedID]; !ok { panic("product hooks must implement UserHasBeenCreated") } @@ -460,7 +460,7 @@ func (a *hooksAdapter) UserHasBeenCreated(c *Context, user *model.User) { } -func (a *hooksAdapter) UserWillLogIn(c *Context, user *model.User) string { +func (a *HooksAdapter) UserWillLogIn(c *Context, user *model.User) string { if _, ok := a.implemented[UserWillLogInID]; !ok { panic("product hooks must implement UserWillLogIn") } @@ -469,7 +469,7 @@ func (a *hooksAdapter) UserWillLogIn(c *Context, user *model.User) string { } -func (a *hooksAdapter) UserHasLoggedIn(c *Context, user *model.User) { +func (a *HooksAdapter) UserHasLoggedIn(c *Context, user *model.User) { if _, ok := a.implemented[UserHasLoggedInID]; !ok { panic("product hooks must implement UserHasLoggedIn") } @@ -478,7 +478,7 @@ func (a *hooksAdapter) UserHasLoggedIn(c *Context, user *model.User) { } -func (a *hooksAdapter) MessageWillBePosted(c *Context, post *model.Post) (*model.Post, string) { +func (a *HooksAdapter) MessageWillBePosted(c *Context, post *model.Post) (*model.Post, string) { if _, ok := a.implemented[MessageWillBePostedID]; !ok { panic("product hooks must implement MessageWillBePosted") } @@ -487,7 +487,7 @@ func (a *hooksAdapter) MessageWillBePosted(c *Context, post *model.Post) (*model } -func (a *hooksAdapter) MessageWillBeUpdated(c *Context, newPost, oldPost *model.Post) (*model.Post, string) { +func (a *HooksAdapter) MessageWillBeUpdated(c *Context, newPost, oldPost *model.Post) (*model.Post, string) { if _, ok := a.implemented[MessageWillBeUpdatedID]; !ok { panic("product hooks must implement MessageWillBeUpdated") } @@ -496,7 +496,7 @@ func (a *hooksAdapter) MessageWillBeUpdated(c *Context, newPost, oldPost *model. } -func (a *hooksAdapter) MessageHasBeenPosted(c *Context, post *model.Post) { +func (a *HooksAdapter) MessageHasBeenPosted(c *Context, post *model.Post) { if _, ok := a.implemented[MessageHasBeenPostedID]; !ok { panic("product hooks must implement MessageHasBeenPosted") } @@ -505,7 +505,7 @@ func (a *hooksAdapter) MessageHasBeenPosted(c *Context, post *model.Post) { } -func (a *hooksAdapter) MessageHasBeenUpdated(c *Context, newPost, oldPost *model.Post) { +func (a *HooksAdapter) MessageHasBeenUpdated(c *Context, newPost, oldPost *model.Post) { if _, ok := a.implemented[MessageHasBeenUpdatedID]; !ok { panic("product hooks must implement MessageHasBeenUpdated") } @@ -514,7 +514,7 @@ func (a *hooksAdapter) MessageHasBeenUpdated(c *Context, newPost, oldPost *model } -func (a *hooksAdapter) ChannelHasBeenCreated(c *Context, channel *model.Channel) { +func (a *HooksAdapter) ChannelHasBeenCreated(c *Context, channel *model.Channel) { if _, ok := a.implemented[ChannelHasBeenCreatedID]; !ok { panic("product hooks must implement ChannelHasBeenCreated") } @@ -523,7 +523,7 @@ func (a *hooksAdapter) ChannelHasBeenCreated(c *Context, channel *model.Channel) } -func (a *hooksAdapter) UserHasJoinedChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) { +func (a *HooksAdapter) UserHasJoinedChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) { if _, ok := a.implemented[UserHasJoinedChannelID]; !ok { panic("product hooks must implement UserHasJoinedChannel") } @@ -532,7 +532,7 @@ func (a *hooksAdapter) UserHasJoinedChannel(c *Context, channelMember *model.Cha } -func (a *hooksAdapter) UserHasLeftChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) { +func (a *HooksAdapter) UserHasLeftChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) { if _, ok := a.implemented[UserHasLeftChannelID]; !ok { panic("product hooks must implement UserHasLeftChannel") } @@ -541,7 +541,7 @@ func (a *hooksAdapter) UserHasLeftChannel(c *Context, channelMember *model.Chann } -func (a *hooksAdapter) UserHasJoinedTeam(c *Context, teamMember *model.TeamMember, actor *model.User) { +func (a *HooksAdapter) UserHasJoinedTeam(c *Context, teamMember *model.TeamMember, actor *model.User) { if _, ok := a.implemented[UserHasJoinedTeamID]; !ok { panic("product hooks must implement UserHasJoinedTeam") } @@ -550,7 +550,7 @@ func (a *hooksAdapter) UserHasJoinedTeam(c *Context, teamMember *model.TeamMembe } -func (a *hooksAdapter) UserHasLeftTeam(c *Context, teamMember *model.TeamMember, actor *model.User) { +func (a *HooksAdapter) UserHasLeftTeam(c *Context, teamMember *model.TeamMember, actor *model.User) { if _, ok := a.implemented[UserHasLeftTeamID]; !ok { panic("product hooks must implement UserHasLeftTeam") } @@ -559,7 +559,7 @@ func (a *hooksAdapter) UserHasLeftTeam(c *Context, teamMember *model.TeamMember, } -func (a *hooksAdapter) FileWillBeUploaded(c *Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) { +func (a *HooksAdapter) FileWillBeUploaded(c *Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) { if _, ok := a.implemented[FileWillBeUploadedID]; !ok { panic("product hooks must implement FileWillBeUploaded") } @@ -568,7 +568,7 @@ func (a *hooksAdapter) FileWillBeUploaded(c *Context, info *model.FileInfo, file } -func (a *hooksAdapter) ReactionHasBeenAdded(c *Context, reaction *model.Reaction) { +func (a *HooksAdapter) ReactionHasBeenAdded(c *Context, reaction *model.Reaction) { if _, ok := a.implemented[ReactionHasBeenAddedID]; !ok { panic("product hooks must implement ReactionHasBeenAdded") } @@ -577,7 +577,7 @@ func (a *hooksAdapter) ReactionHasBeenAdded(c *Context, reaction *model.Reaction } -func (a *hooksAdapter) ReactionHasBeenRemoved(c *Context, reaction *model.Reaction) { +func (a *HooksAdapter) ReactionHasBeenRemoved(c *Context, reaction *model.Reaction) { if _, ok := a.implemented[ReactionHasBeenRemovedID]; !ok { panic("product hooks must implement ReactionHasBeenRemoved") } @@ -586,7 +586,7 @@ func (a *hooksAdapter) ReactionHasBeenRemoved(c *Context, reaction *model.Reacti } -func (a *hooksAdapter) OnPluginClusterEvent(c *Context, ev model.PluginClusterEvent) { +func (a *HooksAdapter) OnPluginClusterEvent(c *Context, ev model.PluginClusterEvent) { if _, ok := a.implemented[OnPluginClusterEventID]; !ok { panic("product hooks must implement OnPluginClusterEvent") } @@ -595,7 +595,7 @@ func (a *hooksAdapter) OnPluginClusterEvent(c *Context, ev model.PluginClusterEv } -func (a *hooksAdapter) OnWebSocketConnect(webConnID, userID string) { +func (a *HooksAdapter) OnWebSocketConnect(webConnID, userID string) { if _, ok := a.implemented[OnWebSocketConnectID]; !ok { panic("product hooks must implement OnWebSocketConnect") } @@ -604,7 +604,7 @@ func (a *hooksAdapter) OnWebSocketConnect(webConnID, userID string) { } -func (a *hooksAdapter) OnWebSocketDisconnect(webConnID, userID string) { +func (a *HooksAdapter) OnWebSocketDisconnect(webConnID, userID string) { if _, ok := a.implemented[OnWebSocketDisconnectID]; !ok { panic("product hooks must implement OnWebSocketDisconnect") } @@ -613,7 +613,7 @@ func (a *hooksAdapter) OnWebSocketDisconnect(webConnID, userID string) { } -func (a *hooksAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, req *model.WebSocketRequest) { +func (a *HooksAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, req *model.WebSocketRequest) { if _, ok := a.implemented[WebSocketMessageHasBeenPostedID]; !ok { panic("product hooks must implement WebSocketMessageHasBeenPosted") } @@ -622,7 +622,7 @@ func (a *hooksAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, r } -func (a *hooksAdapter) RunDataRetention(nowTime, batchSize int64) (int64, error) { +func (a *HooksAdapter) RunDataRetention(nowTime, batchSize int64) (int64, error) { if _, ok := a.implemented[RunDataRetentionID]; !ok { panic("product hooks must implement RunDataRetention") } @@ -631,7 +631,7 @@ func (a *hooksAdapter) RunDataRetention(nowTime, batchSize int64) (int64, error) } -func (a *hooksAdapter) OnInstall(c *Context, event model.OnInstallEvent) error { +func (a *HooksAdapter) OnInstall(c *Context, event model.OnInstallEvent) error { if _, ok := a.implemented[OnInstallID]; !ok { panic("product hooks must implement OnInstall") } @@ -640,7 +640,7 @@ func (a *hooksAdapter) OnInstall(c *Context, event model.OnInstallEvent) error { } -func (a *hooksAdapter) OnSendDailyTelemetry() { +func (a *HooksAdapter) OnSendDailyTelemetry() { if _, ok := a.implemented[OnSendDailyTelemetryID]; !ok { panic("product hooks must implement OnSendDailyTelemetry") } @@ -649,7 +649,7 @@ func (a *hooksAdapter) OnSendDailyTelemetry() { } -func (a *hooksAdapter) OnCloudLimitsUpdated(limits *model.ProductLimits) { +func (a *HooksAdapter) OnCloudLimitsUpdated(limits *model.ProductLimits) { if _, ok := a.implemented[OnCloudLimitsUpdatedID]; !ok { panic("product hooks must implement OnCloudLimitsUpdated") } @@ -658,7 +658,7 @@ func (a *hooksAdapter) OnCloudLimitsUpdated(limits *model.ProductLimits) { } -func (a *hooksAdapter) UserHasPermissionToCollection(c *Context, userID string, collectionType, collectionId string, permission *model.Permission) (bool, error) { +func (a *HooksAdapter) UserHasPermissionToCollection(c *Context, userID string, collectionType, collectionId string, permission *model.Permission) (bool, error) { if _, ok := a.implemented[UserHasPermissionToCollectionID]; !ok { panic("product hooks must implement UserHasPermissionToCollection") } @@ -667,7 +667,7 @@ func (a *hooksAdapter) UserHasPermissionToCollection(c *Context, userID string, } -func (a *hooksAdapter) GetAllCollectionIDsForUser(c *Context, userID, collectionType string) ([]string, error) { +func (a *HooksAdapter) GetAllCollectionIDsForUser(c *Context, userID, collectionType string) ([]string, error) { if _, ok := a.implemented[GetAllCollectionIDsForUserID]; !ok { panic("product hooks must implement GetAllCollectionIDsForUser") } @@ -676,7 +676,7 @@ func (a *hooksAdapter) GetAllCollectionIDsForUser(c *Context, userID, collection } -func (a *hooksAdapter) GetAllUserIdsForCollection(c *Context, collectionType, collectionID string) ([]string, error) { +func (a *HooksAdapter) GetAllUserIdsForCollection(c *Context, collectionType, collectionID string) ([]string, error) { if _, ok := a.implemented[GetAllUserIdsForCollectionID]; !ok { panic("product hooks must implement GetAllUserIdsForCollection") } @@ -685,7 +685,7 @@ func (a *hooksAdapter) GetAllUserIdsForCollection(c *Context, collectionType, co } -func (a *hooksAdapter) GetTopicRedirect(c *Context, topicType, topicID string) (string, error) { +func (a *HooksAdapter) GetTopicRedirect(c *Context, topicType, topicID string) (string, error) { if _, ok := a.implemented[GetTopicRedirectID]; !ok { panic("product hooks must implement GetTopicRedirect") } @@ -694,7 +694,7 @@ func (a *hooksAdapter) GetTopicRedirect(c *Context, topicType, topicID string) ( } -func (a *hooksAdapter) GetCollectionMetadataByIds(c *Context, collectionType string, collectionIds []string) (map[string]*model.CollectionMetadata, error) { +func (a *HooksAdapter) GetCollectionMetadataByIds(c *Context, collectionType string, collectionIds []string) (map[string]*model.CollectionMetadata, error) { if _, ok := a.implemented[GetCollectionMetadataByIdsID]; !ok { panic("product hooks must implement GetCollectionMetadataByIds") } @@ -703,7 +703,7 @@ func (a *hooksAdapter) GetCollectionMetadataByIds(c *Context, collectionType str } -func (a *hooksAdapter) GetTopicMetadataByIds(c *Context, topicType string, topicIds []string) (map[string]*model.TopicMetadata, error) { +func (a *HooksAdapter) GetTopicMetadataByIds(c *Context, topicType string, topicIds []string) (map[string]*model.TopicMetadata, error) { if _, ok := a.implemented[GetTopicMetadataByIdsID]; !ok { panic("product hooks must implement GetTopicMetadataByIds") } diff --git a/product/hooks.go b/product/hooks.go new file mode 100644 index 0000000000..a2515c9a7e --- /dev/null +++ b/product/hooks.go @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package product + +import ( + "sync" + "time" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/plugin" +) + +type HooksManager struct { + registeredProducts sync.Map + metrics einterfaces.MetricsInterface +} + +func NewHooksManager(metrics einterfaces.MetricsInterface) *HooksManager { + return &HooksManager{ + metrics: metrics, + } +} + +func (m *HooksManager) AddProduct(productID string, hooks any) error { + prod, err := plugin.NewAdapter(hooks) + if err != nil { + return err + } + + rp := &plugin.RegisteredProduct{ + ProductID: productID, + Adapter: prod, + } + + m.registeredProducts.Store(productID, rp) + + return nil +} + +func (m *HooksManager) RemoveProduct(productID string) { + m.registeredProducts.Delete(productID) +} + +func (m *HooksManager) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) { + startTime := time.Now() + + m.registeredProducts.Range(func(key, value any) bool { + rp := value.(*plugin.RegisteredProduct) + + if !rp.Implements(hookId) { + return true + } + + hookStartTime := time.Now() + result := hookRunnerFunc(rp.Adapter) + + if m.metrics != nil { + elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second) + m.metrics.ObservePluginMultiHookIterationDuration(rp.ProductID, elapsedTime) + } + + return result + }) + + if m.metrics != nil { + elapsedTime := float64(time.Since(startTime)) / float64(time.Second) + m.metrics.ObservePluginMultiHookDuration(elapsedTime) + } +} + +func (m *HooksManager) HooksForProduct(id string) plugin.Hooks { + if value, ok := m.registeredProducts.Load(id); ok { + rp := value.(*plugin.RegisteredProduct) + return rp.Adapter + } + + return nil +} diff --git a/services/cache/lru_striped.go b/services/cache/lru_striped.go index 3e11933bfa..5ce231b0c4 100644 --- a/services/cache/lru_striped.go +++ b/services/cache/lru_striped.go @@ -29,9 +29,9 @@ import ( // cache where a simple LRU wouldn't have. Example: // // Two buckets B1 and B2, of max size 2 each, meaning, theoretically, a max size of 4: -// * Say you have a set of 3 keys, they could fill an entire LRU cache. -// * But if all those keys are assigned to a single bucket B1, the first key will be evicted from B1 -// * B2 will remain empty, even though there was enough memory allocated +// - Say you have a set of 3 keys, they could fill an entire LRU cache. +// - But if all those keys are assigned to a single bucket B1, the first key will be evicted from B1 +// - B2 will remain empty, even though there was enough memory allocated // // With 4 buckets and random UUIDs as keys, the amount of false evictions is around 5%. // diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index 3e2bba1566..9db3a36d00 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -101,6 +101,7 @@ type TelemetryService struct { rudderClient rudder.Client TelemetryID string timestampLastTelemetrySent time.Time + verbose bool } type RudderConfig struct { @@ -108,12 +109,13 @@ type RudderConfig struct { DataplaneURL string } -func New(srv ServerIface, dbStore store.Store, searchEngine *searchengine.Broker, log *mlog.Logger) *TelemetryService { +func New(srv ServerIface, dbStore store.Store, searchEngine *searchengine.Broker, log *mlog.Logger, verbose bool) *TelemetryService { service := &TelemetryService{ srv: srv, dbStore: dbStore, searchEngine: searchEngine, log: log, + verbose: verbose, } service.ensureTelemetryID() return service @@ -128,7 +130,7 @@ func (ts *TelemetryService) ensureTelemetryID() { systemID := &model.System{Name: model.SystemTelemetryId, Value: id} systemID, err := ts.dbStore.System().InsertIfExists(systemID) if err != nil { - mlog.Error("unable to get the telemetry ID", mlog.Err(err)) + ts.log.Error("unable to get the telemetry ID", mlog.Err(err)) return } @@ -173,12 +175,15 @@ func (ts *TelemetryService) SendTelemetry(event string, properties map[string]an if installationId := os.Getenv("MM_CLOUD_INSTALLATION_ID"); installationId != "" { context = &rudder.Context{Traits: map[string]any{"installationId": installationId}} } - ts.rudderClient.Enqueue(rudder.Track{ + err := ts.rudderClient.Enqueue(rudder.Track{ Event: event, UserId: ts.TelemetryID, Properties: properties, Context: context, }) + if err != nil { + ts.log.Warn("Error sending telemetry", mlog.Err(err)) + } } } @@ -450,6 +455,8 @@ func (ts *TelemetryService) trackConfig() { "restrict_link_previews": isDefault(*cfg.ServiceSettings.RestrictLinkPreviews, ""), "enable_custom_groups": *cfg.ServiceSettings.EnableCustomGroups, "post_priority": *cfg.ServiceSettings.PostPriority, + "self_hosted_first_time_purchase": *cfg.ServiceSettings.SelfHostedFirstTimePurchase, + "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, }) ts.SendTelemetry(TrackConfigTeam, map[string]any{ @@ -732,6 +739,7 @@ func (ts *TelemetryService) trackConfig() { "enable_shared_channels": *cfg.ExperimentalSettings.EnableSharedChannels, "enable_remote_cluster_service": *cfg.ExperimentalSettings.EnableRemoteClusterService && cfg.FeatureFlags.EnableRemoteClusterService, "enable_app_bar": *cfg.ExperimentalSettings.EnableAppBar, + "patch_plugins_react_dom": *cfg.ExperimentalSettings.PatchPluginsReactDOM, }) ts.SendTelemetry(TrackConfigAnalytics, map[string]any{ @@ -1142,62 +1150,62 @@ func (ts *TelemetryService) trackElasticsearch() { func (ts *TelemetryService) trackGroups() { groupCount, err := ts.dbStore.Group().GroupCount() if err != nil { - mlog.Debug("Could not get group_count", mlog.Err(err)) + ts.log.Debug("Could not get group_count", mlog.Err(err)) } ldapGroupCount, err := ts.dbStore.Group().GroupCountBySource(model.GroupSourceLdap) if err != nil { - mlog.Debug("Could not get group_count", mlog.Err(err)) + ts.log.Debug("Could not get group_count", mlog.Err(err)) } customGroupCount, err := ts.dbStore.Group().GroupCountBySource(model.GroupSourceCustom) if err != nil { - mlog.Debug("Could not get group_count", mlog.Err(err)) + ts.log.Debug("Could not get group_count", mlog.Err(err)) } groupTeamCount, err := ts.dbStore.Group().GroupTeamCount() if err != nil { - mlog.Debug("Could not get group_team_count", mlog.Err(err)) + ts.log.Debug("Could not get group_team_count", mlog.Err(err)) } groupChannelCount, err := ts.dbStore.Group().GroupChannelCount() if err != nil { - mlog.Debug("Could not get group_channel_count", mlog.Err(err)) + ts.log.Debug("Could not get group_channel_count", mlog.Err(err)) } groupSyncedTeamCount, nErr := ts.dbStore.Team().GroupSyncedTeamCount() if nErr != nil { - mlog.Debug("Could not get group_synced_team_count", mlog.Err(nErr)) + ts.log.Debug("Could not get group_synced_team_count", mlog.Err(nErr)) } groupSyncedChannelCount, nErr := ts.dbStore.Channel().GroupSyncedChannelCount() if nErr != nil { - mlog.Debug("Could not get group_synced_channel_count", mlog.Err(nErr)) + ts.log.Debug("Could not get group_synced_channel_count", mlog.Err(nErr)) } groupMemberCount, err := ts.dbStore.Group().GroupMemberCount() if err != nil { - mlog.Debug("Could not get group_member_count", mlog.Err(err)) + ts.log.Debug("Could not get group_member_count", mlog.Err(err)) } distinctGroupMemberCount, err := ts.dbStore.Group().DistinctGroupMemberCount() if err != nil { - mlog.Debug("Could not get distinct_group_member_count", mlog.Err(err)) + ts.log.Debug("Could not get distinct_group_member_count", mlog.Err(err)) } distinctCustomGroupMemberCount, err := ts.dbStore.Group().DistinctGroupMemberCountForSource(model.GroupSourceCustom) if err != nil { - mlog.Debug("Could not get distinct_custom_group_member_count", mlog.Err(err)) + ts.log.Debug("Could not get distinct_custom_group_member_count", mlog.Err(err)) } distinctLdapGroupMemberCount, err := ts.dbStore.Group().DistinctGroupMemberCountForSource(model.GroupSourceLdap) if err != nil { - mlog.Debug("Could not get distinct_ldap_group_member_count", mlog.Err(err)) + ts.log.Debug("Could not get distinct_ldap_group_member_count", mlog.Err(err)) } groupCountWithAllowReference, err := ts.dbStore.Group().GroupCountWithAllowReference() if err != nil { - mlog.Debug("Could not get group_count_with_allow_reference", mlog.Err(err)) + ts.log.Debug("Could not get group_count_with_allow_reference", mlog.Err(err)) } ts.SendTelemetry(TrackGroups, map[string]any{ @@ -1219,44 +1227,44 @@ func (ts *TelemetryService) trackGroups() { func (ts *TelemetryService) trackChannelModeration() { channelSchemeCount, err := ts.dbStore.Scheme().CountByScope(model.SchemeScopeChannel) if err != nil { - mlog.Debug("Could not get channel_scheme_count", mlog.Err(err)) + ts.log.Debug("Could not get channel_scheme_count", mlog.Err(err)) } createPostUser, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionCreatePost.Id, model.RoleScopeChannel, model.RoleTypeUser) if err != nil { - mlog.Debug("Could not get create_post_user_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get create_post_user_disabled_count", mlog.Err(err)) } createPostGuest, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionCreatePost.Id, model.RoleScopeChannel, model.RoleTypeGuest) if err != nil { - mlog.Debug("Could not get create_post_guest_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get create_post_guest_disabled_count", mlog.Err(err)) } // only need to track one of 'add_reaction' or 'remove_reaction` because they're both toggled together by the channel moderation feature postReactionsUser, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionAddReaction.Id, model.RoleScopeChannel, model.RoleTypeUser) if err != nil { - mlog.Debug("Could not get post_reactions_user_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get post_reactions_user_disabled_count", mlog.Err(err)) } postReactionsGuest, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionAddReaction.Id, model.RoleScopeChannel, model.RoleTypeGuest) if err != nil { - mlog.Debug("Could not get post_reactions_guest_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get post_reactions_guest_disabled_count", mlog.Err(err)) } // only need to track one of 'manage_public_channel_members' or 'manage_private_channel_members` because they're both toggled together by the channel moderation feature manageMembersUser, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionManagePublicChannelMembers.Id, model.RoleScopeChannel, model.RoleTypeUser) if err != nil { - mlog.Debug("Could not get manage_members_user_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get manage_members_user_disabled_count", mlog.Err(err)) } useChannelMentionsUser, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionUseChannelMentions.Id, model.RoleScopeChannel, model.RoleTypeUser) if err != nil { - mlog.Debug("Could not get use_channel_mentions_user_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get use_channel_mentions_user_disabled_count", mlog.Err(err)) } useChannelMentionsGuest, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionUseChannelMentions.Id, model.RoleScopeChannel, model.RoleTypeGuest) if err != nil { - mlog.Debug("Could not get use_channel_mentions_guest_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get use_channel_mentions_guest_disabled_count", mlog.Err(err)) } ts.SendTelemetry(TrackChannelModeration, map[string]any{ @@ -1280,14 +1288,14 @@ func (ts *TelemetryService) initRudder(endpoint string, rudderKey string) { config := rudder.Config{} config.Logger = rudder.StdLogger(ts.log.With(mlog.String("source", "rudder")).StdLogger(mlog.LvlDebug)) config.Endpoint = endpoint + config.Verbose = ts.verbose // For testing if endpoint != RudderDataplaneURL { - config.Verbose = true config.BatchSize = 1 } client, err := rudder.NewWithConfig(rudderKey, endpoint, config) if err != nil { - mlog.Error("Failed to create Rudder instance", mlog.Err(err)) + ts.log.Error("Failed to create Rudder instance", mlog.Err(err)) return } client.Enqueue(rudder.Identify{ @@ -1413,7 +1421,7 @@ func (ts *TelemetryService) trackPluginConfig(cfg *model.Config, marketplaceURL pluginsEnvironment := ts.srv.GetPluginsEnvironment() if pluginsEnvironment != nil { if plugins, appErr := pluginsEnvironment.Available(); appErr != nil { - mlog.Warn("Unable to add plugin versions to telemetry", mlog.Err(appErr)) + ts.log.Warn("Unable to add plugin versions to telemetry", mlog.Err(appErr)) } else { // If marketplace request failed, use predefined list if marketplacePlugins == nil { diff --git a/services/telemetry/telemetry_test.go b/services/telemetry/telemetry_test.go index 31ab7656ad..389cea5f37 100644 --- a/services/telemetry/telemetry_test.go +++ b/services/telemetry/telemetry_test.go @@ -118,7 +118,7 @@ func makeTelemetryServiceAndReceiver(t *testing.T, cloudLicense bool) (*Telemetr pchan <- p })) - service := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger) + service := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger, false) service.TelemetryID = testTelemetryID service.rudderClient = nil service.initRudder(receiver.URL, RudderKey) @@ -165,6 +165,7 @@ func initializeMocks(cfg *model.Config, cloudLicense bool) (*mocks.ServerIface, func(m *model.Manifest) plugin.API { return pluginsAPIMock }, nil, pluginDir, webappPluginDir, + false, logger, nil) serverIfaceMock.On("GetPluginsEnvironment").Return(pluginEnv, nil) @@ -294,7 +295,7 @@ func TestEnsureTelemetryID(t *testing.T) { testLogger, _ := mlog.NewLogger() - telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger) + telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger, false) assert.Equal(t, "test", telemetryService.TelemetryID) telemetryService.ensureTelemetryID() @@ -327,7 +328,7 @@ func TestEnsureTelemetryID(t *testing.T) { testLogger, _ := mlog.NewLogger() - telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger) + telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger, false) assert.Equal(t, generatedID, telemetryService.TelemetryID) }) @@ -347,7 +348,7 @@ func TestEnsureTelemetryID(t *testing.T) { testLogger, _ := mlog.NewLogger() - telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger) + telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger, false) assert.Equal(t, "", telemetryService.TelemetryID) }) } diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 513749fd4b..aa41c28ab5 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -27,6 +27,7 @@ type OpenTracingLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -37,6 +38,8 @@ type OpenTracingLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore + PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore ReactionStore store.ReactionStore @@ -91,6 +94,10 @@ func (s *OpenTracingLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *OpenTracingLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *OpenTracingLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -131,6 +138,14 @@ func (s *OpenTracingLayer) Post() store.PostStore { return s.PostStore } +func (s *OpenTracingLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + +func (s *OpenTracingLayer) PostPriority() store.PostPriorityStore { + return s.PostPriorityStore +} + func (s *OpenTracingLayer) Preference() store.PreferenceStore { return s.PreferenceStore } @@ -251,6 +266,11 @@ type OpenTracingLayerComplianceStore struct { Root *OpenTracingLayer } +type OpenTracingLayerDraftStore struct { + store.DraftStore + Root *OpenTracingLayer +} + type OpenTracingLayerEmojiStore struct { store.EmojiStore Root *OpenTracingLayer @@ -301,6 +321,16 @@ type OpenTracingLayerPostStore struct { Root *OpenTracingLayer } +type OpenTracingLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *OpenTracingLayer +} + +type OpenTracingLayerPostPriorityStore struct { + store.PostPriorityStore + Root *OpenTracingLayer +} + type OpenTracingLayerPreferenceStore struct { store.PreferenceStore Root *OpenTracingLayer @@ -666,6 +696,19 @@ func (s *OpenTracingLayerChannelStore) ClearCaches() { } +func (s *OpenTracingLayerChannelStore) ClearMembersForUserCache() { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.ClearMembersForUserCache") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + s.ChannelStore.ClearMembersForUserCache() + +} + func (s *OpenTracingLayerChannelStore) ClearSidebarOnTeamLeave(userID string, teamID string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.ClearSidebarOnTeamLeave") @@ -702,6 +745,24 @@ func (s *OpenTracingLayerChannelStore) CountPostsAfter(channelID string, timesta return result, resultVar1, err } +func (s *OpenTracingLayerChannelStore) CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.CountUrgentPostsAfter") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelStore.CountUrgentPostsAfter(channelID, timestamp, userID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerChannelStore) CreateDirectChannel(userID *model.User, otherUserID *model.User, channelOptions ...model.ChannelOption) (*model.Channel, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.CreateDirectChannel") @@ -1867,7 +1928,7 @@ func (s *OpenTracingLayerChannelStore) GroupSyncedChannelCount() (int64, error) return result, err } -func (s *OpenTracingLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error { +func (s *OpenTracingLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool, isUrgent bool) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.IncrementMentionCount") s.Root.Store.SetContext(newCtx) @@ -1876,7 +1937,7 @@ func (s *OpenTracingLayerChannelStore) IncrementMentionCount(channelID string, u }() defer span.Finish() - err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot) + err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot, isUrgent) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -2439,7 +2500,7 @@ func (s *OpenTracingLayerChannelStore) UpdateLastViewedAt(channelIds []string, u return result, err } -func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { +func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.UpdateLastViewedAtPost") s.Root.Store.SetContext(newCtx) @@ -2448,7 +2509,7 @@ func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model. }() defer span.Finish() - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -3190,6 +3251,96 @@ func (s *OpenTracingLayerComplianceStore) Update(compliance *model.Compliance) ( return result, err } +func (s *OpenTracingLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.DraftStore.Delete(userID, channelID, rootID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerDraftStore) Get(userID string, channelID string, rootID string, includeDeleted bool) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Get(userID, channelID, rootID, includeDeleted) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.GetDraftsForUser") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Save(d) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Update") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Update(d) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "EmojiStore.Delete") @@ -6516,6 +6667,132 @@ func (s *OpenTracingLayerPostStore) Update(newPost *model.Post, oldPost *model.P return result, err } +func (s *OpenTracingLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PostAcknowledgementStore.Delete(acknowledgement) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.Get(postID, userID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.GetForPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.GetForPost(postID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.GetForPosts") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPriorityStore.GetForPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostPriorityStore.GetForPost(postId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostPriorityStore) GetForPosts(ids []string) ([]*model.PostPriority, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPriorityStore.GetForPosts") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostPriorityStore.GetForPosts(ids) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerPreferenceStore) CleanupFlagsBatch(limit int64) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PreferenceStore.CleanupFlagsBatch") @@ -9872,7 +10149,7 @@ func (s *OpenTracingLayerThreadStore) GetMembershipsForUser(userId string, teamI return result, err } -func (s *OpenTracingLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { +func (s *OpenTracingLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.GetTeamsUnreadForUser") s.Root.Store.SetContext(newCtx) @@ -9881,7 +10158,7 @@ func (s *OpenTracingLayerThreadStore) GetTeamsUnreadForUser(userID string, teamI }() defer span.Finish() - result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs) + result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs, includeUrgentMentionCount) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -9908,7 +10185,7 @@ func (s *OpenTracingLayerThreadStore) GetThreadFollowers(threadID string, fetchO return result, err } -func (s *OpenTracingLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { +func (s *OpenTracingLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool, postPriorityIsEnabled bool) (*model.ThreadResponse, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.GetThreadForUser") s.Root.Store.SetContext(newCtx) @@ -9917,7 +10194,7 @@ func (s *OpenTracingLayerThreadStore) GetThreadForUser(threadMembership *model.T }() defer span.Finish() - result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended) + result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended, postPriorityIsEnabled) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -10052,6 +10329,24 @@ func (s *OpenTracingLayerThreadStore) GetTotalUnreadThreads(userId string, teamI return result, err } +func (s *OpenTracingLayerThreadStore) GetTotalUnreadUrgentMentions(userId string, teamID string, opts model.GetUserThreadsOpts) (int64, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.GetTotalUnreadUrgentMentions") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ThreadStore.GetTotalUnreadUrgentMentions(userId, teamID, opts) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerThreadStore) MaintainMembership(userID string, postID string, opts store.ThreadMembershipOpts) (*model.ThreadMembership, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.MaintainMembership") @@ -12499,6 +12794,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.CommandStore = &OpenTracingLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &OpenTracingLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &OpenTracingLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &OpenTracingLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &OpenTracingLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &OpenTracingLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &OpenTracingLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} @@ -12509,6 +12805,8 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.OAuthStore = &OpenTracingLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &OpenTracingLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &OpenTracingLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &OpenTracingLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} + newStore.PostPriorityStore = &OpenTracingLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &OpenTracingLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &OpenTracingLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} newStore.ReactionStore = &OpenTracingLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index fdf6b2c9e9..5aca1535cb 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -30,6 +30,7 @@ type RetryLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -40,6 +41,8 @@ type RetryLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore + PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore ReactionStore store.ReactionStore @@ -94,6 +97,10 @@ func (s *RetryLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *RetryLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *RetryLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -134,6 +141,14 @@ func (s *RetryLayer) Post() store.PostStore { return s.PostStore } +func (s *RetryLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + +func (s *RetryLayer) PostPriority() store.PostPriorityStore { + return s.PostPriorityStore +} + func (s *RetryLayer) Preference() store.PreferenceStore { return s.PreferenceStore } @@ -254,6 +269,11 @@ type RetryLayerComplianceStore struct { Root *RetryLayer } +type RetryLayerDraftStore struct { + store.DraftStore + Root *RetryLayer +} + type RetryLayerEmojiStore struct { store.EmojiStore Root *RetryLayer @@ -304,6 +324,16 @@ type RetryLayerPostStore struct { Root *RetryLayer } +type RetryLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *RetryLayer +} + +type RetryLayerPostPriorityStore struct { + store.PostPriorityStore + Root *RetryLayer +} + type RetryLayerPreferenceStore struct { store.PreferenceStore Root *RetryLayer @@ -720,6 +750,12 @@ func (s *RetryLayerChannelStore) ClearCaches() { } +func (s *RetryLayerChannelStore) ClearMembersForUserCache() { + + s.ChannelStore.ClearMembersForUserCache() + +} + func (s *RetryLayerChannelStore) ClearSidebarOnTeamLeave(userID string, teamID string) error { tries := 0 @@ -762,6 +798,27 @@ func (s *RetryLayerChannelStore) CountPostsAfter(channelID string, timestamp int } +func (s *RetryLayerChannelStore) CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) { + + tries := 0 + for { + result, err := s.ChannelStore.CountUrgentPostsAfter(channelID, timestamp, userID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelStore) CreateDirectChannel(userID *model.User, otherUserID *model.User, channelOptions ...model.ChannelOption) (*model.Channel, error) { tries := 0 @@ -2112,11 +2169,11 @@ func (s *RetryLayerChannelStore) GroupSyncedChannelCount() (int64, error) { } -func (s *RetryLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error { +func (s *RetryLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool, isUrgent bool) error { tries := 0 for { - err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot) + err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot, isUrgent) if err == nil { return nil } @@ -2706,11 +2763,11 @@ func (s *RetryLayerChannelStore) UpdateLastViewedAt(channelIds []string, userID } -func (s *RetryLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { +func (s *RetryLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { tries := 0 for { - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) if err == nil { return result, nil } @@ -3573,6 +3630,111 @@ func (s *RetryLayerComplianceStore) Update(compliance *model.Compliance) (*model } +func (s *RetryLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + + tries := 0 + for { + err := s.DraftStore.Delete(userID, channelID, rootID) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Get(userID string, channelID string, rootID string, includeDeleted bool) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Get(userID, channelID, rootID, includeDeleted) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Save(d) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Update(d) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { tries := 0 @@ -7389,6 +7551,153 @@ func (s *RetryLayerPostStore) Update(newPost *model.Post, oldPost *model.Post) ( } +func (s *RetryLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + + tries := 0 + for { + err := s.PostAcknowledgementStore.Delete(acknowledgement) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.Get(postID, userID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.GetForPost(postID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + + tries := 0 + for { + result, err := s.PostPriorityStore.GetForPost(postId) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPriorityStore) GetForPosts(ids []string) ([]*model.PostPriority, error) { + + tries := 0 + for { + result, err := s.PostPriorityStore.GetForPosts(ids) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerPreferenceStore) CleanupFlagsBatch(limit int64) (int64, error) { tries := 0 @@ -11286,11 +11595,11 @@ func (s *RetryLayerThreadStore) GetMembershipsForUser(userId string, teamID stri } -func (s *RetryLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { +func (s *RetryLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { tries := 0 for { - result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs) + result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs, includeUrgentMentionCount) if err == nil { return result, nil } @@ -11328,11 +11637,11 @@ func (s *RetryLayerThreadStore) GetThreadFollowers(threadID string, fetchOnlyAct } -func (s *RetryLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { +func (s *RetryLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool, postPriorityIsEnabled bool) (*model.ThreadResponse, error) { tries := 0 for { - result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended) + result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended, postPriorityIsEnabled) if err == nil { return result, nil } @@ -11496,6 +11805,27 @@ func (s *RetryLayerThreadStore) GetTotalUnreadThreads(userId string, teamID stri } +func (s *RetryLayerThreadStore) GetTotalUnreadUrgentMentions(userId string, teamID string, opts model.GetUserThreadsOpts) (int64, error) { + + tries := 0 + for { + result, err := s.ThreadStore.GetTotalUnreadUrgentMentions(userId, teamID, opts) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerThreadStore) MaintainMembership(userID string, postID string, opts store.ThreadMembershipOpts) (*model.ThreadMembership, error) { tries := 0 @@ -14251,6 +14581,7 @@ func New(childStore store.Store) *RetryLayer { newStore.CommandStore = &RetryLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &RetryLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &RetryLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &RetryLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &RetryLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &RetryLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &RetryLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} @@ -14261,6 +14592,8 @@ func New(childStore store.Store) *RetryLayer { newStore.OAuthStore = &RetryLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &RetryLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &RetryLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &RetryLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} + newStore.PostPriorityStore = &RetryLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &RetryLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &RetryLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} newStore.ReactionStore = &RetryLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} diff --git a/store/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index f71ce9a55d..701cf962f4 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -54,6 +54,9 @@ func genStore() *mocks.Store { mock.On("UserTermsOfService").Return(&mocks.UserTermsOfServiceStore{}) mock.On("Webhook").Return(&mocks.WebhookStore{}) mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{}) + mock.On("Draft").Return(&mocks.DraftStore{}) + mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) + mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) return mock } diff --git a/store/searchlayer/user_layer.go b/store/searchlayer/user_layer.go index 268b18cacc..b754fdf940 100644 --- a/store/searchlayer/user_layer.go +++ b/store/searchlayer/user_layer.go @@ -149,6 +149,7 @@ func (s *SearchUserStore) autocompleteUsersInChannelByEngine(engine searchengine } // getListOfAllowedChannels return the list of allowed channels to search user based on the +// // next scenarios: // - If there isn't view restrictions (team or channel) and no team id to filter them, then all // channels are allowed (nil return) diff --git a/store/searchtest/post_layer.go b/store/searchtest/post_layer.go index 1d03edf7f7..5bfa05a781 100644 --- a/store/searchtest/post_layer.go +++ b/store/searchtest/post_layer.go @@ -35,9 +35,16 @@ var searchPostStoreTests = []searchTest{ Tags: []string{EnginePostgres, EngineMySql, EngineElasticSearch}, }, { + // Postgres supports search with and without quotes Name: "Should be able to search for email addresses with or without quotes", Fn: testSearchEmailAddresses, - Tags: []string{EngineElasticSearch}, + Tags: []string{EnginePostgres, EngineElasticSearch}, + }, + { + // MySql supports search with quotes only + Name: "Should be able to search for email addresses with quotes", + Fn: testSearchEmailAddressesWithQuotes, + Tags: []string{EngineMySql}, }, { Name: "Should be able to search when markdown underscores are applied", @@ -242,11 +249,6 @@ var searchPostStoreTests = []searchTest{ Fn: testSlashShouldNotBeCharSeparator, Tags: []string{EngineMySql, EngineElasticSearch}, }, - { - Name: "Should be able to search emails without quoting them", - Fn: testSearchEmailsWithoutQuotes, - Tags: []string{EngineElasticSearch}, - }, { Name: "Should be able to search in comments", Fn: testSupportSearchInComments, @@ -366,9 +368,9 @@ func testSearchExactPhraseInQuotes(t *testing.T, th *SearchTestHelper) { } func testSearchEmailAddresses(t *testing.T, th *SearchTestHelper) { - p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "test email test@test.com", "", model.PostTypeDefault, 0, false) + p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "email test@test.com", "", model.PostTypeDefault, 0, false) require.NoError(t, err) - _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "test email test2@test.com", "", model.PostTypeDefault, 0, false) + _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "email test2@test.com", "", model.PostTypeDefault, 0, false) require.NoError(t, err) defer th.deleteUserPosts(th.User.Id) @@ -391,6 +393,21 @@ func testSearchEmailAddresses(t *testing.T, th *SearchTestHelper) { }) } +func testSearchEmailAddressesWithQuotes(t *testing.T, th *SearchTestHelper) { + p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "email test@test.com", "", model.PostTypeDefault, 0, false) + require.NoError(t, err) + _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "email test2@test.com", "", model.PostTypeDefault, 0, false) + require.NoError(t, err) + defer th.deleteUserPosts(th.User.Id) + + params := &model.SearchParams{Terms: "\"test@test.com\""} + results, err := th.Store.Post().SearchPostsForUser([]*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20) + require.NoError(t, err) + + require.Len(t, results.Posts, 1) + th.checkPostInSearchResults(t, p1.Id, results.Posts) +} + func testSearchMarkdownUnderscores(t *testing.T, th *SearchTestHelper) { p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "_start middle end_ _another_", "", model.PostTypeDefault, 0, false) require.NoError(t, err) @@ -1769,21 +1786,6 @@ func testSlashShouldNotBeCharSeparator(t *testing.T, th *SearchTestHelper) { th.checkPostInSearchResults(t, p1.Id, results.Posts) } -func testSearchEmailsWithoutQuotes(t *testing.T, th *SearchTestHelper) { - p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "message test@test.com", "", model.PostTypeDefault, 0, false) - require.NoError(t, err) - _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "message test2@test.com", "", model.PostTypeDefault, 0, false) - require.NoError(t, err) - defer th.deleteUserPosts(th.User.Id) - - params := &model.SearchParams{Terms: "test@test.com"} - results, err := th.Store.Post().SearchPostsForUser([]*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20) - require.NoError(t, err) - - require.Len(t, results.Posts, 1) - th.checkPostInSearchResults(t, p1.Id, results.Posts) -} - func testSupportSearchInComments(t *testing.T, th *SearchTestHelper) { p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "message test@test.com", "", model.PostTypeDefault, 0, false) require.NoError(t, err) diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index 69b6835766..85864f3784 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -41,36 +41,38 @@ type SqlChannelStore struct { } type channelMember struct { - ChannelId string - UserId string - Roles string - LastViewedAt int64 - MsgCount int64 - MentionCount int64 - NotifyProps model.StringMap - LastUpdateAt int64 - SchemeUser sql.NullBool - SchemeAdmin sql.NullBool - SchemeGuest sql.NullBool - MentionCountRoot int64 - MsgCountRoot int64 + ChannelId string + UserId string + Roles string + LastViewedAt int64 + MsgCount int64 + MentionCount int64 + UrgentMentionCount int64 + NotifyProps model.StringMap + LastUpdateAt int64 + SchemeUser sql.NullBool + SchemeAdmin sql.NullBool + SchemeGuest sql.NullBool + MentionCountRoot int64 + MsgCountRoot int64 } func NewMapFromChannelMemberModel(cm *model.ChannelMember) map[string]any { return map[string]any{ - "ChannelId": cm.ChannelId, - "UserId": cm.UserId, - "Roles": cm.ExplicitRoles, - "LastViewedAt": cm.LastViewedAt, - "MsgCount": cm.MsgCount, - "MentionCount": cm.MentionCount, - "MentionCountRoot": cm.MentionCountRoot, - "MsgCountRoot": cm.MsgCountRoot, - "NotifyProps": cm.NotifyProps, - "LastUpdateAt": cm.LastUpdateAt, - "SchemeGuest": sql.NullBool{Valid: true, Bool: cm.SchemeGuest}, - "SchemeUser": sql.NullBool{Valid: true, Bool: cm.SchemeUser}, - "SchemeAdmin": sql.NullBool{Valid: true, Bool: cm.SchemeAdmin}, + "ChannelId": cm.ChannelId, + "UserId": cm.UserId, + "Roles": cm.ExplicitRoles, + "LastViewedAt": cm.LastViewedAt, + "MsgCount": cm.MsgCount, + "MentionCount": cm.MentionCount, + "MentionCountRoot": cm.MentionCountRoot, + "UrgentMentionCount": cm.UrgentMentionCount, + "MsgCountRoot": cm.MsgCountRoot, + "NotifyProps": cm.NotifyProps, + "LastUpdateAt": cm.LastUpdateAt, + "SchemeGuest": sql.NullBool{Valid: true, Bool: cm.SchemeGuest}, + "SchemeUser": sql.NullBool{Valid: true, Bool: cm.SchemeUser}, + "SchemeAdmin": sql.NullBool{Valid: true, Bool: cm.SchemeAdmin}, } } @@ -82,6 +84,7 @@ type channelMemberWithSchemeRoles struct { MsgCount int64 MentionCount int64 MentionCountRoot int64 + UrgentMentionCount int64 NotifyProps model.StringMap LastUpdateAt int64 SchemeGuest sql.NullBool @@ -106,7 +109,7 @@ type channelMemberWithTeamWithSchemeRoles struct { type channelMemberWithTeamWithSchemeRolesList []channelMemberWithTeamWithSchemeRoles func channelMemberSliceColumns() []string { - return []string{"ChannelId", "UserId", "Roles", "LastViewedAt", "MsgCount", "MsgCountRoot", "MentionCount", "MentionCountRoot", "NotifyProps", "LastUpdateAt", "SchemeUser", "SchemeAdmin", "SchemeGuest"} + return []string{"ChannelId", "UserId", "Roles", "LastViewedAt", "MsgCount", "MsgCountRoot", "MentionCount", "MentionCountRoot", "UrgentMentionCount", "NotifyProps", "LastUpdateAt", "SchemeUser", "SchemeAdmin", "SchemeGuest"} } func channelMemberToSlice(member *model.ChannelMember) []any { @@ -119,6 +122,7 @@ func channelMemberToSlice(member *model.ChannelMember) []any { resultSlice = append(resultSlice, member.MsgCountRoot) resultSlice = append(resultSlice, member.MentionCount) resultSlice = append(resultSlice, member.MentionCountRoot) + resultSlice = append(resultSlice, member.UrgentMentionCount) resultSlice = append(resultSlice, model.MapToJSON(member.NotifyProps)) resultSlice = append(resultSlice, member.LastUpdateAt) resultSlice = append(resultSlice, member.SchemeUser) @@ -244,20 +248,21 @@ func (db channelMemberWithSchemeRoles) ToModel() *model.ChannelMember { strings.Fields(db.Roles), ) return &model.ChannelMember{ - ChannelId: db.ChannelId, - UserId: db.UserId, - Roles: strings.Join(rolesResult.roles, " "), - LastViewedAt: db.LastViewedAt, - MsgCount: db.MsgCount, - MsgCountRoot: db.MsgCountRoot, - MentionCount: db.MentionCount, - MentionCountRoot: db.MentionCountRoot, - NotifyProps: db.NotifyProps, - LastUpdateAt: db.LastUpdateAt, - SchemeAdmin: rolesResult.schemeAdmin, - SchemeUser: rolesResult.schemeUser, - SchemeGuest: rolesResult.schemeGuest, - ExplicitRoles: strings.Join(rolesResult.explicitRoles, " "), + ChannelId: db.ChannelId, + UserId: db.UserId, + Roles: strings.Join(rolesResult.roles, " "), + LastViewedAt: db.LastViewedAt, + MsgCount: db.MsgCount, + MsgCountRoot: db.MsgCountRoot, + MentionCount: db.MentionCount, + MentionCountRoot: db.MentionCountRoot, + UrgentMentionCount: db.UrgentMentionCount, + NotifyProps: db.NotifyProps, + LastUpdateAt: db.LastUpdateAt, + SchemeAdmin: rolesResult.schemeAdmin, + SchemeUser: rolesResult.schemeUser, + SchemeGuest: rolesResult.schemeGuest, + ExplicitRoles: strings.Join(rolesResult.explicitRoles, " "), } } @@ -307,20 +312,21 @@ func (db channelMemberWithTeamWithSchemeRoles) ToModel() *model.ChannelMemberWit ) return &model.ChannelMemberWithTeamData{ ChannelMember: model.ChannelMember{ - ChannelId: db.ChannelId, - UserId: db.UserId, - Roles: strings.Join(rolesResult.roles, " "), - LastViewedAt: db.LastViewedAt, - MsgCount: db.MsgCount, - MsgCountRoot: db.MsgCountRoot, - MentionCount: db.MentionCount, - MentionCountRoot: db.MentionCountRoot, - NotifyProps: db.NotifyProps, - LastUpdateAt: db.LastUpdateAt, - SchemeAdmin: rolesResult.schemeAdmin, - SchemeUser: rolesResult.schemeUser, - SchemeGuest: rolesResult.schemeGuest, - ExplicitRoles: strings.Join(rolesResult.explicitRoles, " "), + ChannelId: db.ChannelId, + UserId: db.UserId, + Roles: strings.Join(rolesResult.roles, " "), + LastViewedAt: db.LastViewedAt, + MsgCount: db.MsgCount, + MsgCountRoot: db.MsgCountRoot, + MentionCount: db.MentionCount, + MentionCountRoot: db.MentionCountRoot, + UrgentMentionCount: db.UrgentMentionCount, + NotifyProps: db.NotifyProps, + LastUpdateAt: db.LastUpdateAt, + SchemeAdmin: rolesResult.schemeAdmin, + SchemeUser: rolesResult.schemeUser, + SchemeGuest: rolesResult.schemeGuest, + ExplicitRoles: strings.Join(rolesResult.explicitRoles, " "), }, TeamName: db.TeamName, TeamDisplayName: db.TeamDisplayName, @@ -445,6 +451,10 @@ var channelByNameCache = cache.NewLRU(cache.LRUOptions{ Size: model.ChannelCacheSize, }) +func (s SqlChannelStore) ClearMembersForUserCache() { + allChannelMembersForUserCache.Purge() +} + func (s SqlChannelStore) ClearCaches() { allChannelMembersForUserCache.Purge() allChannelMembersNotifyPropsForChannelCache.Purge() @@ -471,7 +481,20 @@ func newSqlChannelStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface func (s *SqlChannelStore) initializeQueries() { s.channelMembersForTeamWithSchemeSelectQuery = s.getQueryBuilder(). Select( - "ChannelMembers.*", + "ChannelMembers.ChannelId", + "ChannelMembers.UserId", + "ChannelMembers.Roles", + "ChannelMembers.LastViewedAt", + "ChannelMembers.MsgCount", + "ChannelMembers.MentionCount", + "ChannelMembers.MentionCountRoot", + "COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount", + "ChannelMembers.MsgCountRoot", + "ChannelMembers.NotifyProps", + "ChannelMembers.LastUpdateAt", + "ChannelMembers.SchemeUser", + "ChannelMembers.SchemeAdmin", + "ChannelMembers.SchemeGuest", "TeamScheme.DefaultChannelGuestRole TeamSchemeDefaultGuestRole", "TeamScheme.DefaultChannelUserRole TeamSchemeDefaultUserRole", "TeamScheme.DefaultChannelAdminRole TeamSchemeDefaultAdminRole", @@ -779,7 +802,7 @@ func (s SqlChannelStore) GetChannelUnread(channelId, userId string) (*model.Chan var unreadChannel model.ChannelUnread err := s.GetReplicaX().Get(&unreadChannel, `SELECT - Channels.TeamId TeamId, Channels.Id ChannelId, (Channels.TotalMsgCount - ChannelMembers.MsgCount) MsgCount, (Channels.TotalMsgCountRoot - ChannelMembers.MsgCountRoot) MsgCountRoot, ChannelMembers.MentionCount MentionCount, ChannelMembers.MentionCountRoot MentionCountRoot, ChannelMembers.NotifyProps NotifyProps + Channels.TeamId TeamId, Channels.Id ChannelId, (Channels.TotalMsgCount - ChannelMembers.MsgCount) MsgCount, (Channels.TotalMsgCountRoot - ChannelMembers.MsgCountRoot) MsgCountRoot, ChannelMembers.MentionCount MentionCount, ChannelMembers.MentionCountRoot MentionCountRoot, COALESCE(ChannelMembers.UrgentMentionCount, 0) UrgentMentionCount, ChannelMembers.NotifyProps NotifyProps FROM Channels, ChannelMembers WHERE @@ -1612,7 +1635,20 @@ func (s SqlChannelStore) GetDeleted(teamId string, offset int, limit int, userId var channelMembersWithSchemeSelectQuery = ` SELECT - ChannelMembers.*, + ChannelMembers.ChannelId, + ChannelMembers.UserId, + ChannelMembers.Roles, + ChannelMembers.LastViewedAt, + ChannelMembers.MsgCount, + ChannelMembers.MentionCount, + ChannelMembers.MentionCountRoot, + COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount, + ChannelMembers.MsgCountRoot, + ChannelMembers.NotifyProps, + ChannelMembers.LastUpdateAt, + ChannelMembers.SchemeUser, + ChannelMembers.SchemeAdmin, + ChannelMembers.SchemeGuest, COALESCE(Teams.DisplayName, '') TeamDisplayName, COALESCE(Teams.Name, '') TeamName, COALESCE(Teams.UpdateAt, 0) TeamUpdateAt, @@ -2048,7 +2084,20 @@ func (s SqlChannelStore) GetMemberForPost(postId string, userId string) (*model. var dbMember channelMemberWithSchemeRoles query := ` SELECT - ChannelMembers.*, + ChannelMembers.ChannelId, + ChannelMembers.UserId, + ChannelMembers.Roles, + ChannelMembers.LastViewedAt, + ChannelMembers.MsgCount, + ChannelMembers.MentionCount, + ChannelMembers.MentionCountRoot, + COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount, + ChannelMembers.MsgCountRoot, + ChannelMembers.NotifyProps, + ChannelMembers.LastUpdateAt, + ChannelMembers.SchemeUser, + ChannelMembers.SchemeAdmin, + ChannelMembers.SchemeGuest, TeamScheme.DefaultChannelGuestRole TeamSchemeDefaultGuestRole, TeamScheme.DefaultChannelUserRole TeamSchemeDefaultUserRole, TeamScheme.DefaultChannelAdminRole TeamSchemeDefaultAdminRole, @@ -2438,6 +2487,7 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) Update("ChannelMembers cm"). Set("MentionCount", 0). Set("MentionCountRoot", 0). + Set("UrgentMentionCount", 0). Set("MsgCount", sq.Expr("greatest(cm.MsgCount, c.TotalMsgCount)")). Set("MsgCountRoot", sq.Expr("greatest(cm.MsgCountRoot, c.TotalMsgCountRoot)")). Set("LastViewedAt", sq.Expr("greatest(cm.LastViewedAt, c.LastPostAt)")). @@ -2497,6 +2547,7 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) updateQuery := s.getQueryBuilder().Update("ChannelMembers"). Set("MentionCount", 0). Set("MentionCountRoot", 0). + Set("UrgentMentionCount", 0). Set("MsgCount", msgCountQuery). Set("MsgCountRoot", msgCountQueryRoot). Set("LastViewedAt", lastViewedQuery). @@ -2518,6 +2569,31 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) return times, nil } +func (s SqlChannelStore) CountUrgentPostsAfter(channelId string, timestamp int64, userId string) (int, error) { + query := s.getQueryBuilder(). + Select("count(*)"). + From("PostsPriority"). + Join("Posts ON Posts.Id = PostsPriority.PostId"). + Where(sq.And{ + sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}, + sq.Eq{"Posts.ChannelId": channelId}, + sq.Gt{"Posts.CreateAt": timestamp}, + sq.Eq{"Posts.DeleteAt": 0}, + }) + + if userId != "" { + query = query.Where(sq.Eq{"Posts.UserId": userId}) + } + + var urgent int64 + err := s.GetReplicaX().GetBuilder(&urgent, query) + if err != nil { + return 0, errors.Wrap(err, "failed to count urgent Posts") + } + + return int(urgent), nil +} + // CountPostsAfter returns the number of posts in the given channel created after but not including the given timestamp. If given a non-empty user ID, only counts posts made by that user. func (s SqlChannelStore) CountPostsAfter(channelId string, timestamp int64, userId string) (int, int, error) { joinLeavePostTypes := []string{ @@ -2566,13 +2642,14 @@ func (s SqlChannelStore) CountPostsAfter(channelId string, timestamp int64, user if err != nil { return 0, 0, errors.Wrap(err, "failed to count root Posts") } + return int(unread), int(unreadRoot), nil } // UpdateLastViewedAtPost updates a ChannelMember as if the user last read the channel at the time of the given post. // If the provided mentionCount is -1, the given post and all posts after it are considered to be mentions. Returns // an updated model.ChannelUnreadAt that can be returned to the client. -func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { +func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { unreadDate := unreadPost.CreateAt - 1 unread, unreadRoot, err := s.CountPostsAfter(unreadPost.ChannelId, unreadDate, "") @@ -2587,6 +2664,7 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s params := map[string]any{ "mentions": mentionCount, "mentionsroot": mentionCountRoot, + "urgentmentions": urgentMentionCount, "unreadcount": unread, "unreadcountroot": unreadRoot, "lastviewedat": unreadDate, @@ -2603,6 +2681,7 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s SET MentionCount = :mentions, MentionCountRoot = :mentionsroot, + UrgentMentionCount = :urgentmentions, MsgCount = (SELECT TotalMsgCount FROM Channels WHERE ID = :channelid) - :unreadcount, MsgCountRoot = (SELECT TotalMsgCountRoot FROM Channels WHERE ID = :channelid) - :unreadcountroot, LastViewedAt = :lastviewedat, @@ -2625,6 +2704,7 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s cm.MsgCountRoot MsgCountRoot, cm.MentionCount MentionCount, cm.MentionCountRoot MentionCountRoot, + COALESCE(cm.UrgentMentionCount, 0) UrgentMentionCount, cm.LastViewedAt LastViewedAt, cm.NotifyProps NotifyProps FROM @@ -2643,7 +2723,7 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s return result, nil } -func (s SqlChannelStore) IncrementMentionCount(channelId string, userIDs []string, isRoot bool) error { +func (s SqlChannelStore) IncrementMentionCount(channelId string, userIDs []string, isRoot bool, isUrgent bool) error { now := model.GetMillis() rootInc := 0 @@ -2651,10 +2731,16 @@ func (s SqlChannelStore) IncrementMentionCount(channelId string, userIDs []strin rootInc = 1 } + urgentInc := 0 + if isUrgent { + urgentInc = 1 + } + sql, args, err := s.getQueryBuilder(). Update("ChannelMembers"). Set("MentionCount", sq.Expr("MentionCount + 1")). Set("MentionCountRoot", sq.Expr("MentionCountRoot + ?", rootInc)). + Set("UrgentMentionCount", sq.Expr("UrgentMentionCount + ?", urgentInc)). Set("LastUpdateAt", now). Where(sq.Eq{ "UserId": userIDs, @@ -2832,7 +2918,21 @@ func (s SqlChannelStore) GetMembersForUser(teamID string, userID string) (model. func (s SqlChannelStore) GetMembersForUserWithCursor(userID, teamID string, opts *store.ChannelMemberGraphQLSearchOpts) (model.ChannelMembers, error) { query := s.getQueryBuilder(). - Select("ChannelMembers.*", + Select( + "ChannelMembers.ChannelId", + "ChannelMembers.UserId", + "ChannelMembers.Roles", + "ChannelMembers.LastViewedAt", + "ChannelMembers.MsgCount", + "ChannelMembers.MentionCount", + "ChannelMembers.MentionCountRoot", + "COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount", + "ChannelMembers.MsgCountRoot", + "ChannelMembers.NotifyProps", + "ChannelMembers.LastUpdateAt", + "ChannelMembers.SchemeUser", + "ChannelMembers.SchemeAdmin", + "ChannelMembers.SchemeGuest", "TeamScheme.DefaultChannelGuestRole TeamSchemeDefaultGuestRole", "TeamScheme.DefaultChannelUserRole TeamSchemeDefaultUserRole", "TeamScheme.DefaultChannelAdminRole TeamSchemeDefaultAdminRole", @@ -3790,6 +3890,7 @@ func (s SqlChannelStore) MigrateChannelMembers(fromChannelId string, fromUserId LastViewedAt=:LastViewedAt, MsgCount=:MsgCount, MentionCount=:MentionCount, + UrgentMentionCount=:UrgentMentionCount, NotifyProps=:NotifyProps, LastUpdateAt=:LastUpdateAt, SchemeUser=:SchemeUser, @@ -3932,6 +4033,7 @@ func (s SqlChannelStore) GetChannelMembersForExport(userId string, teamId string ChannelMembers.MsgCount, ChannelMembers.MentionCount, ChannelMembers.MentionCountRoot, + COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount, ChannelMembers.NotifyProps, ChannelMembers.LastUpdateAt, ChannelMembers.SchemeUser, @@ -3981,7 +4083,7 @@ func (s SqlChannelStore) GetAllDirectChannelsForExportAfter(limit int, afterId s channelIds = append(channelIds, channel.Id) } query = s.getQueryBuilder(). - Select("u.Username as Username, ChannelId, UserId, cm.Roles as Roles, LastViewedAt, MsgCount, MentionCount, MentionCountRoot, cm.NotifyProps as NotifyProps, LastUpdateAt, SchemeUser, SchemeAdmin, (SchemeGuest IS NOT NULL AND SchemeGuest) as SchemeGuest"). + Select("u.Username as Username, ChannelId, UserId, cm.Roles as Roles, LastViewedAt, MsgCount, MentionCount, MentionCountRoot, COALESCE(UrgentMentionCount, 0) UrgentMentionCount, cm.NotifyProps as NotifyProps, LastUpdateAt, SchemeUser, SchemeAdmin, (SchemeGuest IS NOT NULL AND SchemeGuest) as SchemeGuest"). From("ChannelMembers cm"). Join("Users u ON ( u.Id = cm.UserId )"). Where(sq.And{ diff --git a/store/sqlstore/draft_store.go b/store/sqlstore/draft_store.go new file mode 100644 index 0000000000..2dfcc5763d --- /dev/null +++ b/store/sqlstore/draft_store.go @@ -0,0 +1,260 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "sync" + + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +type SqlDraftStore struct { + *SqlStore + metrics einterfaces.MetricsInterface + maxDraftSizeOnce sync.Once + maxDraftSizeCached int +} + +func draftSliceColumns() []string { + return []string{ + "CreateAt", + "UpdateAt", + "DeleteAt", + "Message", + "RootId", + "ChannelId", + "UserId", + "FileIds", + "Props", + "Priority", + } +} + +func draftToSlice(draft *model.Draft) []interface{} { + return []interface{}{ + draft.CreateAt, + draft.UpdateAt, + draft.DeleteAt, + draft.Message, + draft.RootId, + draft.ChannelId, + draft.UserId, + model.ArrayToJSON(draft.FileIds), + model.StringInterfaceToJSON(draft.Props), + model.StringInterfaceToJSON(draft.Priority), + } +} + +func newSqlDraftStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.DraftStore { + return &SqlDraftStore{ + SqlStore: sqlStore, + metrics: metrics, + maxDraftSizeCached: model.PostMessageMaxRunesV1, + } +} + +func (s *SqlDraftStore) Get(userId, channelId, rootId string, includeDeleted bool) (*model.Draft, error) { + query := s.getQueryBuilder(). + Select(draftSliceColumns()...). + From("Drafts"). + Where(sq.Eq{ + "UserId": userId, + "ChannelId": channelId, + "RootId": rootId, + }) + + if !includeDeleted { + query = query.Where(sq.Eq{"DeleteAt": 0}) + } + + dt := model.Draft{} + err := s.GetReplicaX().GetBuilder(&dt, query) + + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Draft", channelId) + } + return nil, errors.Wrapf(err, "failed to find draft with channelid = %s", channelId) + } + + return &dt, nil +} + +func (s *SqlDraftStore) Save(draft *model.Draft) (*model.Draft, error) { + draft.PreSave() + maxDraftSize := s.GetMaxDraftSize() + if err := draft.IsValid(maxDraftSize); err != nil { + return nil, err + } + + builder := s.getQueryBuilder().Insert("Drafts").Columns(draftSliceColumns()...).Values(draftToSlice(draft)...) + query, args, err := builder.ToSql() + + if err != nil { + return nil, errors.Wrap(err, "save_draft_tosql") + } + + if _, err = s.GetMasterX().Exec(query, args...); err != nil { + return nil, errors.Wrap(err, "failed to save Draft") + } + + return draft, nil +} + +func (s *SqlDraftStore) Update(draft *model.Draft) (*model.Draft, error) { + draft.PreUpdate() + + maxDraftSize := s.GetMaxDraftSize() + if err := draft.IsValid(maxDraftSize); err != nil { + return nil, err + } + + query := s.getQueryBuilder(). + Update("Drafts"). + Set("UpdateAt", draft.UpdateAt). + Set("Message", draft.Message). + Set("Props", draft.Props). + Set("FileIds", draft.FileIds). + Set("Priority", draft.Priority). + Set("DeleteAt", 0). + Where(sq.Eq{ + "UserId": draft.UserId, + "ChannelId": draft.ChannelId, + "RootId": draft.RootId, + }) + + if _, err := s.GetMasterX().ExecBuilder(query); err != nil { + return nil, errors.Wrapf(err, "failed to update Draft with channelid=%s", draft.ChannelId) + } + + return draft, nil +} + +func (s *SqlDraftStore) GetDraftsForUser(userID, teamID string) ([]*model.Draft, error) { + var drafts []*model.Draft + + query := s.getQueryBuilder(). + Select( + "Drafts.CreateAt", + "Drafts.UpdateAt", + "Drafts.Message", + "Drafts.RootId", + "Drafts.ChannelId", + "Drafts.UserId", + "Drafts.FileIds", + "Drafts.Props", + "Drafts.Priority", + ). + From("Drafts"). + InnerJoin("ChannelMembers ON ChannelMembers.ChannelId = Drafts.ChannelId"). + Where(sq.And{ + sq.Eq{"Drafts.DeleteAt": 0}, + sq.Eq{"Drafts.UserId": userID}, + sq.Eq{"ChannelMembers.UserId": userID}, + }). + OrderBy("Drafts.UpdateAt DESC") + + if teamID != "" { + query = query. + Join("Channels ON Drafts.ChannelId = Channels.Id"). + Where(sq.Or{ + sq.Eq{"Channels.TeamId": teamID}, + sq.Eq{"Channels.TeamId": ""}, + }) + } + + err := s.GetReplicaX().SelectBuilder(&drafts, query) + + if err != nil { + return nil, errors.Wrap(err, "failed to get user drafts") + } + + return drafts, nil +} + +func (s *SqlDraftStore) Delete(userID, channelID, rootID string) error { + time := model.GetMillis() + query := s.getQueryBuilder(). + Update("Drafts"). + Set("UpdateAt", time). + Set("DeleteAt", time). + Where(sq.Eq{ + "UserId": userID, + "ChannelId": channelID, + "RootId": rootID, + }) + + sql, args, err := query.ToSql() + if err != nil { + return errors.Wrapf(err, "failed to convert to sql") + } + + _, err = s.GetMasterX().Exec(sql, args...) + + if err != nil { + return errors.Wrap(err, "failed to delete Draft") + } + + return nil +} + +// GetMaxDraftSize returns the maximum number of runes that may be stored in a post. +func (s *SqlDraftStore) GetMaxDraftSize() int { + s.maxDraftSizeOnce.Do(func() { + s.maxDraftSizeCached = s.determineMaxDraftSize() + }) + return s.maxDraftSizeCached +} + +func (s *SqlDraftStore) determineMaxDraftSize() int { + var maxDraftSizeBytes int32 + + if s.DriverName() == model.DatabaseDriverPostgres { + // The Draft.Message column in Postgres has historically been VARCHAR(4000), but + // may be manually enlarged to support longer drafts. + if err := s.GetReplicaX().Get(&maxDraftSizeBytes, ` + SELECT + COALESCE(character_maximum_length, 0) + FROM + information_schema.columns + WHERE + table_name = 'drafts' + AND column_name = 'message' + `); err != nil { + mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err)) + } + } else if s.DriverName() == model.DatabaseDriverMysql { + // The Draft.Message column in MySQL has historically been TEXT, with a maximum + // limit of 65535. + if err := s.GetReplicaX().Get(&maxDraftSizeBytes, ` + SELECT + COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + table_schema = DATABASE() + AND table_name = 'Drafts' + AND column_name = 'Message' + LIMIT 0, 1 + `); err != nil { + mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err)) + } + } else { + mlog.Warn("No implementation found to determine the maximum supported draft size") + } + + // Assume a worst-case representation of four bytes per rune. + maxDraftSize := int(maxDraftSizeBytes) / 4 + + mlog.Info("Draft.Message has size restrictions", mlog.Int("max_characters", maxDraftSize), mlog.Int32("max_bytes", maxDraftSizeBytes)) + + return maxDraftSize +} diff --git a/store/sqlstore/draft_store_test.go b/store/sqlstore/draft_store_test.go new file mode 100644 index 0000000000..25f1a53423 --- /dev/null +++ b/store/sqlstore/draft_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/store/storetest" +) + +func TestDraftStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestDraftStore) +} diff --git a/store/sqlstore/file_info_store.go b/store/sqlstore/file_info_store.go index 47d847b9d1..49403e4d98 100644 --- a/store/sqlstore/file_info_store.go +++ b/store/sqlstore/file_info_store.go @@ -606,8 +606,7 @@ func (fs SqlFileInfoStore) Search(paramsList []*model.SearchParams, userId, team terms := params.Terms excludedTerms := params.ExcludedTerms - // these chars have special meaning and can be treated as spaces - for _, c := range specialSearchChar { + for _, c := range fs.specialSearchChars() { terms = strings.Replace(terms, c, " ", -1) excludedTerms = strings.Replace(excludedTerms, c, " ", -1) } diff --git a/store/sqlstore/group_store.go b/store/sqlstore/group_store.go index 40866d2a7a..64737840b8 100644 --- a/store/sqlstore/group_store.go +++ b/store/sqlstore/group_store.go @@ -1056,34 +1056,38 @@ func (s *SqlGroupStore) CountGroupsByChannel(channelId string, opts model.GroupS } type group struct { - Id string - Name *string - DisplayName string - Description string - Source model.GroupSource - RemoteId *string - CreateAt int64 - UpdateAt int64 - DeleteAt int64 - HasSyncables bool - MemberCount *int - AllowReference bool + Id string + Name *string + DisplayName string + Description string + Source model.GroupSource + RemoteId *string + CreateAt int64 + UpdateAt int64 + DeleteAt int64 + HasSyncables bool + MemberCount *int + AllowReference bool + ChannelMemberCount *int + ChannelMemberTimezonesCount *int } func (g group) ToModel() *model.Group { return &model.Group{ - Id: g.Id, - Name: g.Name, - DisplayName: g.DisplayName, - Description: g.Description, - Source: g.Source, - RemoteId: g.RemoteId, - CreateAt: g.CreateAt, - UpdateAt: g.UpdateAt, - DeleteAt: g.DeleteAt, - HasSyncables: g.HasSyncables, - AllowReference: g.AllowReference, - MemberCount: g.MemberCount, + Id: g.Id, + Name: g.Name, + DisplayName: g.DisplayName, + Description: g.Description, + Source: g.Source, + RemoteId: g.RemoteId, + CreateAt: g.CreateAt, + UpdateAt: g.UpdateAt, + DeleteAt: g.DeleteAt, + HasSyncables: g.HasSyncables, + AllowReference: g.AllowReference, + MemberCount: g.MemberCount, + ChannelMemberCount: g.ChannelMemberCount, + ChannelMemberTimezonesCount: g.ChannelMemberTimezonesCount, } } @@ -1416,7 +1420,20 @@ func (s *SqlGroupStore) GetGroupsAssociatedToChannelsByTeam(teamId string, opts func (s *SqlGroupStore) GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, error) { groupsVar := groups{} - groupsQuery := s.getQueryBuilder().Select("g.*") + selectQuery := []string{"g.*"} + + if opts.IncludeMemberCount { + selectQuery = append(selectQuery, "coalesce(Members.MemberCount, 0) AS MemberCount") + } + + if opts.IncludeChannelMemberCount != "" { + selectQuery = append(selectQuery, "coalesce(ChannelMembers.ChannelMemberCount, 0) AS ChannelMemberCount") + if opts.IncludeTimezones { + selectQuery = append(selectQuery, "coalesce(ChannelMembers.ChannelMemberTimezonesCount, 0) AS ChannelMemberTimezonesCount") + } + } + + groupsQuery := s.getQueryBuilder().Select(strings.Join(selectQuery, ", ")) if opts.IncludeMemberCount { countQuery := s.getQueryBuilder(). @@ -1433,12 +1450,43 @@ func (s *SqlGroupStore) GetGroups(page, perPage int, opts model.GroupSearchOpts, if err != nil { return nil, errors.Wrap(err, "get_groups_tosql") } - - groupsQuery = s.getQueryBuilder(). - Select("g.*, coalesce(Members.MemberCount, 0) AS MemberCount"). + groupsQuery = groupsQuery. LeftJoin("("+countString+") AS Members ON Members.GroupId = g.Id", params...) } + if opts.IncludeChannelMemberCount != "" { + selectStr := "GroupMembers.GroupId, COUNT(ChannelMembers.UserId) AS ChannelMemberCount" + joinStr := "" + + if opts.IncludeTimezones { + if s.DriverName() == model.DatabaseDriverMysql { + selectStr += `, + COUNT(DISTINCT + ( + CASE WHEN JSON_EXTRACT(Timezone, '$.useAutomaticTimezone') = 'true' AND LENGTH(JSON_UNQUOTE(JSON_EXTRACT(Timezone, '$.automaticTimezone'))) > 0 + THEN JSON_EXTRACT(Timezone, '$.automaticTimezone') + WHEN JSON_EXTRACT(Timezone, '$.useAutomaticTimezone') = 'false' AND LENGTH(JSON_UNQUOTE(JSON_EXTRACT(Timezone, '$.manualTimezone'))) > 0 + THEN JSON_EXTRACT(Timezone, '$.manualTimezone') + END + )) AS ChannelMemberTimezonesCount` + } else if s.DriverName() == model.DatabaseDriverPostgres { + selectStr += `, + COUNT(DISTINCT + ( + CASE WHEN Timezone->>'useAutomaticTimezone' = 'true' AND length(Timezone->>'automaticTimezone') > 0 + THEN Timezone->>'automaticTimezone' + WHEN Timezone->>'useAutomaticTimezone' = 'false' AND length(Timezone->>'manualTimezone') > 0 + THEN Timezone->>'manualTimezone' + END + )) AS ChannelMemberTimezonesCount` + } + joinStr = "LEFT JOIN Users ON Users.Id = GroupMembers.UserId" + } + + groupsQuery = groupsQuery. + LeftJoin("(SELECT "+selectStr+" FROM ChannelMembers LEFT JOIN GroupMembers ON GroupMembers.UserId = ChannelMembers.UserId AND GroupMembers.DeleteAt = 0 "+joinStr+" WHERE ChannelMembers.ChannelId = ? GROUP BY GroupId) AS ChannelMembers ON ChannelMembers.GroupId = g.Id", opts.IncludeChannelMemberCount) + } + if opts.FilterHasMember != "" { groupsQuery = groupsQuery. LeftJoin("GroupMembers ON GroupMembers.GroupId = g.Id"). diff --git a/store/sqlstore/post_acknowledgements_store.go b/store/sqlstore/post_acknowledgements_store.go new file mode 100644 index 0000000000..d3de3addea --- /dev/null +++ b/store/sqlstore/post_acknowledgements_store.go @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +type SqlPostAcknowledgementStore struct { + *SqlStore +} + +func newSqlPostAcknowledgementStore(sqlStore *SqlStore) store.PostAcknowledgementStore { + return &SqlPostAcknowledgementStore{sqlStore} +} + +func (s *SqlPostAcknowledgementStore) Get(postID, userID string) (*model.PostAcknowledgement, error) { + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.Eq{"PostId": postID}, + sq.Eq{"UserId": userID}, + sq.NotEq{"AcknowledgedAt": 0}, + }) + + var acknowledgement model.PostAcknowledgement + err := s.GetReplicaX().GetBuilder(&acknowledgement, query) + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("PostAcknowledgement", postID) + } + + return nil, err + } + + return &acknowledgement, nil +} + +func (s *SqlPostAcknowledgementStore) Save(postID, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + if acknowledgedAt == 0 { + acknowledgedAt = model.GetMillis() + } + + acknowledgement := &model.PostAcknowledgement{ + UserId: userID, + PostId: postID, + AcknowledgedAt: acknowledgedAt, + } + + if err := acknowledgement.IsValid(); err != nil { + return nil, err + } + + query := s.getQueryBuilder(). + Insert("PostAcknowledgements"). + Columns("PostId", "UserId", "AcknowledgedAt"). + Values(acknowledgement.PostId, acknowledgement.UserId, acknowledgement.AcknowledgedAt) + + if s.DriverName() == model.DatabaseDriverMysql { + query = query.SuffixExpr(sq.Expr("ON DUPLICATE KEY UPDATE AcknowledgedAt = ?", acknowledgement.AcknowledgedAt)) + } else { + query = query.SuffixExpr(sq.Expr("ON CONFLICT (postid, userid) DO UPDATE SET AcknowledgedAt = ?", acknowledgement.AcknowledgedAt)) + } + + _, err := s.GetMasterX().ExecBuilder(query) + if err != nil { + return nil, err + } + + return acknowledgement, nil +} + +func (s *SqlPostAcknowledgementStore) Delete(ack *model.PostAcknowledgement) error { + query := s.getQueryBuilder(). + Update("PostAcknowledgements"). + Set("AcknowledgedAt", 0). + Where(sq.And{ + sq.Eq{"PostId": ack.PostId}, + sq.Eq{"UserId": ack.UserId}, + }) + + _, err := s.GetMasterX().ExecBuilder(query) + if err != nil { + return err + } + + return nil +} + +func (s *SqlPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + var acknowledgements []*model.PostAcknowledgement + + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.NotEq{"AcknowledgedAt": 0}, + sq.Eq{"PostId": postID}, + }) + + err := s.GetReplicaX().SelectBuilder(&acknowledgements, query) + if err != nil { + return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for postID=%s", postID) + } + + return acknowledgements, nil +} + +func (s *SqlPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + var acknowledgements []*model.PostAcknowledgement + + perPage := 200 + for i := 0; i < len(postIds); i += perPage { + j := i + perPage + if len(postIds) < j { + j = len(postIds) + } + + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.Eq{"PostId": postIds[i:j]}, + sq.NotEq{"AcknowledgedAt": 0}, + }) + + var acknowledgementsBatch []*model.PostAcknowledgement + err := s.GetReplicaX().SelectBuilder(&acknowledgementsBatch, query) + if err != nil { + return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for post list") + } + + acknowledgements = append(acknowledgements, acknowledgementsBatch...) + } + + return acknowledgements, nil +} diff --git a/store/sqlstore/post_acknowledgements_store_test.go b/store/sqlstore/post_acknowledgements_store_test.go new file mode 100644 index 0000000000..7de92cfef4 --- /dev/null +++ b/store/sqlstore/post_acknowledgements_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/store/storetest" +) + +func TestPostAcknowledgementsStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestPostAcknowledgementsStore) +} diff --git a/store/sqlstore/post_priority_store.go b/store/sqlstore/post_priority_store.go new file mode 100644 index 0000000000..03b302e3c5 --- /dev/null +++ b/store/sqlstore/post_priority_store.go @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + sq "github.com/mattermost/squirrel" +) + +type SqlPostPriorityStore struct { + *SqlStore +} + +func newSqlPostPriorityStore(sqlStore *SqlStore) store.PostPriorityStore { + return &SqlPostPriorityStore{ + SqlStore: sqlStore, + } +} + +func (s *SqlPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + query := s.getQueryBuilder(). + Select("Priority", "RequestedAck", "PersistentNotifications"). + From("PostsPriority"). + Where(sq.Eq{"PostId": postId}) + + var postPriority model.PostPriority + err := s.GetReplicaX().GetBuilder(&postPriority, query) + if err != nil { + return nil, err + } + + return &postPriority, nil +} + +func (s *SqlPostPriorityStore) GetForPosts(postIds []string) ([]*model.PostPriority, error) { + var priority []*model.PostPriority + + perPage := 200 + for i := 0; i < len(postIds); i += perPage { + j := i + perPage + if len(postIds) < j { + j = len(postIds) + } + + query := s.getQueryBuilder(). + Select("PostId", "Priority", "RequestedAck", "PersistentNotifications"). + From("PostsPriority"). + Where(sq.Eq{"PostId": postIds[i:j]}) + + var priorityBatch []*model.PostPriority + err := s.GetReplicaX().SelectBuilder(&priority, query) + + if err != nil { + return nil, err + } + + priority = append(priority, priorityBatch...) + } + + return priority, nil +} diff --git a/store/sqlstore/post_priority_store_test.go b/store/sqlstore/post_priority_store_test.go new file mode 100644 index 0000000000..3ab4627476 --- /dev/null +++ b/store/sqlstore/post_priority_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/store/storetest" +) + +func TestPostPriorityStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestPostPriorityStore) +} diff --git a/store/sqlstore/post_store.go b/store/sqlstore/post_store.go index 127e566861..b7f5979cd9 100644 --- a/store/sqlstore/post_store.go +++ b/store/sqlstore/post_store.go @@ -219,6 +219,10 @@ func (s *SqlPostStore) SaveMultiple(posts []*model.Post) ([]*model.Post, int, er return nil, -1, errors.Wrap(err, "update thread from posts failed") } + if err = s.savePostsPriority(transaction, posts); err != nil { + return nil, -1, errors.Wrap(err, "failed to save PostPriority") + } + if err = transaction.Commit(); err != nil { // don't need to rollback here since the transaction is already closed return posts, -1, errors.Wrap(err, "commit_transaction") @@ -1788,18 +1792,6 @@ func (s *SqlPostStore) getParentsPostsPostgreSQL(channelId string, offset int, l return posts, nil } -var specialSearchChar = []string{ - "<", - ">", - "+", - "-", - "(", - ")", - "~", - "@", - ":", -} - // GetNthRecentPostTime returns the CreateAt time of the nth most recent post. func (s *SqlPostStore) GetNthRecentPostTime(n int64) (int64, error) { if n <= 0 { @@ -1989,8 +1981,7 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search } } - // these chars have special meaning and can be treated as spaces - for _, c := range specialSearchChar { + for _, c := range s.specialSearchChars() { terms = strings.Replace(terms, c, " ", -1) excludedTerms = strings.Replace(excludedTerms, c, " ", -1) } @@ -2933,6 +2924,24 @@ func (s *SqlPostStore) updateThreadAfterReplyDeletion(transaction *sqlxTxWrapper return nil } +func (s *SqlPostStore) savePostsPriority(transaction *sqlxTxWrapper, posts []*model.Post) error { + for _, post := range posts { + if post.GetPriority() != nil { + postPriority := &model.PostPriority{ + PostId: post.Id, + ChannelId: post.ChannelId, + Priority: post.Metadata.Priority.Priority, + RequestedAck: post.Metadata.Priority.RequestedAck, + PersistentNotifications: post.Metadata.Priority.PersistentNotifications, + } + if _, err := transaction.NamedExec(`INSERT INTO PostsPriority (PostId, ChannelId, Priority, RequestedAck, PersistentNotifications) VALUES (:PostId, :ChannelId, :Priority, :RequestedAck, :PersistentNotifications)`, postPriority); err != nil { + return err + } + } + } + return nil +} + func (s *SqlPostStore) updateThreadsFromPosts(transaction *sqlxTxWrapper, posts []*model.Post) error { postsByRoot := map[string][]*model.Post{} var rootIds []string diff --git a/store/sqlstore/scheme_store.go b/store/sqlstore/scheme_store.go index a31ad5a27c..e72591628d 100644 --- a/store/sqlstore/scheme_store.go +++ b/store/sqlstore/scheme_store.go @@ -14,6 +14,22 @@ import ( "github.com/mattermost/mattermost-server/v6/store" ) +const ( + SchemeRoleDisplayNameTeamAdmin = "Team Admin Role for Scheme" + SchemeRoleDisplayNameTeamUser = "Team User Role for Scheme" + SchemeRoleDisplayNameTeamGuest = "Team Guest Role for Scheme" + + SchemeRoleDisplayNameChannelAdmin = "Channel Admin Role for Scheme" + SchemeRoleDisplayNameChannelUser = "Channel User Role for Scheme" + SchemeRoleDisplayNameChannelGuest = "Channel Guest Role for Scheme" + + SchemeRoleDisplayNamePlaybookAdmin = "Playbook Admin Role for Scheme" + SchemeRoleDisplayNamePlaybookMember = "Playbook Member Role for Scheme" + + SchemeRoleDisplayNameRunAdmin = "Run Admin Role for Scheme" + SchemeRoleDisplayNameRunMember = "Run Member Role for Scheme" +) + type SqlSchemeStore struct { *SqlStore } @@ -50,7 +66,7 @@ func (s *SqlSchemeStore) Save(scheme *model.Scheme) (_ *model.Scheme, err error) SET UpdateAt=:UpdateAt, CreateAt=:CreateAt, DeleteAt=:DeleteAt, Name=:Name, DisplayName=:DisplayName, Description=:Description, Scope=:Scope, DefaultTeamAdminRole=:DefaultTeamAdminRole, DefaultTeamUserRole=:DefaultTeamUserRole, DefaultTeamGuestRole=:DefaultTeamGuestRole, DefaultChannelAdminRole=:DefaultChannelAdminRole, DefaultChannelUserRole=:DefaultChannelUserRole, DefaultChannelGuestRole=:DefaultChannelGuestRole, - DefaultPlaybookMemberRole=:DefaultPlaybookMemberRole, DefaultPlaybookAdminRole=:DefaultPlaybookAdminRole, DefaultRunMemberRole=:DefaultRunMemberRole, DefaultRunAdminRole=:DefaultRunAdminRole + DefaultPlaybookMemberRole=:DefaultPlaybookMemberRole, DefaultPlaybookAdminRole=:DefaultPlaybookAdminRole, DefaultRunMemberRole=:DefaultRunMemberRole, DefaultRunAdminRole=:DefaultRunAdminRole WHERE Id=:Id`, scheme) if err != nil { @@ -101,7 +117,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // Team Admin Role teamAdminRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Team Admin Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamAdmin, scheme.Name), Permissions: defaultRoles[model.TeamAdminRoleId].Permissions, SchemeManaged: true, } @@ -115,7 +131,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // Team User Role teamUserRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Team User Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamUser, scheme.Name), Permissions: defaultRoles[model.TeamUserRoleId].Permissions, SchemeManaged: true, } @@ -129,7 +145,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // Team Guest Role teamGuestRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Team Guest Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamGuest, scheme.Name), Permissions: defaultRoles[model.TeamGuestRoleId].Permissions, SchemeManaged: true, } @@ -143,7 +159,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // playbook admin role playbookAdminRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Playbook Admin Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookAdmin, scheme.Name), Permissions: defaultRoles[model.PlaybookAdminRoleId].Permissions, SchemeManaged: true, } @@ -156,7 +172,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // playbook member role playbookMemberRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Playbook Member Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookMember, scheme.Name), Permissions: defaultRoles[model.PlaybookMemberRoleId].Permissions, SchemeManaged: true, } @@ -169,7 +185,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // run admin role runAdminRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Run Admin Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunAdmin, scheme.Name), Permissions: defaultRoles[model.RunAdminRoleId].Permissions, SchemeManaged: true, } @@ -182,7 +198,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // run member role runMemberRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Run Member Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunMember, scheme.Name), Permissions: defaultRoles[model.RunMemberRoleId].Permissions, SchemeManaged: true, } @@ -369,7 +385,7 @@ func (s *SqlSchemeStore) Delete(schemeId string) (*model.Scheme, error) { res, err := s.GetMasterX().NamedExec(`UPDATE Schemes SET UpdateAt=:UpdateAt, DeleteAt=:DeleteAt, CreateAt=:CreateAt, Name=:Name, DisplayName=:DisplayName, Description=:Description, Scope=:Scope, DefaultTeamAdminRole=:DefaultTeamAdminRole, DefaultTeamUserRole=:DefaultTeamUserRole, DefaultTeamGuestRole=:DefaultTeamGuestRole, - DefaultChannelAdminRole=:DefaultChannelAdminRole, DefaultChannelUserRole=:DefaultChannelUserRole, DefaultChannelGuestRole=:DefaultChannelGuestRole + DefaultChannelAdminRole=:DefaultChannelAdminRole, DefaultChannelUserRole=:DefaultChannelUserRole, DefaultChannelGuestRole=:DefaultChannelGuestRole WHERE Id=:Id`, &scheme) if err != nil { diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 71f9598ed0..4c1bf1f05b 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -108,7 +108,10 @@ type SqlStoreStores struct { UserTermsOfService store.UserTermsOfServiceStore linkMetadata store.LinkMetadataStore sharedchannel store.SharedChannelStore + draft store.DraftStore notifyAdmin store.NotifyAdminStore + postPriority store.PostPriorityStore + postAcknowledgement store.PostAcknowledgementStore } type SqlStore struct { @@ -213,7 +216,10 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.scheme = newSqlSchemeStore(store) store.stores.group = newSqlGroupStore(store) store.stores.productNotices = newSqlProductNoticesStore(store) + store.stores.draft = newSqlDraftStore(store, metrics) store.stores.notifyAdmin = newSqlNotifyAdminStore(store) + store.stores.postPriority = newSqlPostPriorityStore(store) + store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -335,6 +341,28 @@ func (ss *SqlStore) DriverName() string { return *ss.settings.DriverName } +// specialSearchChars have special meaning and can be treated as spaces +func (ss *SqlStore) specialSearchChars() []string { + chars := []string{ + "<", + ">", + "+", + "-", + "(", + ")", + "~", + ":", + } + + // Postgres can handle "@" without any errors + // Also helps postgres in enabling search for EmailAddresses + if ss.DriverName() != model.DatabaseDriverPostgres { + chars = append(chars, "@") + } + + return chars +} + // computeBinaryParam returns whether the data source uses binary_parameters // when using Postgres func (ss *SqlStore) computeBinaryParam() (bool, error) { @@ -933,6 +961,18 @@ func (ss *SqlStore) SharedChannel() store.SharedChannelStore { return ss.stores.sharedchannel } +func (ss *SqlStore) PostPriority() store.PostPriorityStore { + return ss.stores.postPriority +} + +func (ss *SqlStore) Draft() store.DraftStore { + return ss.stores.draft +} + +func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { + return ss.stores.postAcknowledgement +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/store/sqlstore/team_store.go b/store/sqlstore/team_store.go index cbe8535a21..00b3d731ae 100644 --- a/store/sqlstore/team_store.go +++ b/store/sqlstore/team_store.go @@ -1675,7 +1675,7 @@ func (s SqlTeamStore) GetNewTeamMembersSince(teamID string, since int64, offset return nil, 0, errors.Wrap(err, "failed to count team members since") } - newTeamMembersBuilder := builderF("Users.Id, Users.Username, Users.FirstName, Users.LastName, Users.Position, TeamMembers.CreateAt, Users.Nickname"). + newTeamMembersBuilder := builderF("Users.Id, Users.Username, Users.FirstName, Users.LastName, Users.Position, Users.LastPictureUpdate, TeamMembers.CreateAt, Users.Nickname"). Limit(uint64(limit + 1)). Offset(uint64(offset)) query, args, err = newTeamMembersBuilder.ToSql() diff --git a/store/sqlstore/thread_store.go b/store/sqlstore/thread_store.go index e314e82093..0de9c5db45 100644 --- a/store/sqlstore/thread_store.go +++ b/store/sqlstore/thread_store.go @@ -7,11 +7,11 @@ import ( "context" "database/sql" "strconv" - "sync" "time" sq "github.com/mattermost/squirrel" "github.com/pkg/errors" + "golang.org/x/sync/errgroup" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/store" @@ -30,6 +30,7 @@ type JoinedThread struct { Participants model.StringArray ThreadDeleteAt int64 TeamId string + IsUrgent bool model.Post } @@ -51,6 +52,7 @@ func (thread *JoinedThread) toThreadResponse(users map[string]*model.User) *mode Participants: threadParticipants, Post: thread.Post.ToNilIfInvalid(), DeleteAt: thread.ThreadDeleteAt, + IsUrgent: thread.IsUrgent, } } @@ -213,6 +215,46 @@ func (s *SqlThreadStore) GetTotalUnreadMentions(userId, teamId string, opts mode return totalUnreadMentions, nil } +// GetTotalUnreadUrgentMentions counts the number of unread mentions for the given user, optionally +// constrained to the given team + DMs/GMs. +func (s *SqlThreadStore) GetTotalUnreadUrgentMentions(userId, teamId string, opts model.GetUserThreadsOpts) (int64, error) { + var totalUnreadUrgentMentions int64 + + query := s.getQueryBuilder(). + Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0)"). + From("ThreadMemberships"). + Join("PostsPriority ON PostsPriority.PostId = ThreadMemberships.PostId"). + Where(sq.Eq{ + "ThreadMemberships.UserId": userId, + "ThreadMemberships.Following": true, + "PostsPriority.Priority": model.PostPriorityUrgent, + }) + + if teamId != "" || !opts.Deleted { + query = query.Join("Threads ON Threads.PostId = ThreadMemberships.PostId") + } + + if teamId != "" { + query = query. + Where(sq.Or{ + sq.Eq{"Threads.ThreadTeamId": teamId}, + sq.Eq{"Threads.ThreadTeamId": ""}, + }) + } + + if !opts.Deleted { + query = query. + Where(sq.Eq{"COALESCE(Threads.ThreadDeleteAt, 0)": 0}) + } + + err := s.GetReplicaX().GetBuilder(&totalUnreadUrgentMentions, query) + if err != nil { + return 0, errors.Wrapf(err, "failed to count unread urgent mentions for user id=%s", userId) + } + + return totalUnreadUrgentMentions, nil +} + func (s *SqlThreadStore) GetThreadsForUser(userId, teamId string, opts model.GetUserThreadsOpts) ([]*model.ThreadResponse, error) { pageSize := uint64(30) if opts.PageSize != 0 { @@ -243,6 +285,17 @@ func (s *SqlThreadStore) GetThreadsForUser(userId, teamId string, opts model.Get Where(sq.Eq{"ThreadMemberships.UserId": userId}). Where(sq.Eq{"ThreadMemberships.Following": true}) + if opts.IncludeIsUrgent { + urgencyCase := sq. + Case(). + When(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}, "true"). + Else("false") + + query = query. + Column(sq.Alias(urgencyCase, "IsUrgent")). + LeftJoin("PostsPriority ON PostsPriority.PostId = Threads.PostId") + } + // If a team is specified, constrain to channels in that team or DMs/GMs without // a team at all. if teamId != "" { @@ -322,7 +375,7 @@ func (s *SqlThreadStore) GetThreadsForUser(userId, teamId string, opts model.Get // GetTeamsUnreadForUser returns the total unread threads and unread mentions // for a user from all teams. -func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { +func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { fetchConditions := sq.And{ sq.Eq{"ThreadMemberships.UserId": userID}, sq.Eq{"ThreadMemberships.Following": true}, @@ -330,8 +383,7 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) sq.Eq{"COALESCE(Threads.ThreadDeleteAt, 0)": 0}, } - var wg sync.WaitGroup - var err1, err2 error + var eg errgroup.Group unreadThreads := []struct { Count int64 @@ -341,13 +393,15 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) Count int64 TeamId string }{} + unreadUrgentMentions := []struct { + Count int64 + TeamId string + }{} // Running these concurrently hasn't shown any major downside // than running them serially. So using a bit of perf boost. // In any case, they will be replaced by computed columns later. - wg.Add(1) - go func() { - defer wg.Done() + eg.Go(func() error { repliesQuery := s.getQueryBuilder(). Select("COUNT(Threads.PostId) AS Count, ThreadTeamId AS TeamId"). From("Threads"). @@ -356,15 +410,10 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) Where("Threads.LastReplyAt > ThreadMemberships.LastViewed"). GroupBy("Threads.ThreadTeamId") - err := s.GetReplicaX().SelectBuilder(&unreadThreads, repliesQuery) - if err != nil { - err1 = errors.Wrap(err, "failed to get total unread threads") - } - }() + return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadThreads, repliesQuery), "failed to get total unread threads") + }) - wg.Add(1) - go func() { - defer wg.Done() + eg.Go(func() error { mentionsQuery := s.getQueryBuilder(). Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0) AS Count, ThreadTeamId AS TeamId"). From("ThreadMemberships"). @@ -372,20 +421,27 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) Where(fetchConditions). GroupBy("Threads.ThreadTeamId") - err := s.GetReplicaX().SelectBuilder(&unreadMentions, mentionsQuery) - if err != nil { - err2 = errors.Wrap(err, "failed to get total unread mentions") - } - }() + return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadMentions, mentionsQuery), "failed to get total unread mentions") + }) + + if includeUrgentMentionCount { + eg.Go(func() error { + urgentMentionsQuery := s.getQueryBuilder(). + Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0) AS Count, ThreadTeamId AS TeamId"). + From("ThreadMemberships"). + LeftJoin("Threads ON Threads.PostId = ThreadMemberships.PostId"). + Join("PostsPriority ON PostsPriority.PostId = ThreadMemberships.PostId"). + Where(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}). + Where(fetchConditions). + GroupBy("Threads.ThreadTeamId") + + return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadUrgentMentions, urgentMentionsQuery), "failed to get total unread urgent mentions") + }) + } // Wait for them to be over - wg.Wait() - - if err1 != nil { - return nil, err1 - } - if err2 != nil { - return nil, err2 + if err := eg.Wait(); err != nil { + return nil, err } res := make(map[string]*model.TeamUnread) @@ -405,6 +461,15 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) } } } + for _, item := range unreadUrgentMentions { + if _, ok := res[item.TeamId]; ok { + res[item.TeamId].ThreadUrgentMentionCount = item.Count + } else { + res[item.TeamId] = &model.TeamUnread{ + ThreadUrgentMentionCount: item.Count, + } + } + } return res, nil } @@ -436,7 +501,7 @@ func (s *SqlThreadStore) GetThreadFollowers(threadID string, fetchOnlyActive boo return users, nil } -func (s *SqlThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { +func (s *SqlThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended, postPriorityEnabled bool) (*model.ThreadResponse, error) { if !threadMembership.Following { return nil, nil // in case the thread is not followed anymore - return nil error to be interpreted as 404 } @@ -462,6 +527,17 @@ func (s *SqlThreadStore) GetThreadForUser(threadMembership *model.ThreadMembersh LeftJoin("Posts ON Posts.Id = Threads.PostId"). Where(sq.Eq{"Threads.PostId": threadMembership.PostId}) + if postPriorityEnabled { + urgencyCase := sq. + Case(). + When(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}, "true"). + Else("false") + + query = query. + Column(sq.Alias(urgencyCase, "IsUrgent")). + LeftJoin("PostsPriority ON PostsPriority.PostId = Threads.PostId") + } + err := s.GetReplicaX().GetBuilder(&thread, query) if err != nil { if err == sql.ErrNoRows { diff --git a/store/store.go b/store/store.go index 055b0dfeea..6a2a7d6e1d 100644 --- a/store/store.go +++ b/store/store.go @@ -59,6 +59,7 @@ type Store interface { UserTermsOfService() UserTermsOfServiceStore LinkMetadata() LinkMetadataStore SharedChannel() SharedChannelStore + Draft() DraftStore MarkSystemRanUnitTests() Close() LockToMaster() @@ -84,6 +85,8 @@ type Store interface { SetContext(context context.Context) Context() context.Context NotifyAdmin() NotifyAdminStore + PostPriority() PostPriorityStore + PostAcknowledgement() PostAcknowledgementStore } type RetentionPolicyStore interface { @@ -240,9 +243,10 @@ type ChannelStore interface { PermanentDeleteMembersByUser(userID string) error PermanentDeleteMembersByChannel(channelID string) error UpdateLastViewedAt(channelIds []string, userID string) (map[string]int64, error) - UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) + UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) CountPostsAfter(channelID string, timestamp int64, userID string) (int, int, error) - IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error + CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) + IncrementMentionCount(channelID string, userIDs []string, isRoot, isUrgent bool) error AnalyticsTypeCount(teamID string, channelType model.ChannelType) (int64, error) GetMembersForUser(teamID string, userID string) (model.ChannelMembers, error) GetTeamMembersForChannel(channelID string) ([]string, error) @@ -263,6 +267,7 @@ type ChannelStore interface { AnalyticsDeletedTypeCount(teamID string, channelType model.ChannelType) (int64, error) GetChannelUnread(channelID, userID string) (*model.ChannelUnread, error) ClearCaches() + ClearMembersForUserCache() GetChannelsByScheme(schemeID string, offset int, limit int) (model.ChannelList, error) MigrateChannelMembers(fromChannelID string, fromUserID string) (map[string]string, error) ResetAllChannelSchemes() error @@ -322,9 +327,10 @@ type ThreadStore interface { GetTotalUnreadThreads(userId, teamID string, opts model.GetUserThreadsOpts) (int64, error) GetTotalThreads(userId, teamID string, opts model.GetUserThreadsOpts) (int64, error) GetTotalUnreadMentions(userId, teamID string, opts model.GetUserThreadsOpts) (int64, error) + GetTotalUnreadUrgentMentions(userId, teamID string, opts model.GetUserThreadsOpts) (int64, error) GetThreadsForUser(userId, teamID string, opts model.GetUserThreadsOpts) ([]*model.ThreadResponse, error) - GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) - GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) + GetThreadForUser(threadMembership *model.ThreadMembership, extended, postPriorityIsEnabled bool) (*model.ThreadResponse, error) + GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) MarkAllAsRead(userID string, threadIds []string) error MarkAllAsReadByTeam(userID, teamID string) error @@ -970,6 +976,27 @@ type SharedChannelStore interface { UpdateAttachmentLastSyncAt(id string, syncTime int64) error } +type PostPriorityStore interface { + GetForPost(postId string) (*model.PostPriority, error) + GetForPosts(ids []string) ([]*model.PostPriority, error) +} + +type DraftStore interface { + Save(d *model.Draft) (*model.Draft, error) + Get(userID, channelID, rootID string, includeDeleted bool) (*model.Draft, error) + Delete(userID, channelID, rootID string) error + GetDraftsForUser(userID, teamID string) ([]*model.Draft, error) + Update(d *model.Draft) (*model.Draft, error) +} + +type PostAcknowledgementStore interface { + Get(postID, userID string) (*model.PostAcknowledgement, error) + GetForPost(postID string) ([]*model.PostAcknowledgement, error) + GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) + Save(postID, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) + Delete(acknowledgement *model.PostAcknowledgement) error +} + // ChannelSearchOpts contains options for searching channels. // // NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records. diff --git a/store/storetest/channel_store.go b/store/storetest/channel_store.go index c1843bc8be..28e66f1bef 100644 --- a/store/storetest/channel_store.go +++ b/store/storetest/channel_store.go @@ -104,6 +104,7 @@ func TestChannelStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("GetMembersForUserWithCursor", func(t *testing.T) { testChannelStoreGetMembersForUserWithCursor(t, ss) }) t.Run("GetMembersForUserWithPagination", func(t *testing.T) { testChannelStoreGetMembersForUserWithPagination(t, ss) }) t.Run("CountPostsAfter", func(t *testing.T) { testCountPostsAfter(t, ss) }) + t.Run("CountUrgentPostsAfter", func(t *testing.T) { testCountUrgentPostsAfter(t, ss) }) t.Run("UpdateLastViewedAt", func(t *testing.T) { testChannelStoreUpdateLastViewedAt(t, ss) }) t.Run("IncrementMentionCount", func(t *testing.T) { testChannelStoreIncrementMentionCount(t, ss) }) t.Run("UpdateChannelMember", func(t *testing.T) { testUpdateChannelMember(t, ss) }) @@ -4833,6 +4834,66 @@ func testCountPostsAfter(t *testing.T, ss store.Store) { }) } +func testCountUrgentPostsAfter(t *testing.T, ss store.Store) { + t.Run("should count all posts with or without the given user ID", func(t *testing.T) { + userId1 := model.NewId() + userId2 := model.NewId() + + channelId := model.NewId() + + p1, err := ss.Post().Save(&model.Post{ + UserId: userId1, + ChannelId: channelId, + CreateAt: 1000, + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + }, + }) + require.NoError(t, err) + + _, err = ss.Post().Save(&model.Post{ + UserId: userId1, + ChannelId: channelId, + CreateAt: 1001, + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + }, + }) + require.NoError(t, err) + + _, err = ss.Post().Save(&model.Post{ + UserId: userId2, + ChannelId: channelId, + CreateAt: 1002, + }) + require.NoError(t, err) + + count, err := ss.Channel().CountUrgentPostsAfter(channelId, p1.CreateAt-1, "") + require.NoError(t, err) + assert.Equal(t, 1, count) + + count, err = ss.Channel().CountUrgentPostsAfter(channelId, p1.CreateAt, "") + require.NoError(t, err) + assert.Equal(t, 0, count) + + count, err = ss.Channel().CountUrgentPostsAfter(channelId, p1.CreateAt-1, userId1) + require.NoError(t, err) + assert.Equal(t, 1, count) + + count, err = ss.Channel().CountUrgentPostsAfter(channelId, p1.CreateAt, userId1) + require.NoError(t, err) + assert.Equal(t, 0, count) + }) +} + func testChannelStoreUpdateLastViewedAt(t *testing.T, ss store.Store) { o1 := model.Channel{} o1.TeamId = model.NewId() @@ -4912,16 +4973,16 @@ func testChannelStoreIncrementMentionCount(t *testing.T, ss store.Store) { _, err := ss.Channel().SaveMember(&m1) require.NoError(t, err) - err = ss.Channel().IncrementMentionCount(m1.ChannelId, []string{m1.UserId}, false) + err = ss.Channel().IncrementMentionCount(m1.ChannelId, []string{m1.UserId}, false, false) require.NoError(t, err, "failed to update") - err = ss.Channel().IncrementMentionCount(m1.ChannelId, []string{"missing id"}, false) + err = ss.Channel().IncrementMentionCount(m1.ChannelId, []string{"missing id"}, false, false) require.NoError(t, err, "failed to update") - err = ss.Channel().IncrementMentionCount("missing id", []string{m1.UserId}, false) + err = ss.Channel().IncrementMentionCount("missing id", []string{m1.UserId}, false, false) require.NoError(t, err, "failed to update") - err = ss.Channel().IncrementMentionCount("missing id", []string{"missing id"}, false) + err = ss.Channel().IncrementMentionCount("missing id", []string{"missing id"}, false, false) require.NoError(t, err, "failed to update") } diff --git a/store/storetest/compliance_store.go b/store/storetest/compliance_store.go index b052332b1d..f39b1c43ef 100644 --- a/store/storetest/compliance_store.go +++ b/store/storetest/compliance_store.go @@ -782,7 +782,7 @@ func testMessageExportGroupMessageChannel(t *testing.T, ss store.Store) { assert.Equal(t, user1.Username, *messageExportMap[post.Id].Username) } -//post,edit,export +// post,edit,export func testEditExportMessage(t *testing.T, ss store.Store) { defer cleanupStoreState(t, ss) // get the starting number of message export entries @@ -874,7 +874,7 @@ func testEditExportMessage(t *testing.T, ss store.Store) { } } -//post, export, edit, export +// post, export, edit, export func testEditAfterExportMessage(t *testing.T, ss store.Store) { defer cleanupStoreState(t, ss) // get the starting number of message export entries @@ -985,7 +985,7 @@ func testEditAfterExportMessage(t *testing.T, ss store.Store) { } } -//post, delete, export +// post, delete, export func testDeleteExportMessage(t *testing.T, ss store.Store) { defer cleanupStoreState(t, ss) // get the starting number of message export entries @@ -1070,7 +1070,7 @@ func testDeleteExportMessage(t *testing.T, ss store.Store) { assert.Equal(t, user1.Username, *v.Username) } -//post,export,delete,export +// post,export,delete,export func testDeleteAfterExportMessage(t *testing.T, ss store.Store) { defer cleanupStoreState(t, ss) // get the starting number of message export entries diff --git a/store/storetest/draft_store.go b/store/storetest/draft_store.go new file mode 100644 index 0000000000..9bbe65ee82 --- /dev/null +++ b/store/storetest/draft_store.go @@ -0,0 +1,359 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDraftStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("SaveDraft", func(t *testing.T) { testSaveDraft(t, ss) }) + t.Run("UpdateDraft", func(t *testing.T) { testUpdateDraft(t, ss) }) + t.Run("DeleteDraft", func(t *testing.T) { testDeleteDraft(t, ss) }) + t.Run("GetDraft", func(t *testing.T) { testGetDraft(t, ss) }) + t.Run("GetDraftsForUser", func(t *testing.T) { testGetDraftsForUser(t, ss) }) +} + +func testSaveDraft(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("save drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Save(draft1) + assert.NoError(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Save(draft2) + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) +} + +func testUpdateDraft(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("update drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Update(draft1) + assert.NoError(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Update(draft2) + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) +} + +func testDeleteDraft(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("delete drafts", func(t *testing.T) { + err := ss.Draft().Delete(user.Id, channel.Id, "") + assert.NoError(t, err) + + err = ss.Draft().Delete(user.Id, channel2.Id, "") + assert.NoError(t, err) + + _, err = ss.Draft().Get(user.Id, channel.Id, "", false) + require.Error(t, err) + assert.IsType(t, &store.ErrNotFound{}, err) + + _, err = ss.Draft().Get(user.Id, channel2.Id, "", false) + assert.Error(t, err) + assert.IsType(t, &store.ErrNotFound{}, err) + }) +} + +func testGetDraft(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Get(user.Id, channel.Id, "", false) + assert.NoError(t, err) + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Get(user.Id, channel2.Id, "", false) + assert.NoError(t, err) + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) + + t.Run("get draft including deleted", func(t *testing.T) { + draftResp, err := ss.Draft().Get(user.Id, channel.Id, "", false) + assert.NoError(t, err) + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + err = ss.Draft().Delete(user.Id, channel.Id, "") + assert.NoError(t, err) + _, err = ss.Draft().Get(user.Id, channel.Id, "", false) + assert.Error(t, err) + assert.IsType(t, &store.ErrNotFound{}, err) + + draftResp, err = ss.Draft().Get(user.Id, channel.Id, "", true) + assert.NoError(t, err) + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + }) +} + +func testGetDraftsForUser(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := ss.Draft().GetDraftsForUser(user.Id, "") + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + }) +} diff --git a/store/storetest/file_info_store.go b/store/storetest/file_info_store.go index 4df67ae24a..7155f2f538 100644 --- a/store/storetest/file_info_store.go +++ b/store/storetest/file_info_store.go @@ -810,6 +810,7 @@ func testFileInfoGetStorageUsage(t *testing.T, ss store.Store) { } func testGetUptoNSizeFileTime(t *testing.T, ss store.Store) { + t.Skip("MM-48627") _, err := ss.FileInfo().GetUptoNSizeFileTime(0) assert.Error(t, err) _, err = ss.FileInfo().GetUptoNSizeFileTime(-1) diff --git a/store/storetest/mocks/ChannelStore.go b/store/storetest/mocks/ChannelStore.go index 0b73494d83..f54a4266d8 100644 --- a/store/storetest/mocks/ChannelStore.go +++ b/store/storetest/mocks/ChannelStore.go @@ -150,6 +150,11 @@ func (_m *ChannelStore) ClearCaches() { _m.Called() } +// ClearMembersForUserCache provides a mock function with given fields: +func (_m *ChannelStore) ClearMembersForUserCache() { + _m.Called() +} + // ClearSidebarOnTeamLeave provides a mock function with given fields: userID, teamID func (_m *ChannelStore) ClearSidebarOnTeamLeave(userID string, teamID string) error { ret := _m.Called(userID, teamID) @@ -192,6 +197,27 @@ func (_m *ChannelStore) CountPostsAfter(channelID string, timestamp int64, userI return r0, r1, r2 } +// CountUrgentPostsAfter provides a mock function with given fields: channelID, timestamp, userID +func (_m *ChannelStore) CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) { + ret := _m.Called(channelID, timestamp, userID) + + var r0 int + if rf, ok := ret.Get(0).(func(string, int64, string) int); ok { + r0 = rf(channelID, timestamp, userID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int64, string) error); ok { + r1 = rf(channelID, timestamp, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateDirectChannel provides a mock function with given fields: userID, otherUserID, channelOptions func (_m *ChannelStore) CreateDirectChannel(userID *model.User, otherUserID *model.User, channelOptions ...model.ChannelOption) (*model.Channel, error) { _va := make([]interface{}, len(channelOptions)) @@ -1646,13 +1672,13 @@ func (_m *ChannelStore) GroupSyncedChannelCount() (int64, error) { return r0, r1 } -// IncrementMentionCount provides a mock function with given fields: channelID, userIDs, isRoot -func (_m *ChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error { - ret := _m.Called(channelID, userIDs, isRoot) +// IncrementMentionCount provides a mock function with given fields: channelID, userIDs, isRoot, isUrgent +func (_m *ChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool, isUrgent bool) error { + ret := _m.Called(channelID, userIDs, isRoot, isUrgent) var r0 error - if rf, ok := ret.Get(0).(func(string, []string, bool) error); ok { - r0 = rf(channelID, userIDs, isRoot) + if rf, ok := ret.Get(0).(func(string, []string, bool, bool) error); ok { + r0 = rf(channelID, userIDs, isRoot, isUrgent) } else { r0 = ret.Error(0) } @@ -2192,13 +2218,13 @@ func (_m *ChannelStore) UpdateLastViewedAt(channelIds []string, userID string) ( return r0, r1 } -// UpdateLastViewedAtPost provides a mock function with given fields: unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot -func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { - ret := _m.Called(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) +// UpdateLastViewedAtPost provides a mock function with given fields: unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot +func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { + ret := _m.Called(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) var r0 *model.ChannelUnreadAt - if rf, ok := ret.Get(0).(func(*model.Post, string, int, int, bool) *model.ChannelUnreadAt); ok { - r0 = rf(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + if rf, ok := ret.Get(0).(func(*model.Post, string, int, int, int, bool) *model.ChannelUnreadAt); ok { + r0 = rf(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.ChannelUnreadAt) @@ -2206,8 +2232,8 @@ func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID st } var r1 error - if rf, ok := ret.Get(1).(func(*model.Post, string, int, int, bool) error); ok { - r1 = rf(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + if rf, ok := ret.Get(1).(func(*model.Post, string, int, int, int, bool) error); ok { + r1 = rf(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) } else { r1 = ret.Error(1) } diff --git a/store/storetest/mocks/DraftStore.go b/store/storetest/mocks/DraftStore.go new file mode 100644 index 0000000000..1eb7d5f17b --- /dev/null +++ b/store/storetest/mocks/DraftStore.go @@ -0,0 +1,121 @@ +// Code generated by mockery v2.10.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v6/model" + mock "github.com/stretchr/testify/mock" +) + +// DraftStore is an autogenerated mock type for the DraftStore type +type DraftStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: userID, channelID, rootID +func (_m *DraftStore) Delete(userID string, channelID string, rootID string) error { + ret := _m.Called(userID, channelID, rootID) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = rf(userID, channelID, rootID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: userID, channelID, rootID, includeDeleted +func (_m *DraftStore) Get(userID string, channelID string, rootID string, includeDeleted bool) (*model.Draft, error) { + ret := _m.Called(userID, channelID, rootID, includeDeleted) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(string, string, string, bool) *model.Draft); ok { + r0 = rf(userID, channelID, rootID, includeDeleted) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string, bool) error); ok { + r1 = rf(userID, channelID, rootID, includeDeleted) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetDraftsForUser provides a mock function with given fields: userID, teamID +func (_m *DraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + ret := _m.Called(userID, teamID) + + var r0 []*model.Draft + if rf, ok := ret.Get(0).(func(string, string) []*model.Draft); ok { + r0 = rf(userID, teamID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(userID, teamID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: d +func (_m *DraftStore) Save(d *model.Draft) (*model.Draft, error) { + ret := _m.Called(d) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(*model.Draft) *model.Draft); ok { + r0 = rf(d) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Draft) error); ok { + r1 = rf(d) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: d +func (_m *DraftStore) Update(d *model.Draft) (*model.Draft, error) { + ret := _m.Called(d) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(*model.Draft) *model.Draft); ok { + r0 = rf(d) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Draft) error); ok { + r1 = rf(d) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/PostAcknowledgementStore.go b/store/storetest/mocks/PostAcknowledgementStore.go new file mode 100644 index 0000000000..ad130137da --- /dev/null +++ b/store/storetest/mocks/PostAcknowledgementStore.go @@ -0,0 +1,121 @@ +// Code generated by mockery v2.10.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v6/model" + mock "github.com/stretchr/testify/mock" +) + +// PostAcknowledgementStore is an autogenerated mock type for the PostAcknowledgementStore type +type PostAcknowledgementStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: acknowledgement +func (_m *PostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + ret := _m.Called(acknowledgement) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.PostAcknowledgement) error); ok { + r0 = rf(acknowledgement) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: postID, userID +func (_m *PostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + ret := _m.Called(postID, userID) + + var r0 *model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string, string) *model.PostAcknowledgement); ok { + r0 = rf(postID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(postID, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForPost provides a mock function with given fields: postID +func (_m *PostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + ret := _m.Called(postID) + + var r0 []*model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string) []*model.PostAcknowledgement); ok { + r0 = rf(postID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForPosts provides a mock function with given fields: postIds +func (_m *PostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + ret := _m.Called(postIds) + + var r0 []*model.PostAcknowledgement + if rf, ok := ret.Get(0).(func([]string) []*model.PostAcknowledgement); ok { + r0 = rf(postIds) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(postIds) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: postID, userID, acknowledgedAt +func (_m *PostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + ret := _m.Called(postID, userID, acknowledgedAt) + + var r0 *model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string, string, int64) *model.PostAcknowledgement); ok { + r0 = rf(postID, userID, acknowledgedAt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, int64) error); ok { + r1 = rf(postID, userID, acknowledgedAt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/PostPriorityStore.go b/store/storetest/mocks/PostPriorityStore.go new file mode 100644 index 0000000000..de126ec11a --- /dev/null +++ b/store/storetest/mocks/PostPriorityStore.go @@ -0,0 +1,61 @@ +// Code generated by mockery v2.10.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v6/model" + mock "github.com/stretchr/testify/mock" +) + +// PostPriorityStore is an autogenerated mock type for the PostPriorityStore type +type PostPriorityStore struct { + mock.Mock +} + +// GetForPost provides a mock function with given fields: postId +func (_m *PostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + ret := _m.Called(postId) + + var r0 *model.PostPriority + if rf, ok := ret.Get(0).(func(string) *model.PostPriority); ok { + r0 = rf(postId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostPriority) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForPosts provides a mock function with given fields: ids +func (_m *PostPriorityStore) GetForPosts(ids []string) ([]*model.PostPriority, error) { + ret := _m.Called(ids) + + var r0 []*model.PostPriority + if rf, ok := ret.Get(0).(func([]string) []*model.PostPriority); ok { + r0 = rf(ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostPriority) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index bd7c7e81c7..395cc3e9f1 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -187,6 +187,22 @@ func (_m *Store) Context() context.Context { return r0 } +// Draft provides a mock function with given fields: +func (_m *Store) Draft() store.DraftStore { + ret := _m.Called() + + var r0 store.DraftStore + if rf, ok := ret.Get(0).(func() store.DraftStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.DraftStore) + } + } + + return r0 +} + // DropAllTables provides a mock function with given fields: func (_m *Store) DropAllTables() { _m.Called() @@ -475,6 +491,38 @@ func (_m *Store) Post() store.PostStore { return r0 } +// PostAcknowledgement provides a mock function with given fields: +func (_m *Store) PostAcknowledgement() store.PostAcknowledgementStore { + ret := _m.Called() + + var r0 store.PostAcknowledgementStore + if rf, ok := ret.Get(0).(func() store.PostAcknowledgementStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PostAcknowledgementStore) + } + } + + return r0 +} + +// PostPriority provides a mock function with given fields: +func (_m *Store) PostPriority() store.PostPriorityStore { + ret := _m.Called() + + var r0 store.PostPriorityStore + if rf, ok := ret.Get(0).(func() store.PostPriorityStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PostPriorityStore) + } + } + + return r0 +} + // Preference provides a mock function with given fields: func (_m *Store) Preference() store.PreferenceStore { ret := _m.Called() diff --git a/store/storetest/mocks/ThreadStore.go b/store/storetest/mocks/ThreadStore.go index 19d82db686..caf49a4e5e 100644 --- a/store/storetest/mocks/ThreadStore.go +++ b/store/storetest/mocks/ThreadStore.go @@ -119,13 +119,13 @@ func (_m *ThreadStore) GetMembershipsForUser(userId string, teamID string) ([]*m return r0, r1 } -// GetTeamsUnreadForUser provides a mock function with given fields: userID, teamIDs -func (_m *ThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { - ret := _m.Called(userID, teamIDs) +// GetTeamsUnreadForUser provides a mock function with given fields: userID, teamIDs, includeUrgentMentionCount +func (_m *ThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { + ret := _m.Called(userID, teamIDs, includeUrgentMentionCount) var r0 map[string]*model.TeamUnread - if rf, ok := ret.Get(0).(func(string, []string) map[string]*model.TeamUnread); ok { - r0 = rf(userID, teamIDs) + if rf, ok := ret.Get(0).(func(string, []string, bool) map[string]*model.TeamUnread); ok { + r0 = rf(userID, teamIDs, includeUrgentMentionCount) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]*model.TeamUnread) @@ -133,8 +133,8 @@ func (_m *ThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (m } var r1 error - if rf, ok := ret.Get(1).(func(string, []string) error); ok { - r1 = rf(userID, teamIDs) + if rf, ok := ret.Get(1).(func(string, []string, bool) error); ok { + r1 = rf(userID, teamIDs, includeUrgentMentionCount) } else { r1 = ret.Error(1) } @@ -165,13 +165,13 @@ func (_m *ThreadStore) GetThreadFollowers(threadID string, fetchOnlyActive bool) return r0, r1 } -// GetThreadForUser provides a mock function with given fields: threadMembership, extended -func (_m *ThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { - ret := _m.Called(threadMembership, extended) +// GetThreadForUser provides a mock function with given fields: threadMembership, extended, postPriorityIsEnabled +func (_m *ThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool, postPriorityIsEnabled bool) (*model.ThreadResponse, error) { + ret := _m.Called(threadMembership, extended, postPriorityIsEnabled) var r0 *model.ThreadResponse - if rf, ok := ret.Get(0).(func(*model.ThreadMembership, bool) *model.ThreadResponse); ok { - r0 = rf(threadMembership, extended) + if rf, ok := ret.Get(0).(func(*model.ThreadMembership, bool, bool) *model.ThreadResponse); ok { + r0 = rf(threadMembership, extended, postPriorityIsEnabled) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.ThreadResponse) @@ -179,8 +179,8 @@ func (_m *ThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership } var r1 error - if rf, ok := ret.Get(1).(func(*model.ThreadMembership, bool) error); ok { - r1 = rf(threadMembership, extended) + if rf, ok := ret.Get(1).(func(*model.ThreadMembership, bool, bool) error); ok { + r1 = rf(threadMembership, extended, postPriorityIsEnabled) } else { r1 = ret.Error(1) } @@ -341,6 +341,27 @@ func (_m *ThreadStore) GetTotalUnreadThreads(userId string, teamID string, opts return r0, r1 } +// GetTotalUnreadUrgentMentions provides a mock function with given fields: userId, teamID, opts +func (_m *ThreadStore) GetTotalUnreadUrgentMentions(userId string, teamID string, opts model.GetUserThreadsOpts) (int64, error) { + ret := _m.Called(userId, teamID, opts) + + var r0 int64 + if rf, ok := ret.Get(0).(func(string, string, model.GetUserThreadsOpts) int64); ok { + r0 = rf(userId, teamID, opts) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, model.GetUserThreadsOpts) error); ok { + r1 = rf(userId, teamID, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // MaintainMembership provides a mock function with given fields: userID, postID, opts func (_m *ThreadStore) MaintainMembership(userID string, postID string, opts store.ThreadMembershipOpts) (*model.ThreadMembership, error) { ret := _m.Called(userID, postID, opts) diff --git a/store/storetest/post_acknowledgements_store.go b/store/storetest/post_acknowledgements_store.go new file mode 100644 index 0000000000..ac751119bf --- /dev/null +++ b/store/storetest/post_acknowledgements_store.go @@ -0,0 +1,181 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/stretchr/testify/require" +) + +func TestPostAcknowledgementsStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("Save", func(t *testing.T) { testPostAcknowledgementsStoreSave(t, ss) }) + t.Run("GetForPost", func(t *testing.T) { testPostAcknowledgementsStoreGetForPost(t, ss) }) + t.Run("GetForPosts", func(t *testing.T) { testPostAcknowledgementsStoreGetForPosts(t, ss) }) +} + +func testPostAcknowledgementsStoreSave(t *testing.T, ss store.Store) { + userId1 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, err := ss.Post().Save(&p1) + require.NoError(t, err) + + t.Run("consecutive saves should just update the acknowledged at", func(t *testing.T) { + _, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + _, err = ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1}) + }) +} + +func testPostAcknowledgementsStoreGetForPost(t *testing.T, ss store.Store) { + userId1 := model.NewId() + userId2 := model.NewId() + userId3 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, err := ss.Post().Save(&p1) + require.NoError(t, err) + + t.Run("get acknowledgements for post", func(t *testing.T) { + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + ack2, err := ss.PostAcknowledgement().Save(p1.Id, userId2, 0) + require.NoError(t, err) + ack3, err := ss.PostAcknowledgement().Save(p1.Id, userId3, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2, ack3}) + + err = ss.PostAcknowledgement().Delete(ack1) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack2, ack3}) + + err = ss.PostAcknowledgement().Delete(ack2) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3}) + + err = ss.PostAcknowledgement().Delete(ack3) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.Empty(t, acknowledgements) + }) +} + +func testPostAcknowledgementsStoreGetForPosts(t *testing.T, ss store.Store) { + userId1 := model.NewId() + userId2 := model.NewId() + userId3 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + p2 := model.Post{} + p2.ChannelId = model.NewId() + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(""), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + t.Run("get acknowledgements for post", func(t *testing.T) { + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + ack2, err := ss.PostAcknowledgement().Save(p1.Id, userId2, 0) + require.NoError(t, err) + ack3, err := ss.PostAcknowledgement().Save(p2.Id, userId2, 0) + require.NoError(t, err) + ack4, err := ss.PostAcknowledgement().Save(p2.Id, userId3, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPosts([]string{p1.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2}) + + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3, ack4}) + + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2, ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack1) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack2, ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack2) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack3) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack4}) + + err = ss.PostAcknowledgement().Delete(ack4) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.Empty(t, acknowledgements) + }) +} diff --git a/store/storetest/post_priority_store.go b/store/storetest/post_priority_store.go new file mode 100644 index 0000000000..68efa6ec8d --- /dev/null +++ b/store/storetest/post_priority_store.go @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "database/sql" + "errors" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostPriorityStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("GetForPost", func(t *testing.T) { testPostPriorityStoreGetForPost(t, ss) }) +} + +func testPostPriorityStoreGetForPost(t *testing.T, ss store.Store) { + + t.Run("Save post priority when in post's metadata", func(t *testing.T) { + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + + p2 := model.Post{} + p2.ChannelId = model.NewId() + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p3 := model.Post{} + p3.ChannelId = model.NewId() + p3.UserId = model.NewId() + p3.Message = NewTestId() + + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2, &p3}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + pp1, err := ss.PostPriority().GetForPost(p1.Id) + require.NoError(t, err) + assert.Equal(t, "important", *pp1.Priority) + assert.Equal(t, true, *pp1.RequestedAck) + assert.Equal(t, false, *pp1.PersistentNotifications) + + pp2, err := ss.PostPriority().GetForPost(p2.Id) + require.NoError(t, err) + assert.Equal(t, model.PostPriorityUrgent, *pp2.Priority) + assert.Equal(t, false, *pp2.RequestedAck) + assert.Equal(t, true, *pp2.PersistentNotifications) + + _, err = ss.PostPriority().GetForPost(p3.Id) + assert.True(t, errors.Is(err, sql.ErrNoRows)) + }) +} diff --git a/store/storetest/post_store.go b/store/storetest/post_store.go index 677d049218..f3c7d91630 100644 --- a/store/storetest/post_store.go +++ b/store/storetest/post_store.go @@ -238,6 +238,31 @@ func testPostStoreSave(t *testing.T, ss store.Store) { assert.Greater(t, rchannel3.LastPostAt, rchannel2.LastPostAt) assert.Equal(t, int64(3), rchannel3.TotalMsgCount) }) + + t.Run("Save post with priority metadata set", func(t *testing.T) { + o1 := model.Post{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.Message = NewTestId() + + o1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + + p, err := ss.Post().Save(&o1) + require.NoError(t, err, "couldn't save item") + assert.Equal(t, int64(0), p.ReplyCount) + + pp, err := ss.PostPriority().GetForPost(p.Id) + require.NoError(t, err, "couldn't save item") + assert.Equal(t, "important", *pp.Priority) + assert.Equal(t, true, *pp.RequestedAck) + assert.Equal(t, false, *pp.PersistentNotifications) + }) } func testPostStoreSaveMultiple(t *testing.T, ss store.Store) { diff --git a/store/storetest/store.go b/store/storetest/store.go index 7979f8dac5..e873ae4994 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -54,8 +54,11 @@ type Store struct { LinkMetadataStore mocks.LinkMetadataStore SharedChannelStore mocks.SharedChannelStore ProductNoticesStore mocks.ProductNoticesStore + DraftStore mocks.DraftStore context context.Context NotifyAdminStore mocks.NotifyAdminStore + PostPriorityStore mocks.PostPriorityStore + PostAcknowledgementStore mocks.PostAcknowledgementStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -93,6 +96,7 @@ func (s *Store) Role() store.RoleStore { return &s.R func (s *Store) Scheme() store.SchemeStore { return &s.SchemeStore } func (s *Store) TermsOfService() store.TermsOfServiceStore { return &s.TermsOfServiceStore } func (s *Store) UserTermsOfService() store.UserTermsOfServiceStore { return &s.UserTermsOfServiceStore } +func (s *Store) Draft() store.DraftStore { return &s.DraftStore } func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } @@ -100,17 +104,21 @@ func (s *Store) NotifyAdmin() store.NotifyAdminStore { return &s.NotifyAdmin func (s *Store) Group() store.GroupStore { return &s.GroupStore } func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMetadataStore } func (s *Store) SharedChannel() store.SharedChannelStore { return &s.SharedChannelStore } -func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } -func (s *Store) Close() { /* do nothing */ } -func (s *Store) LockToMaster() { /* do nothing */ } -func (s *Store) UnlockFromMaster() { /* do nothing */ } -func (s *Store) DropAllTables() { /* do nothing */ } -func (s *Store) GetDbVersion(bool) (string, error) { return "", nil } -func (s *Store) GetInternalMasterDB() *sql.DB { return nil } -func (s *Store) GetInternalReplicaDB() *sql.DB { return nil } -func (s *Store) GetInternalReplicaDBs() []*sql.DB { return nil } -func (s *Store) RecycleDBConnections(time.Duration) {} -func (s *Store) GetDBSchemaVersion() (int, error) { return 1, nil } +func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorityStore } +func (s *Store) PostAcknowledgement() store.PostAcknowledgementStore { + return &s.PostAcknowledgementStore +} +func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } +func (s *Store) Close() { /* do nothing */ } +func (s *Store) LockToMaster() { /* do nothing */ } +func (s *Store) UnlockFromMaster() { /* do nothing */ } +func (s *Store) DropAllTables() { /* do nothing */ } +func (s *Store) GetDbVersion(bool) (string, error) { return "", nil } +func (s *Store) GetInternalMasterDB() *sql.DB { return nil } +func (s *Store) GetInternalReplicaDB() *sql.DB { return nil } +func (s *Store) GetInternalReplicaDBs() []*sql.DB { return nil } +func (s *Store) RecycleDBConnections(time.Duration) {} +func (s *Store) GetDBSchemaVersion() (int, error) { return 1, nil } func (s *Store) GetAppliedMigrations() ([]model.AppliedMigration, error) { return []model.AppliedMigration{}, nil } @@ -157,6 +165,9 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.ThreadStore, &s.ProductNoticesStore, &s.SharedChannelStore, + &s.DraftStore, &s.NotifyAdminStore, + &s.PostPriorityStore, + &s.PostAcknowledgementStore, ) } diff --git a/store/storetest/thread_store.go b/store/storetest/thread_store.go index e90e9f7d89..8bf646b503 100644 --- a/store/storetest/thread_store.go +++ b/store/storetest/thread_store.go @@ -32,7 +32,7 @@ func TestThreadStore(t *testing.T, ss store.Store, s SqlStore) { } func testThreadStorePopulation(t *testing.T, ss store.Store) { - makeSomePosts := func() []*model.Post { + makeSomePosts := func(urgent bool) []*model.Post { u1 := model.User{ Email: MakeEmail(), @@ -61,6 +61,16 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { o.UserId = u.Id o.Message = NewTestId() + if urgent { + o.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + } + } + otmp, err3 := ss.Post().Save(&o) require.NoError(t, err3) o2 := model.Post{} @@ -100,7 +110,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { return newPosts } t.Run("Save replies creates a thread", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) thread, err := ss.Thread().Get(newPosts[0].Id) require.NoError(t, err, "couldn't get thread") require.NotNil(t, thread) @@ -133,7 +143,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Delete a reply updates count on a thread", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) thread, err := ss.Thread().Get(newPosts[0].Id) require.NoError(t, err, "couldn't get thread") require.NotNil(t, thread) @@ -307,7 +317,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Thread membership 'viewed' timestamp is updated properly", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, @@ -341,7 +351,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Thread membership 'viewed' timestamp is updated properly for new membership", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, @@ -356,7 +366,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Updating post does not make thread unread", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, IncrementMentions: false, @@ -366,14 +376,14 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { } m, err := ss.Thread().MaintainMembership(newPosts[0].UserId, newPosts[0].Id, opts) require.NoError(t, err) - th, err := ss.Thread().GetThreadForUser(m, false) + th, err := ss.Thread().GetThreadForUser(m, false, false) require.NoError(t, err) require.Equal(t, int64(2), th.UnreadReplies) m.LastViewed = newPosts[2].UpdateAt + 1 _, err = ss.Thread().UpdateMembership(m) require.NoError(t, err) - th, err = ss.Thread().GetThreadForUser(m, false) + th, err = ss.Thread().GetThreadForUser(m, false, false) require.NoError(t, err) require.Equal(t, int64(0), th.UnreadReplies) @@ -382,13 +392,13 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { _, err = ss.Post().Update(editedPost, newPosts[2]) require.NoError(t, err) - th, err = ss.Thread().GetThreadForUser(m, false) + th, err = ss.Thread().GetThreadForUser(m, false, false) require.NoError(t, err) require.Equal(t, int64(0), th.UnreadReplies) }) t.Run("Empty participantID should not appear in thread response", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, IncrementMentions: false, @@ -399,7 +409,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { m, err := ss.Thread().MaintainMembership("", newPosts[0].Id, opts) require.NoError(t, err) m.UserId = newPosts[0].UserId - th, err := ss.Thread().GetThreadForUser(m, true) + th, err := ss.Thread().GetThreadForUser(m, true, false) require.NoError(t, err) for _, user := range th.Participants { require.NotNil(t, user) @@ -407,7 +417,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Get unread reply counts for thread", func(t *testing.T) { t.Skip("MM-41797") - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, IncrementMentions: false, @@ -435,6 +445,36 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { require.NoError(t, err) require.Equal(t, int64(2), unreads) }) + + testCases := []bool{true, false} + + for _, isUrgent := range testCases { + t.Run("Return is urgent for user thread/s", func(t *testing.T) { + newPosts := makeSomePosts(isUrgent) + opts := store.ThreadMembershipOpts{ + Following: true, + IncrementMentions: false, + UpdateFollowing: true, + UpdateViewedTimestamp: true, + UpdateParticipants: false, + } + + userID := newPosts[0].UserId + _, e := ss.Thread().MaintainMembership(userID, newPosts[0].Id, opts) + require.NoError(t, e) + + m, e := ss.Thread().GetMembershipForUser(userID, newPosts[0].Id) + require.NoError(t, e) + + th, e := ss.Thread().GetThreadForUser(m, false, true) + require.NoError(t, e) + require.Equal(t, isUrgent, th.IsUrgent) + + threads, e := ss.Thread().GetThreadsForUser(userID, "", model.GetUserThreadsOpts{IncludeIsUrgent: true}) + require.NoError(t, e) + require.Equal(t, isUrgent, threads[0].IsUrgent) + }) + } } func threadStoreCreateReply(t *testing.T, ss store.Store, channelID, postID, userID string, createAt int64) *model.Post { @@ -660,7 +700,7 @@ func testGetTeamsUnreadForUser(t *testing.T, ss store.Store) { threadStoreCreateReply(t, ss, channel1.Id, post.Id, post.UserId, model.GetMillis()) createThreadMembership(userID, post.Id) - teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}) + teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}, true) require.NoError(t, err) assert.Len(t, teamsUnread, 1) assert.Equal(t, int64(1), teamsUnread[team1.Id].ThreadCount) @@ -674,7 +714,7 @@ func testGetTeamsUnreadForUser(t *testing.T, ss store.Store) { threadStoreCreateReply(t, ss, channel1.Id, post.Id, post.UserId, model.GetMillis()) createThreadMembership(userID, post.Id) - teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}) + teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}, true) require.NoError(t, err) assert.Len(t, teamsUnread, 1) assert.Equal(t, int64(2), teamsUnread[team1.Id].ThreadCount) @@ -693,16 +733,24 @@ func testGetTeamsUnreadForUser(t *testing.T, ss store.Store) { Type: model.ChannelTypeOpen, }, -1) require.NoError(t, err) + post2, err := ss.Post().Save(&model.Post{ ChannelId: channel2.Id, UserId: userID, Message: model.NewRandomString(10), + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + }, }) require.NoError(t, err) threadStoreCreateReply(t, ss, channel2.Id, post2.Id, post2.UserId, model.GetMillis()) createThreadMembership(userID, post2.Id) - teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id, team2.Id}) + teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id, team2.Id}, true) require.NoError(t, err) assert.Len(t, teamsUnread, 2) assert.Equal(t, int64(2), teamsUnread[team1.Id].ThreadCount) @@ -715,11 +763,12 @@ func testGetTeamsUnreadForUser(t *testing.T, ss store.Store) { _, err = ss.Thread().MaintainMembership(userID, post2.Id, opts) require.NoError(t, err) - teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team2.Id}) + teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team2.Id}, true) require.NoError(t, err) assert.Len(t, teamsUnread, 1) assert.Equal(t, int64(1), teamsUnread[team2.Id].ThreadCount) assert.Equal(t, int64(1), teamsUnread[team2.Id].ThreadMentionCount) + assert.Equal(t, int64(1), teamsUnread[team2.Id].ThreadUrgentMentionCount) } type byPostId []*model.Post @@ -831,6 +880,13 @@ func testVarious(t *testing.T, ss store.Store) { ChannelId: team1channel1.Id, UserId: user1ID, Message: model.NewRandomString(10), + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + }, }) require.NoError(t, err) @@ -1032,6 +1088,33 @@ func testVarious(t *testing.T, ss store.Store) { } }) + t.Run("GetTotalUnreadUrgentMentions", func(t *testing.T) { + testCases := []struct { + Description string + UserID string + TeamID string + Options model.GetUserThreadsOpts + ExpectedThreads []*model.Post + }{ + {"all teams, user1", user1ID, "", model.GetUserThreadsOpts{}, []*model.Post{ + team1channel1post3, + }}, + {"team1, user1", user1ID, team1.Id, model.GetUserThreadsOpts{}, []*model.Post{ + team1channel1post3, + }}, + {"team2, user1", user1ID, team2.Id, model.GetUserThreadsOpts{}, []*model.Post{}}, + } + + for _, testCase := range testCases { + t.Run(testCase.Description, func(t *testing.T) { + totalUnreadUrgentMentions, err := ss.Thread().GetTotalUnreadUrgentMentions(testCase.UserID, testCase.TeamID, testCase.Options) + require.NoError(t, err) + + assert.EqualValues(t, int64(len(testCase.ExpectedThreads)), totalUnreadUrgentMentions) + }) + } + }) + assertThreadPosts := func(t *testing.T, threads []*model.ThreadResponse, expectedPosts []*model.Post) { t.Helper() @@ -1166,7 +1249,7 @@ func testMarkAllAsReadByChannels(t *testing.T, ss store.Store) { assertThreadReplyCount := func(t *testing.T, userID string, count int64) { t.Helper() - teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}) + teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}, false) require.NoError(t, err) require.Len(t, teamsUnread, 1, "unexpected unread teams count") assert.Equal(t, count, teamsUnread[team1.Id].ThreadCount, "unexpected thread count") @@ -1623,7 +1706,7 @@ func testMarkAllAsReadByTeam(t *testing.T, ss store.Store) { assertThreadReplyCount := func(t *testing.T, userID, teamID string, count int64, message string) { t.Helper() - teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{teamID}) + teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{teamID}, true) require.NoError(t, err) require.Lenf(t, teamsUnread, 1, "unexpected unread teams count: %s", message) assert.Equalf(t, count, teamsUnread[teamID].ThreadCount, "unexpected thread count: %s", message) diff --git a/store/storetest/user_store.go b/store/storetest/user_store.go index c938056249..138c3cfb14 100644 --- a/store/storetest/user_store.go +++ b/store/storetest/user_store.go @@ -2468,7 +2468,7 @@ func testUserUnreadCount(t *testing.T, ss store.Store) { // Post one message with mention to open channel _, nErr = ss.Post().Save(&p1) require.NoError(t, nErr) - nErr = ss.Channel().IncrementMentionCount(c1.Id, []string{u2.Id, u3.Id}, false) + nErr = ss.Channel().IncrementMentionCount(c1.Id, []string{u2.Id, u3.Id}, false, false) require.NoError(t, nErr) // Post 2 messages without mention to direct channel @@ -2479,7 +2479,7 @@ func testUserUnreadCount(t *testing.T, ss store.Store) { _, nErr = ss.Post().Save(&p2) require.NoError(t, nErr) - nErr = ss.Channel().IncrementMentionCount(c2.Id, []string{u2.Id}, false) + nErr = ss.Channel().IncrementMentionCount(c2.Id, []string{u2.Id}, false, false) require.NoError(t, nErr) p3 := model.Post{} @@ -2489,7 +2489,7 @@ func testUserUnreadCount(t *testing.T, ss store.Store) { _, nErr = ss.Post().Save(&p3) require.NoError(t, nErr) - nErr = ss.Channel().IncrementMentionCount(c2.Id, []string{u2.Id}, false) + nErr = ss.Channel().IncrementMentionCount(c2.Id, []string{u2.Id}, false, false) require.NoError(t, nErr) badge, unreadCountErr := ss.User().GetUnreadCount(u2.Id, false) @@ -2501,7 +2501,7 @@ func testUserUnreadCount(t *testing.T, ss store.Store) { require.Equal(t, int64(1), badge, "should have 1 unread message") // Increment root mentions by 1 - nErr = ss.Channel().IncrementMentionCount(c1.Id, []string{u3.Id}, true) + nErr = ss.Channel().IncrementMentionCount(c1.Id, []string{u3.Id}, true, false) require.NoError(t, nErr) // CRT is enabled, only root mentions are counted diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 9e8bb2fade..c4c89b935c 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -26,6 +26,7 @@ type TimerLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -36,6 +37,8 @@ type TimerLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore + PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore ReactionStore store.ReactionStore @@ -90,6 +93,10 @@ func (s *TimerLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *TimerLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *TimerLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -130,6 +137,14 @@ func (s *TimerLayer) Post() store.PostStore { return s.PostStore } +func (s *TimerLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + +func (s *TimerLayer) PostPriority() store.PostPriorityStore { + return s.PostPriorityStore +} + func (s *TimerLayer) Preference() store.PreferenceStore { return s.PreferenceStore } @@ -250,6 +265,11 @@ type TimerLayerComplianceStore struct { Root *TimerLayer } +type TimerLayerDraftStore struct { + store.DraftStore + Root *TimerLayer +} + type TimerLayerEmojiStore struct { store.EmojiStore Root *TimerLayer @@ -300,6 +320,16 @@ type TimerLayerPostStore struct { Root *TimerLayer } +type TimerLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *TimerLayer +} + +type TimerLayerPostPriorityStore struct { + store.PostPriorityStore + Root *TimerLayer +} + type TimerLayerPreferenceStore struct { store.PreferenceStore Root *TimerLayer @@ -639,6 +669,21 @@ func (s *TimerLayerChannelStore) ClearCaches() { } } +func (s *TimerLayerChannelStore) ClearMembersForUserCache() { + start := time.Now() + + s.ChannelStore.ClearMembersForUserCache() + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if true { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.ClearMembersForUserCache", success, elapsed) + } +} + func (s *TimerLayerChannelStore) ClearSidebarOnTeamLeave(userID string, teamID string) error { start := time.Now() @@ -671,6 +716,22 @@ func (s *TimerLayerChannelStore) CountPostsAfter(channelID string, timestamp int return result, resultVar1, err } +func (s *TimerLayerChannelStore) CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) { + start := time.Now() + + result, err := s.ChannelStore.CountUrgentPostsAfter(channelID, timestamp, userID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.CountUrgentPostsAfter", success, elapsed) + } + return result, err +} + func (s *TimerLayerChannelStore) CreateDirectChannel(userID *model.User, otherUserID *model.User, channelOptions ...model.ChannelOption) (*model.Channel, error) { start := time.Now() @@ -1711,10 +1772,10 @@ func (s *TimerLayerChannelStore) GroupSyncedChannelCount() (int64, error) { return result, err } -func (s *TimerLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error { +func (s *TimerLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool, isUrgent bool) error { start := time.Now() - err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot) + err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot, isUrgent) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -2248,10 +2309,10 @@ func (s *TimerLayerChannelStore) UpdateLastViewedAt(channelIds []string, userID return result, err } -func (s *TimerLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { +func (s *TimerLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { start := time.Now() - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -2919,6 +2980,86 @@ func (s *TimerLayerComplianceStore) Update(compliance *model.Compliance) (*model return result, err } +func (s *TimerLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + start := time.Now() + + err := s.DraftStore.Delete(userID, channelID, rootID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerDraftStore) Get(userID string, channelID string, rootID string, includeDeleted bool) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Get(userID, channelID, rootID, includeDeleted) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.GetDraftsForUser", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Save(d) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Save", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Update(d) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Update", success, elapsed) + } + return result, err +} + func (s *TimerLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { start := time.Now() @@ -5891,6 +6032,118 @@ func (s *TimerLayerPostStore) Update(newPost *model.Post, oldPost *model.Post) ( return result, err } +func (s *TimerLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + start := time.Now() + + err := s.PostAcknowledgementStore.Delete(acknowledgement) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.Get(postID, userID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.GetForPost(postID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.GetForPost", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.GetForPosts", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Save", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + start := time.Now() + + result, err := s.PostPriorityStore.GetForPost(postId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPriorityStore.GetForPost", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostPriorityStore) GetForPosts(ids []string) ([]*model.PostPriority, error) { + start := time.Now() + + result, err := s.PostPriorityStore.GetForPosts(ids) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPriorityStore.GetForPosts", success, elapsed) + } + return result, err +} + func (s *TimerLayerPreferenceStore) CleanupFlagsBatch(limit int64) (int64, error) { start := time.Now() @@ -8881,10 +9134,10 @@ func (s *TimerLayerThreadStore) GetMembershipsForUser(userId string, teamID stri return result, err } -func (s *TimerLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { +func (s *TimerLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { start := time.Now() - result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs) + result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs, includeUrgentMentionCount) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -8913,10 +9166,10 @@ func (s *TimerLayerThreadStore) GetThreadFollowers(threadID string, fetchOnlyAct return result, err } -func (s *TimerLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { +func (s *TimerLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool, postPriorityIsEnabled bool) (*model.ThreadResponse, error) { start := time.Now() - result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended) + result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended, postPriorityIsEnabled) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -9041,6 +9294,22 @@ func (s *TimerLayerThreadStore) GetTotalUnreadThreads(userId string, teamID stri return result, err } +func (s *TimerLayerThreadStore) GetTotalUnreadUrgentMentions(userId string, teamID string, opts model.GetUserThreadsOpts) (int64, error) { + start := time.Now() + + result, err := s.ThreadStore.GetTotalUnreadUrgentMentions(userId, teamID, opts) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.GetTotalUnreadUrgentMentions", success, elapsed) + } + return result, err +} + func (s *TimerLayerThreadStore) MaintainMembership(userID string, postID string, opts store.ThreadMembershipOpts) (*model.ThreadMembership, error) { start := time.Now() @@ -11260,6 +11529,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.CommandStore = &TimerLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &TimerLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &TimerLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &TimerLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &TimerLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &TimerLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &TimerLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} @@ -11270,6 +11540,8 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.OAuthStore = &TimerLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &TimerLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &TimerLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &TimerLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} + newStore.PostPriorityStore = &TimerLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &TimerLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &TimerLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} newStore.ReactionStore = &TimerLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} diff --git a/templates/cloud_30_day_arrears.html b/templates/cloud_30_day_arrears.html index 19290dd87f..40cc01ef49 100644 --- a/templates/cloud_30_day_arrears.html +++ b/templates/cloud_30_day_arrears.html @@ -439,7 +439,7 @@ - + -
diff --git a/templates/cloud_30_day_arrears.mjml b/templates/cloud_30_day_arrears.mjml index eaff779310..1b3fb3bb3c 100644 --- a/templates/cloud_30_day_arrears.mjml +++ b/templates/cloud_30_day_arrears.mjml @@ -32,7 +32,7 @@ {{.Props.LimitsDocs}} - + {{.Props.Button}} diff --git a/templates/cloud_45_day_arrears.html b/templates/cloud_45_day_arrears.html index 9c4af6731c..f868f37ab4 100644 --- a/templates/cloud_45_day_arrears.html +++ b/templates/cloud_45_day_arrears.html @@ -433,7 +433,7 @@
+ -
diff --git a/templates/cloud_90_day_arrears.html b/templates/cloud_90_day_arrears.html index f63b2b2a07..21769839ca 100644 --- a/templates/cloud_90_day_arrears.html +++ b/templates/cloud_90_day_arrears.html @@ -433,7 +433,7 @@
+
diff --git a/templates/partials/cloud_title_3subtitles_button.mjml b/templates/partials/cloud_title_3subtitles_button.mjml index 23681ab9e3..38c39071af 100644 --- a/templates/partials/cloud_title_3subtitles_button.mjml +++ b/templates/partials/cloud_title_3subtitles_button.mjml @@ -16,7 +16,7 @@ {{.Props.Button}} {{if .IncludeSecondaryActionButton}} - + {{.Props.SecondaryActionButtonText}} {{end}} diff --git a/utils/api.go b/utils/api.go index 4680c66fb9..11d3767de1 100644 --- a/utils/api.go +++ b/utils/api.go @@ -101,7 +101,7 @@ func RenderMobileAuthComplete(w http.ResponseWriter, redirectURL string) { } func RenderMobileError(config *model.Config, w http.ResponseWriter, err *model.AppError, redirectURL string) { - var link = redirectURL + var link = template.HTMLEscapeString(redirectURL) var invalidSchemes = map[string]bool{ "data": true, "javascript": true, diff --git a/utils/merge.go b/utils/merge.go index 073a3380b2..cdeaa6e908 100644 --- a/utils/merge.go +++ b/utils/merge.go @@ -26,15 +26,16 @@ type MergeConfig struct { // - maps and slices are treated as pointers, and merged as a single value // // Note that callers need to cast the returned interface back into the original type: -// func mergeTestStruct(base, patch *testStruct) (*testStruct, error) { -// ret, err := merge(base, patch) -// if err != nil { -// return nil, err -// } // -// retTS := ret.(testStruct) -// return &retTS, nil -// } +// func mergeTestStruct(base, patch *testStruct) (*testStruct, error) { +// ret, err := merge(base, patch) +// if err != nil { +// return nil, err +// } +// +// retTS := ret.(testStruct) +// return &retTS, nil +// } func Merge(base any, patch any, mergeConfig *MergeConfig) (any, error) { if reflect.TypeOf(base) != reflect.TypeOf(patch) { return nil, fmt.Errorf( diff --git a/web/context.go b/web/context.go index 056dca354c..2286f806c6 100644 --- a/web/context.go +++ b/web/context.go @@ -743,7 +743,7 @@ func (c *Context) RequireInvoiceId() *Context { return c } - if len(c.Params.InvoiceId) != 27 { + if len(c.Params.InvoiceId) != 27 && c.Params.InvoiceId != model.UpcomingInvoice { c.SetInvalidURLParam("invoice_id") } diff --git a/web/handlers.go b/web/handlers.go index bc836b72ba..798831bc73 100644 --- a/web/handlers.go +++ b/web/handlers.go @@ -236,7 +236,7 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } cloudCSP := "" - if c.App.Channels().License().IsCloud() { + if c.App.Channels().License().IsCloud() || *c.App.Config().ServiceSettings.SelfHostedFirstTimePurchase { cloudCSP = " js.stripe.com/v3" } diff --git a/web/params.go b/web/params.go index 20c944355b..64e8227119 100644 --- a/web/params.go +++ b/web/params.go @@ -90,6 +90,7 @@ type Params struct { ExcludePolicyConstrained bool GroupSource model.GroupSource FilterHasMember string + IncludeChannelMemberCount string // Cloud InvoiceId string @@ -208,6 +209,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.NotAssociatedToChannel = query.Get("not_associated_to_channel") params.FilterAllowReference, _ = strconv.ParseBool(query.Get("filter_allow_reference")) params.FilterParentTeamPermitted, _ = strconv.ParseBool(query.Get("filter_parent_team_permitted")) + params.IncludeChannelMemberCount = query.Get("include_channel_member_count") if val, err := strconv.ParseBool(query.Get("paginate")); err == nil { params.Paginate = &val diff --git a/web/web_test.go b/web/web_test.go index 139753675d..ae8e322f91 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -280,7 +280,7 @@ func TestPublicFilesRequest(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server), pluginDir, webappPluginDir, false, th.App.Log(), nil) require.NoError(t, err) pluginID := "com.mattermost.sample"