diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..3c24100d19 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,14 @@ +name: "CodeQL config" + +query-filters: + - exclude: + problem.severity: + - warning + - recommendation + - exclude: + id: go/log-injection + +paths-ignore: + - templates + - tests + - 'api4/*_local.go' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 20c839211d..791a45ee95 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -6,7 +6,7 @@ on: branches: [ master ] schedule: - cron: '30 5,17 * * *' - + permissions: contents: read @@ -20,17 +20,24 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'go', 'javascript' ] + language: [ 'go' ] steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} - debug: true + debug: false + config-file: ./.github/codeql/codeql-config.yml + + # Autobuild attempts to build any compiled languages - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 + + # Perform Analysis - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/Makefile b/Makefile index 80b0d047cd..85b7c222ef 100644 --- a/Makefile +++ b/Makefile @@ -154,13 +154,13 @@ 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.4 +PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.32.6 PLUGIN_PACKAGES += mattermost-plugin-jenkins-v1.1.0 PLUGIN_PACKAGES += mattermost-plugin-jira-v2.4.0 PLUGIN_PACKAGES += mattermost-plugin-nps-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-welcomebot-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.6.0 -PLUGIN_PACKAGES += focalboard-v7.4.1 +PLUGIN_PACKAGES += focalboard-v7.4.3 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 diff --git a/api4/channel_test.go b/api4/channel_test.go index 16e432bf0d..1e1ea4b2e1 100644 --- a/api4/channel_test.go +++ b/api4/channel_test.go @@ -1779,6 +1779,7 @@ func TestSearchGroupChannels(t *testing.T) { } func TestDeleteChannel(t *testing.T) { + t.Skip("MM-47465") th := Setup(t).InitBasic() defer th.TearDown() c := th.Client diff --git a/api4/cloud.go b/api4/cloud.go index e86a9b0aca..a88ac919b3 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -158,14 +158,14 @@ func requestCloudTrial(c *Context, w http.ResponseWriter, r *http.Request) { // check if the email needs to be set bodyBytes, err := io.ReadAll(r.Body) if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } // this value will not be empty when both emails (user admin and CWS customer) are not business email and - // we need to request a new email from the user via the request business email modal + // a new business email was provided via the request business email modal var startTrialRequest *model.StartCloudTrialRequest if err = json.Unmarshal(bodyBytes, &startTrialRequest); err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } @@ -199,20 +199,20 @@ func validateBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) { user, appErr := c.App.GetUser(c.AppContext.Session().UserId) if appErr != nil { - c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr) + c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(appErr) return } bodyBytes, err := io.ReadAll(r.Body) if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } var emailToValidate *model.ValidateBusinessEmailRequest err = json.Unmarshal(bodyBytes, &emailToValidate) if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } @@ -244,14 +244,14 @@ func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.R user, userErr := c.App.GetUser(c.AppContext.Session().UserId) if userErr != nil { - c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, userErr.Error(), http.StatusInternalServerError) + c.Err = userErr return } // get the cloud customer email to validate if is a valid business email cloudCustomer, err := c.App.Cloud().GetCloudCustomer(user.Id) if err != nil { - c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, err.Error(), http.StatusInternalServerError) + c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, err.Error(), http.StatusBadRequest) return } emailErr := c.App.Cloud().ValidateBusinessEmail(user.Id, cloudCustomer.Email) diff --git a/api4/cloud_test.go b/api4/cloud_test.go index 999f1077e4..2092342c89 100644 --- a/api4/cloud_test.go +++ b/api4/cloud_test.go @@ -296,6 +296,20 @@ func Test_requestTrial(t *testing.T) { require.Equal(t, subscriptionChanged, subscription) require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") }) + + t.Run("Empty body returns bad request", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + r, err := th.SystemAdminClient.DoAPIPutBytes("/cloud/request-trial", nil) + require.Error(t, err) + closeBody(r) + require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request") + }) } func Test_validateBusinessEmail(t *testing.T) { @@ -373,6 +387,20 @@ func Test_validateBusinessEmail(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode, "200") }) + + t.Run("Empty body returns bad request", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + r, err := th.SystemAdminClient.DoAPIPostBytes("/cloud/validate-business-email", nil) + require.Error(t, err) + closeBody(r) + require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request") + }) } func Test_validateWorkspaceBusinessEmail(t *testing.T) { @@ -442,6 +470,39 @@ func Test_validateWorkspaceBusinessEmail(t *testing.T) { _, err := th.SystemAdminClient.ValidateWorkspaceBusinessEmail() require.NoError(t, err) }) + + t.Run("Error while grabbing the cloud customer returns bad request", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := mocks.CloudInterface{} + + cloudCustomerInfo := model.CloudCustomerInfo{ + Email: "badrequest@gmail.com", + } + + // return an error while getting the cloud customer so we validate the forbidden error return + cloud.Mock.On("GetCloudCustomer", th.SystemAdminUser.Id).Return(nil, errors.New("error while gettings the cloud customer")) + + // required cloud mocks so the request doesn't fail + cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, cloudCustomerInfo.Email).Return(errors.New("invalid email")) + cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, th.SystemAdminUser.Email).Return(nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + r, err := th.SystemAdminClient.DoAPIPostBytes("/cloud/validate-workspace-business-email", nil) + require.Error(t, err) + closeBody(r) + require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request") + }) } func TestGetCloudProducts(t *testing.T) { diff --git a/api4/file_test.go b/api4/file_test.go index 0184f972b7..85bd04ee21 100644 --- a/api4/file_test.go +++ b/api4/file_test.go @@ -397,8 +397,8 @@ func TestUploadFiles(t *testing.T) { { title: "Happy image thumbnail/preview 10", names: []string{"10000x1.png"}, - expectedImageThumbnailNames: []string{"10000x1_expected_thumb.jpeg"}, - expectedImagePreviewNames: []string{"10000x1_expected_preview.jpeg"}, + expectedImageThumbnailNames: []string{"10000x1_expected_thumb.png"}, + expectedImagePreviewNames: []string{"10000x1_expected_preview.png"}, expectImage: true, expectedImageWidths: []int{10000}, expectedImageHeights: []int{1}, @@ -409,8 +409,8 @@ func TestUploadFiles(t *testing.T) { { title: "Happy image thumbnail/preview 11", names: []string{"1x10000.png"}, - expectedImageThumbnailNames: []string{"1x10000_expected_thumb.jpeg"}, - expectedImagePreviewNames: []string{"1x10000_expected_preview.jpeg"}, + expectedImageThumbnailNames: []string{"1x10000_expected_thumb.png"}, + expectedImagePreviewNames: []string{"1x10000_expected_preview.png"}, expectImage: true, expectedImageWidths: []int{1}, expectedImageHeights: []int{10000}, @@ -678,8 +678,12 @@ func TestUploadFiles(t *testing.T) { fmt.Sprintf("File %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.Path, expectedPath)) if tc.expectImage { - expectedThumbnailPath := fmt.Sprintf("%s/%s_thumb.jpg", expectedDir, name) - expectedPreviewPath := fmt.Sprintf("%s/%s_preview.jpg", expectedDir, name) + // We convert all other image types to jpeg, except pngs. + if ext != ".png" { + ext = ".jpg" + } + expectedThumbnailPath := fmt.Sprintf("%s/%s_thumb%s", expectedDir, name, ext) + expectedPreviewPath := fmt.Sprintf("%s/%s_preview%s", expectedDir, name, ext) assert.Equal(t, dbInfo.ThumbnailPath, expectedThumbnailPath, fmt.Sprintf("Thumbnail for %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.ThumbnailPath, expectedThumbnailPath)) assert.Equal(t, dbInfo.PreviewPath, expectedPreviewPath, diff --git a/api4/group.go b/api4/group.go index f8b65aa5bb..abcde27459 100644 --- a/api4/group.go +++ b/api4/group.go @@ -18,82 +18,88 @@ import ( func (api *API) InitGroup() { // GET /api/v4/groups - api.BaseRoutes.Groups.Handle("", api.APISessionRequired(requireLicense(getGroups))).Methods("GET") + api.BaseRoutes.Groups.Handle("", api.APISessionRequired(getGroups)).Methods("GET") // POST /api/v4/groups - api.BaseRoutes.Groups.Handle("", api.APISessionRequired(requireLicense(createGroup))).Methods("POST") + api.BaseRoutes.Groups.Handle("", api.APISessionRequired(createGroup)).Methods("POST") // GET /api/v4/groups/:group_id api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}", - api.APISessionRequired(requireLicense(getGroup))).Methods("GET") + api.APISessionRequired(getGroup)).Methods("GET") // PUT /api/v4/groups/:group_id/patch api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/patch", - api.APISessionRequired(requireLicense(patchGroup))).Methods("PUT") + api.APISessionRequired(patchGroup)).Methods("PUT") // POST /api/v4/groups/:group_id/teams/:team_id/link // POST /api/v4/groups/:group_id/channels/:channel_id/link api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/link", - api.APISessionRequired(requireLicense(linkGroupSyncable))).Methods("POST") + api.APISessionRequired(linkGroupSyncable)).Methods("POST") // DELETE /api/v4/groups/:group_id/teams/:team_id/link // DELETE /api/v4/groups/:group_id/channels/:channel_id/link api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/link", - api.APISessionRequired(requireLicense(unlinkGroupSyncable))).Methods("DELETE") + api.APISessionRequired(unlinkGroupSyncable)).Methods("DELETE") // GET /api/v4/groups/:group_id/teams/:team_id // GET /api/v4/groups/:group_id/channels/:channel_id api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}", - api.APISessionRequired(requireLicense(getGroupSyncable))).Methods("GET") + api.APISessionRequired(getGroupSyncable)).Methods("GET") // GET /api/v4/groups/:group_id/teams // GET /api/v4/groups/:group_id/channels api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}", - api.APISessionRequired(requireLicense(getGroupSyncables))).Methods("GET") + api.APISessionRequired(getGroupSyncables)).Methods("GET") // PUT /api/v4/groups/:group_id/teams/:team_id/patch // PUT /api/v4/groups/:group_id/channels/:channel_id/patch api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/patch", - api.APISessionRequired(requireLicense(patchGroupSyncable))).Methods("PUT") + api.APISessionRequired(patchGroupSyncable)).Methods("PUT") // GET /api/v4/groups/:group_id/stats api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/stats", - api.APISessionRequired(requireLicense(getGroupStats))).Methods("GET") + api.APISessionRequired(getGroupStats)).Methods("GET") // GET /api/v4/groups/:group_id/members api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", - api.APISessionRequired(requireLicense(getGroupMembers))).Methods("GET") + api.APISessionRequired(getGroupMembers)).Methods("GET") // GET /api/v4/users/:user_id/groups api.BaseRoutes.Users.Handle("/{user_id:[A-Za-z0-9]+}/groups", - api.APISessionRequired(requireLicense(getGroupsByUserId))).Methods("GET") + api.APISessionRequired(getGroupsByUserId)).Methods("GET") // GET /api/v4/channels/:channel_id/groups api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/groups", - api.APISessionRequired(requireLicense(getGroupsByChannel))).Methods("GET") + api.APISessionRequired(getGroupsByChannel)).Methods("GET") // GET /api/v4/teams/:team_id/groups api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups", - api.APISessionRequired(requireLicense(getGroupsByTeam))).Methods("GET") + api.APISessionRequired(getGroupsByTeam)).Methods("GET") // GET /api/v4/teams/:team_id/groups_by_channels api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups_by_channels", - api.APISessionRequired(requireLicense(getGroupsAssociatedToChannelsByTeam))).Methods("GET") + api.APISessionRequired(getGroupsAssociatedToChannelsByTeam)).Methods("GET") // DELETE /api/v4/groups/:group_id api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}", - api.APISessionRequired(requireLicense(deleteGroup))).Methods("DELETE") + api.APISessionRequired(deleteGroup)).Methods("DELETE") // POST /api/v4/groups/:group_id/members api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", - api.APISessionRequired(requireLicense(addGroupMembers))).Methods("POST") + api.APISessionRequired(addGroupMembers)).Methods("POST") // DELETE /api/v4/groups/:group_id/members api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", - api.APISessionRequired(requireLicense(deleteGroupMembers))).Methods("DELETE") + api.APISessionRequired(deleteGroupMembers)).Methods("DELETE") } func getGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireGroupId() if c.Err != nil { return @@ -130,6 +136,11 @@ func getGroup(c *Context, w http.ResponseWriter, r *http.Request) { } func createGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } var group *model.GroupWithUserIds if err := json.NewDecoder(r.Body).Decode(&group); err != nil { c.SetInvalidParamWithErr("group", err) @@ -185,6 +196,11 @@ func createGroup(c *Context, w http.ResponseWriter, r *http.Request) { } func patchGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -277,6 +293,11 @@ func patchGroup(c *Context, w http.ResponseWriter, r *http.Request) { } func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -368,6 +389,11 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -411,6 +437,11 @@ func getGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupSyncables(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -448,6 +479,11 @@ func getGroupSyncables(c *Context, w http.ResponseWriter, r *http.Request) { } func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -529,6 +565,11 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { } func unlinkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -606,6 +647,11 @@ func verifyLinkUnlinkPermission(c *Context, syncableType model.GroupSyncableType } func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -651,6 +697,11 @@ func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupStats(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -686,6 +737,11 @@ func getGroupStats(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupsByUserId(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireUserId() if c.Err != nil { return @@ -717,72 +773,46 @@ func getGroupsByUserId(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupsByChannel(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireChannelId() if c.Err != nil { return } - - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { - c.Err = model.NewAppError("Api4.getGroupsByChannel", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) - return - } - - channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId) + b, appErr := getGroupsByChannelCommon(c, r) if appErr != nil { c.Err = appErr return } - - var permission *model.Permission - if channel.Type == model.ChannelTypePrivate { - permission = model.PermissionReadPrivateChannelGroups - } else { - permission = model.PermissionReadPublicChannelGroups - } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, permission) { - c.SetPermissionError(permission) - return - } - - opts := model.GroupSearchOpts{ - Q: c.Params.Q, - IncludeMemberCount: c.Params.IncludeMemberCount, - FilterAllowReference: c.Params.FilterAllowReference, - } - if c.Params.Paginate == nil || *c.Params.Paginate { - opts.PageOpts = &model.PageOpts{Page: c.Params.Page, PerPage: c.Params.PerPage} - } - - groups, totalCount, appErr := c.App.GetGroupsByChannel(c.Params.ChannelId, opts) - if appErr != nil { - c.Err = appErr - return - } - - b, err := json.Marshal(struct { - Groups []*model.GroupWithSchemeAdmin `json:"groups"` - Count int `json:"total_group_count"` - }{ - Groups: groups, - Count: totalCount, - }) - if err != nil { - c.Err = model.NewAppError("Api4.getGroupsByChannel", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - w.Write(b) } func getGroupsByTeam(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireTeamId() if c.Err != nil { return } - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { - c.Err = model.NewAppError("Api4.getGroupsByTeam", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) + + b, appError := getGroupsByTeamCommon(c, r) + if appError != nil { + c.Err = appError return } + w.Write(b) +} + +func getGroupsByTeamCommon(c *Context, r *http.Request) ([]byte, *model.AppError) { + if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { + return nil, model.NewAppError("Api4.getGroupsByTeam", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) + } opts := model.GroupSearchOpts{ Q: c.Params.Q, @@ -795,8 +825,7 @@ func getGroupsByTeam(c *Context, w http.ResponseWriter, r *http.Request) { groups, totalCount, appErr := c.App.GetGroupsByTeam(c.Params.TeamId, opts) if appErr != nil { - c.Err = appErr - return + return nil, appErr } b, err := json.Marshal(struct { @@ -808,14 +837,64 @@ func getGroupsByTeam(c *Context, w http.ResponseWriter, r *http.Request) { }) if err != nil { - c.Err = model.NewAppError("Api4.getGroupsByTeam", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return + return nil, model.NewAppError("Api4.getGroupsByTeam", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } - w.Write(b) + return b, nil +} +func getGroupsByChannelCommon(c *Context, r *http.Request) ([]byte, *model.AppError) { + if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { + return nil, model.NewAppError("Api4.getGroupsByChannel", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) + } + + channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId) + if appErr != nil { + return nil, appErr + } + + var permission *model.Permission + if channel.Type == model.ChannelTypePrivate { + permission = model.PermissionReadPrivateChannelGroups + } else { + permission = model.PermissionReadPublicChannelGroups + } + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, permission) { + return nil, c.App.MakePermissionError(c.AppContext.Session(), []*model.Permission{permission}) + } + + opts := model.GroupSearchOpts{ + Q: c.Params.Q, + IncludeMemberCount: c.Params.IncludeMemberCount, + FilterAllowReference: c.Params.FilterAllowReference, + } + if c.Params.Paginate == nil || *c.Params.Paginate { + opts.PageOpts = &model.PageOpts{Page: c.Params.Page, PerPage: c.Params.PerPage} + } + + groups, totalCount, appErr := c.App.GetGroupsByChannel(c.Params.ChannelId, opts) + if appErr != nil { + return nil, appErr + } + + b, err := json.Marshal(struct { + Groups []*model.GroupWithSchemeAdmin `json:"groups"` + Count int `json:"total_group_count"` + }{ + Groups: groups, + Count: totalCount, + }) + if err != nil { + return nil, model.NewAppError("Api4.getGroupsByChannel", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return b, nil } func getGroupsAssociatedToChannelsByTeam(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireTeamId() if c.Err != nil { return @@ -855,6 +934,11 @@ func getGroupsAssociatedToChannelsByTeam(c *Context, w http.ResponseWriter, r *h } func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } var teamID, channelID string source := c.Params.GroupSource @@ -961,6 +1045,11 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } func deleteGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -1004,6 +1093,11 @@ func deleteGroup(c *Context, w http.ResponseWriter, r *http.Request) { } func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -1058,6 +1152,11 @@ func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { } func deleteGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return diff --git a/api4/group_local.go b/api4/group_local.go index 5ede4d7bf3..1964fdbed4 100644 --- a/api4/group_local.go +++ b/api4/group_local.go @@ -3,7 +3,39 @@ package api4 +import ( + "net/http" +) + func (api *API) InitGroupLocal() { - api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByChannel)).Methods("GET") - api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByTeam)).Methods("GET") + api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByChannelLocal)).Methods("GET") + api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByTeamLocal)).Methods("GET") +} + +func getGroupsByChannelLocal(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireChannelId() + if c.Err != nil { + return + } + b, appErr := getGroupsByChannelCommon(c, r) + if appErr != nil { + c.Err = appErr + return + } + + w.Write(b) +} + +func getGroupsByTeamLocal(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId() + if c.Err != nil { + return + } + b, appError := getGroupsByTeamCommon(c, r) + if appError != nil { + c.Err = appError + return + } + + w.Write(b) } diff --git a/api4/handlers.go b/api4/handlers.go index 9ec6be4aae..250dac32e7 100644 --- a/api4/handlers.go +++ b/api4/handlers.go @@ -200,33 +200,27 @@ func (api *API) APILocal(h handlerFunc) http.Handler { return handler } -func requireLicense(f handlerFunc) handlerFunc { - return func(c *Context, w http.ResponseWriter, r *http.Request) { - if c.App.Channels().License() == nil { - c.Err = model.NewAppError("", "api.license_error", nil, "", http.StatusNotImplemented) - return - } - f(c, w, r) +func requireLicense(c *Context) *model.AppError { + if c.App.Channels().License() == nil { + err := model.NewAppError("", "api.license_error", nil, "", http.StatusNotImplemented) + return err } + return nil } -func minimumProfessionalLicense(f handlerFunc) handlerFunc { - return func(c *Context, w http.ResponseWriter, r *http.Request) { - lic := c.App.Srv().License() - if lic == nil || (lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise) { - c.Err = model.NewAppError("", model.NoTranslation, nil, "license is neither professional nor enterprise", http.StatusNotImplemented) - return - } - f(c, w, r) +func minimumProfessionalLicense(c *Context) *model.AppError { + lic := c.App.Srv().License() + if lic == nil || (lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise) { + err := model.NewAppError("", model.NoTranslation, nil, "license is neither professional nor enterprise", http.StatusNotImplemented) + return err } + return nil } -func rejectGuests(f handlerFunc) handlerFunc { - return func(c *Context, w http.ResponseWriter, r *http.Request) { - if c.AppContext.Session().Props[model.SessionPropIsGuest] == "true" { - c.Err = model.NewAppError("", model.NoTranslation, nil, "insufficient permissions as a guest user", http.StatusNotImplemented) - return - } - f(c, w, r) +func rejectGuests(c *Context) *model.AppError { + if c.AppContext.Session().Props[model.SessionPropIsGuest] == "true" { + err := model.NewAppError("", model.NoTranslation, nil, "insufficient permissions as a guest user", http.StatusNotImplemented) + return err } + return nil } diff --git a/api4/insights.go b/api4/insights.go index 3c90294ec7..9d74a57c2f 100644 --- a/api4/insights.go +++ b/api4/insights.go @@ -13,31 +13,44 @@ import ( func (api *API) InitInsights() { // Reactions - api.BaseRoutes.InsightsForTeam.Handle("/reactions", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopReactionsForTeamSince)))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/reactions", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopReactionsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/reactions", api.APISessionRequired(getTopReactionsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/reactions", api.APISessionRequired(getTopReactionsForUserSince)).Methods("GET") // Channels - api.BaseRoutes.InsightsForTeam.Handle("/channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopChannelsForTeamSince)))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopChannelsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/channels", api.APISessionRequired(getTopChannelsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/channels", api.APISessionRequired(getTopChannelsForUserSince)).Methods("GET") // Threads - api.BaseRoutes.InsightsForTeam.Handle("/threads", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopThreadsForTeamSince)))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/threads", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopThreadsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/threads", api.APISessionRequired(getTopThreadsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/threads", api.APISessionRequired(getTopThreadsForUserSince)).Methods("GET") // user DMs - api.BaseRoutes.InsightsForUser.Handle("/dms", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopDMsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/dms", api.APISessionRequired(getTopDMsForUserSince)).Methods("GET") // Inactive channels - api.BaseRoutes.InsightsForTeam.Handle("/inactive_channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopInactiveChannelsForTeamSince)))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/inactive_channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopInactiveChannelsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/inactive_channels", api.APISessionRequired(getTopInactiveChannelsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/inactive_channels", api.APISessionRequired(getTopInactiveChannelsForUserSince)).Methods("GET") // New teammembers - api.BaseRoutes.InsightsForTeam.Handle("/team_members", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getNewTeamMembersSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/team_members", api.APISessionRequired(getNewTeamMembersSince)).Methods("GET") } // Top Reactions func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -60,7 +73,11 @@ func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Requ return } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topReactionList, appErr := c.App.GetTopReactionsForTeamSince(c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -82,6 +99,18 @@ func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Requ } func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.Params.TeamId = r.URL.Query().Get("team_id") // TeamId is an optional parameter @@ -109,7 +138,11 @@ func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Requ return } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topReactionList, appErr := c.App.GetTopReactionsForUserSince(c.AppContext.Session().UserId, c.Params.TeamId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -133,6 +166,18 @@ func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Requ // Top Channels func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -156,7 +201,11 @@ func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reque } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } topChannels, appErr := c.App.GetTopChannelsForTeamSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -184,6 +233,18 @@ func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reque } func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.Params.TeamId = r.URL.Query().Get("team_id") // TeamId is an optional parameter @@ -212,7 +273,11 @@ func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Reque } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } topChannels, appErr := c.App.GetTopChannelsForUserSince(c.AppContext, c.AppContext.Session().UserId, c.Params.TeamId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -241,6 +306,18 @@ func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Reque // Top Threads func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -264,7 +341,11 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques return } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topThreads, appErr := c.App.GetTopThreadsForTeamSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -286,6 +367,18 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques } func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.Params.TeamId = r.URL.Query().Get("team_id") // restrict users with no access to team @@ -313,7 +406,11 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques } } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topThreads, appErr := c.App.GetTopThreadsForUserSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -336,13 +433,29 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques // Top DMs func getTopDMsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + user, err := c.App.GetUser(c.AppContext.Session().UserId) if err != nil { c.Err = err return } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topDMs, err := c.App.GetTopDMsForUserSince(user.Id, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -367,6 +480,18 @@ func getTopDMsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { // Top Channels func getTopInactiveChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -390,7 +515,11 @@ func getTopInactiveChannelsForTeamSince(c *Context, w http.ResponseWriter, r *ht } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } topChannels, err := c.App.GetTopInactiveChannelsForTeamSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -411,6 +540,18 @@ func getTopInactiveChannelsForTeamSince(c *Context, w http.ResponseWriter, r *ht // top inactive channels func getTopInactiveChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.Params.TeamId = r.URL.Query().Get("team_id") // TeamId is an optional parameter @@ -439,7 +580,11 @@ func getTopInactiveChannelsForUserSince(c *Context, w http.ResponseWriter, r *ht } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } topChannels, err := c.App.GetTopInactiveChannelsForUserSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -479,6 +624,18 @@ func postCountByDurationViewModel(c *Context, topChannelList *model.TopChannelLi } func getNewTeamMembersSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -501,7 +658,11 @@ func getNewTeamMembersSince(c *Context, w http.ResponseWriter, r *http.Request) return } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } ntms, count, err := c.App.GetNewTeamMembersSince(c.AppContext, c.Params.TeamId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), diff --git a/api4/insights_test.go b/api4/insights_test.go index 089bd26842..2443c1ea26 100644 --- a/api4/insights_test.go +++ b/api4/insights_test.go @@ -230,6 +230,12 @@ func TestGetTopReactionsForTeamSince(t *testing.T) { CheckNotFoundStatus(t, resp) }) + t.Run("get-top-reactions-for-team-since invalid time range", func(t *testing.T) { + _, resp, err := client.GetTopReactionsForTeamSince(teamId, "7_days", 0, 5) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("get-top-reactions-for-team-since not a member of team", func(t *testing.T) { th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) _, resp, err := client.GetTopReactionsForTeamSince(teamId, model.TimeRangeToday, 0, 5) @@ -417,6 +423,12 @@ func TestGetTopReactionsForUserSince(t *testing.T) { CheckNotFoundStatus(t, resp) }) + t.Run("get-top-reactions-for-user-since invalid time range", func(t *testing.T) { + _, resp, err := client.GetTopReactionsForUserSince(teamId, "7_days", 0, 5) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("get-top-reactions-for-user-since not a member of team", func(t *testing.T) { th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) _, resp, err := client.GetTopReactionsForUserSince(teamId, model.TimeRangeToday, 0, 5) @@ -515,6 +527,12 @@ func TestGetTopChannelsForTeamSince(t *testing.T) { CheckNotFoundStatus(t, resp) }) + t.Run("get-top-channels-for-team-since invalid time range", func(t *testing.T) { + _, resp, err := client.GetTopChannelsForTeamSince(teamId, "7_days", 0, 5) + assert.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("get-top-channels-for-team-since not a member of team", func(t *testing.T) { th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) _, resp, err := client.GetTopChannelsForTeamSince(teamId, model.TimeRangeToday, 0, 5) @@ -592,6 +610,12 @@ func TestGetTopChannelsForUserSince(t *testing.T) { CheckNotFoundStatus(t, resp) }) + t.Run("get-top-channels-for-user-since invalid time range", func(t *testing.T) { + _, resp, err := client.GetTopChannelsForUserSince(teamId, "7_days", 0, 5) + assert.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("get-top-channels-for-user-since not a member of team", func(t *testing.T) { th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) _, resp, err := client.GetTopChannelsForUserSince(teamId, model.TimeRangeToday, 0, 5) diff --git a/api4/license.go b/api4/license.go index e98f859910..4a56ca74ed 100644 --- a/api4/license.go +++ b/api4/license.go @@ -106,7 +106,13 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { // skip the restrictions if license is a sanctioned trial if !license.IsSanctionedTrial() && license.IsTrialLicense() { - canStartTrialLicense, err := c.App.Srv().Platform().LicenseManager().CanStartTrial() + lm := c.App.Srv().Platform().LicenseManager() + if lm == nil { + c.Err = model.NewAppError("addLicense", "api.license.upgrade_needed.app_error", nil, "", http.StatusInternalServerError) + return + } + + canStartTrialLicense, err := lm.CanStartTrial() if err != nil { c.Err = model.NewAppError("addLicense", "api.license.add_license.open.app_error", nil, "", http.StatusInternalServerError) return diff --git a/api4/license_test.go b/api4/license_test.go index 05acd6378c..dbf0e62bfc 100644 --- a/api4/license_test.go +++ b/api4/license_test.go @@ -91,9 +91,6 @@ func TestUploadLicenseFile(t *testing.T) { mockLicenseValidator := mocks2.LicenseValidatorIface{} defer testutils.ResetLicenseValidator() - //startTimestamp, err := time.Parse("2 Jan 2006 3:04 pm", "1 Jan 2021 12:00 am") - //require.Nil(t, err) - userCount := 100 mills := model.GetMillis() @@ -125,6 +122,37 @@ func TestUploadLicenseFile(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) + t.Run("try to get gone through trial, with TE build", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = false }) + th.App.Srv().Platform().SetLicenseManager(nil) + + mockLicenseValidator := mocks2.LicenseValidatorIface{} + defer testutils.ResetLicenseValidator() + + license := model.License{ + Id: model.NewId(), + Features: &model.Features{ + Users: model.NewInt(100), + }, + Customer: &model.Customer{ + Name: "Test", + }, + StartsAt: model.GetMillis() + 100, + ExpiresAt: model.GetMillis() + 100 + (30*(time.Hour*24) + (time.Hour * 8)).Milliseconds(), + } + + mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once() + licenseBytes, err := json.Marshal(license) + require.NoError(t, err) + + mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(true, string(licenseBytes)) + utils.LicenseValidator = &mockLicenseValidator + + resp, err := th.SystemAdminClient.UploadLicenseFile([]byte("")) + CheckErrorID(t, err, "api.license.upgrade_needed.app_error") + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) + }) + t.Run("allow uploading sanctioned trials even if server already gone through trial", func(t *testing.T) { mockLicenseValidator := mocks2.LicenseValidatorIface{} defer testutils.ResetLicenseValidator() diff --git a/api4/resolver_channel.go b/api4/resolver_channel.go index e8f10a8a98..2ad2eca523 100644 --- a/api4/resolver_channel.go +++ b/api4/resolver_channel.go @@ -29,36 +29,6 @@ func (ch *channel) Team(ctx context.Context) (*model.Team, error) { return getGraphQLTeam(ctx, ch.TeamId) } -// match with api4.getChannelStats -func (ch *channel) Stats(ctx context.Context) (*model.ChannelStats, error) { - c, err := getCtx(ctx) - if err != nil { - return nil, err - } - - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), ch.Id, model.PermissionReadChannel) { - c.SetPermissionError(model.PermissionReadChannel) - return nil, c.Err - } - - memberCount, appErr := c.App.GetChannelMemberCount(c.AppContext, ch.Id) - if appErr != nil { - return nil, appErr - } - - guestCount, appErr := c.App.GetChannelGuestCount(c.AppContext, ch.Id) - if appErr != nil { - return nil, appErr - } - - pinnedPostCount, appErr := c.App.GetChannelPinnedPostCount(c.AppContext, ch.Id) - if appErr != nil { - return nil, appErr - } - - return &model.ChannelStats{ChannelId: ch.Id, MemberCount: memberCount, GuestCount: guestCount, PinnedPostCount: pinnedPostCount}, nil -} - func (ch *channel) Cursor() *string { cursor := string(channelCursorPrefix) + "-" + ch.Id encoded := base64.StdEncoding.EncodeToString([]byte(cursor)) diff --git a/api4/resolver_channel_test.go b/api4/resolver_channel_test.go index 36c7989ec1..d5a55dee16 100644 --- a/api4/resolver_channel_test.go +++ b/api4/resolver_channel_test.go @@ -48,12 +48,6 @@ func TestGraphQLChannels(t *testing.T) { ID string `json:"id"` DisplayName string `json:"displayName"` } `json:"team"` - Stats struct { - ChannelId string `json:"channelId"` - MemberCount float64 `json:"memberCount"` - GuestCount float64 `json:"guestCount"` - PinnedPostCount float64 `json:"pinnedpostCount"` - } `json:"stats"` } `json:"channels"` } @@ -388,39 +382,6 @@ func TestGraphQLChannels(t *testing.T) { require.NoError(t, json.Unmarshal(resp.Data, &q)) assert.Len(t, q.Channels, 5) }) - - t.Run("stats", func(t *testing.T) { - query := `query channels($teamId: String, $first: Int) { - channels(userId: "me", teamId: $teamId, first: $first) { - id - stats { - channelId - memberCount - } - } - } - ` - input := graphQLInput{ - OperationName: "channels", - Query: query, - Variables: map[string]any{ - "first": 10, - "teamId": myTeam.Id, - }, - } - - resp, err := th.MakeGraphQLRequest(&input) - require.NoError(t, err) - require.Len(t, resp.Errors, 0) - require.NoError(t, json.Unmarshal(resp.Data, &q)) - require.Len(t, q.Channels, 3) - for _, ch := range q.Channels { - require.Equal(t, ch.ID, ch.Stats.ChannelId) - count, appErr := th.App.GetChannelMemberCount(th.Context, ch.Stats.ChannelId) - require.Nil(t, appErr) - require.Equal(t, float64(count), ch.Stats.MemberCount) - } - }) } func TestGetPrettyDNForUsers(t *testing.T) { diff --git a/api4/schema.graphqls b/api4/schema.graphqls index a976ead790..5af4d4d87c 100644 --- a/api4/schema.graphqls +++ b/api4/schema.graphqls @@ -62,7 +62,6 @@ type Channel { totalMsgCount: Float! totalMsgCountRoot: Float! lastRootPostAt: Float! - stats: ChannelStats extraUpdateAt: Float! props: StringInterface! policyId: String @@ -220,10 +219,3 @@ type Session { props: StringMap! local: Boolean! } - -type ChannelStats { - channelId: String! - memberCount: Float! - guestCount: Float! - pinnedPostCount: Float! -} diff --git a/app/app_iface.go b/app/app_iface.go index ef6b3b4733..aed132ba5f 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -871,6 +871,7 @@ type AppIface interface { InviteNewUsersToTeam(emailList []string, teamID, senderId string) *model.AppError InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) IsCRTEnabledForUser(c request.CTX, userID string) bool + IsFirstAdmin(user *model.User) bool IsFirstUserAccount() bool IsLeader() bool IsPasswordValid(password string) *model.AppError diff --git a/app/channel.go b/app/channel.go index 8da9b638f9..504c7265d6 100644 --- a/app/channel.go +++ b/app/channel.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/mattermost/logr/v2" "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" @@ -130,6 +131,35 @@ func (a *App) JoinDefaultChannels(c request.CTX, teamID string, user *model.User message.Add("user_id", user.Id) message.Add("team_id", channel.TeamId) a.Publish(message) + + // A/B Test on the welcome post + if a.Config().FeatureFlags.SendWelcomePost && channelName == model.DefaultChannelName { + nbTeams, err := a.Srv().Store().Team().AnalyticsTeamCount(&model.TeamSearch{ + IncludeDeleted: model.NewBool(true), + }) + if err != nil { + c.Logger().Warn("unable to get number of teams", logr.Err(err)) + return nil + } + + if nbTeams == 1 && a.IsFirstAdmin(user) { + // Post the welcome message + if _, err := a.CreatePost(c, &model.Post{ + ChannelId: channel.Id, + Type: model.PostTypeWelcomePost, + UserId: user.Id, + }, channel, false, false); err != nil { + c.Logger().Warn("unable to post welcome message", logr.Err(err)) + return nil + } + ts := a.Srv().GetTelemetryService() + if ts != nil { + ts.SendTelemetry("welcome-message-sent", map[string]any{ + "category": "growth", + }) + } + } + } } if nErr != nil { diff --git a/app/channel_test.go b/app/channel_test.go index 260b46d641..0361122027 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -2498,7 +2498,7 @@ func TestGetTopChannelsForTeamSince(t *testing.T) { {ID: channel5.Id, MessageCount: 2}, } - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-channels-for-team-since", func(t *testing.T) { topChannels, err := th.App.GetTopChannelsForTeamSince(th.Context, th.BasicChannel.TeamId, th.BasicUser.Id, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) @@ -2576,7 +2576,7 @@ func TestGetTopChannelsForUserSince(t *testing.T) { {ID: channel5.Id, MessageCount: 2}, } - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-channels-for-user-since", func(t *testing.T) { topChannels, err := th.App.GetTopChannelsForUserSince(th.Context, th.BasicUser.Id, "", &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) @@ -2787,7 +2787,7 @@ func TestGetTopInactiveChannelsForTeamSince(t *testing.T) { {ID: channel2.Id, MessageCount: 6}, } - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-channels-for-team-since", func(t *testing.T) { topChannels, err := th.App.GetTopInactiveChannelsForTeamSince(th.Context, th.BasicChannel.TeamId, th.BasicUser.Id, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) @@ -2885,7 +2885,7 @@ func TestGetTopInactiveChannelsForUserSince(t *testing.T) { {ID: channel2.Id, MessageCount: 6}, } - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-channels-for-user-since", func(t *testing.T) { topChannels, err := th.App.GetTopInactiveChannelsForUserSince(th.Context, th.BasicChannel.TeamId, th.BasicUser.Id, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 4}) diff --git a/app/email/email.go b/app/email/email.go index b9057800fa..d9f3882c61 100644 --- a/app/email/email.go +++ b/app/email/email.go @@ -69,6 +69,10 @@ func (es *Service) SendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, tok map[string]any{"TeamDisplayName": es.config().TeamSettings.SiteName}) data.Props["VerifyUrl"] = link data.Props["VerifyButton"] = T("api.templates.email_change_verify_body.button") + data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") + data.Props["EmailInfo1"] = T("api.templates.email_us_anytime_at") + data.Props["SupportEmail"] = "feedback@mattermost.com" + data.Props["FooterV2"] = T("api.templates.email_footer_v2") body, err := es.templatesContainer.RenderToString("email_change_verify_body", data) if err != nil { diff --git a/app/file.go b/app/file.go index b0e68f4921..5f899aff70 100644 --- a/app/file.go +++ b/app/file.go @@ -266,8 +266,8 @@ func (a *App) getInfoForFilename(post *model.Post, teamID, channelID, userID, ol if info.IsImage() && !info.IsSvg() { nameWithoutExtension := name[:strings.LastIndex(name, ".")] - info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg" - info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg" + info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview." + getFileExtFromMimeType(info.MimeType) + info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(info.MimeType) } return info @@ -724,8 +724,8 @@ func (t *UploadFileTask) preprocessImage() *model.AppError { t.fileinfo.HasPreviewImage = true nameWithoutExtension := t.Name[:strings.LastIndex(t.Name, ".")] - t.fileinfo.PreviewPath = t.pathPrefix() + nameWithoutExtension + "_preview.jpg" - t.fileinfo.ThumbnailPath = t.pathPrefix() + nameWithoutExtension + "_thumb.jpg" + t.fileinfo.PreviewPath = t.pathPrefix() + nameWithoutExtension + "_preview." + getFileExtFromMimeType(t.fileinfo.MimeType) + t.fileinfo.ThumbnailPath = t.pathPrefix() + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(t.fileinfo.MimeType) // check the image orientation with goexif; consume the bytes we // already have first, then keep Tee-ing from input. @@ -770,20 +770,22 @@ func (t *UploadFileTask) postprocessImage(file io.Reader) { defer release() } - // Fill in the background of a potentially-transparent png file as white - if imgType == "png" { - imaging.FillImageTransparency(decoded, image.White) - } - decoded = imaging.MakeImageUpright(decoded, t.imageOrientation) if decoded == nil { return } - writeJPEG := func(img image.Image, path string) { + writeImage := func(img image.Image, path string) { r, w := io.Pipe() go func() { - err := t.imgEncoder.EncodeJPEG(w, img, jpegEncQuality) + var err error + // It's okay to access imgType in a separate goroutine, + // because imgType is only written once and never written again. + if imgType == "png" { + err = t.imgEncoder.EncodePNG(w, img) + } else { + err = t.imgEncoder.EncodeJPEG(w, img, jpegEncQuality) + } if err != nil { mlog.Error("Unable to encode image as jpeg", mlog.String("path", path), mlog.Err(err)) w.CloseWithError(err) @@ -804,12 +806,12 @@ func (t *UploadFileTask) postprocessImage(file io.Reader) { // This is needed on mobile in case of animated GIFs. go func() { defer wg.Done() - writeJPEG(imaging.GenerateThumbnail(decoded, imageThumbnailWidth, imageThumbnailHeight), t.fileinfo.ThumbnailPath) + writeImage(imaging.GenerateThumbnail(decoded, imageThumbnailWidth, imageThumbnailHeight), t.fileinfo.ThumbnailPath) }() go func() { defer wg.Done() - writeJPEG(imaging.GeneratePreview(decoded, imagePreviewWidth), t.fileinfo.PreviewPath) + writeImage(imaging.GeneratePreview(decoded, imagePreviewWidth), t.fileinfo.PreviewPath) }() go func() { @@ -889,8 +891,8 @@ func (a *App) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTe } nameWithoutExtension := filename[:strings.LastIndex(filename, ".")] - info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg" - info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg" + info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview." + getFileExtFromMimeType(info.MimeType) + info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(info.MimeType) } if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { @@ -949,40 +951,33 @@ func (a *App) HandleImages(previewPathList []string, thumbnailPathList []string, wg := new(sync.WaitGroup) for i := range fileData { - img, release, err := prepareImage(a.ch.imgDecoder, bytes.NewReader(fileData[i])) + img, imgType, release, err := prepareImage(a.ch.imgDecoder, bytes.NewReader(fileData[i])) if err != nil { mlog.Debug("Failed to prepare image", mlog.Err(err)) continue } wg.Add(2) - go func(img image.Image, path string) { + go func(img image.Image, imgType, path string) { defer wg.Done() - a.generateThumbnailImage(img, path) - }(img, thumbnailPathList[i]) + a.generateThumbnailImage(img, imgType, path) + }(img, imgType, thumbnailPathList[i]) - go func(img image.Image, path string) { + go func(img image.Image, imgType, path string) { defer wg.Done() - a.generatePreviewImage(img, path) - }(img, previewPathList[i]) + a.generatePreviewImage(img, imgType, path) + }(img, imgType, previewPathList[i]) wg.Wait() release() } } -func prepareImage(imgDecoder *imaging.Decoder, imgData io.ReadSeeker) (img image.Image, release func(), err error) { +func prepareImage(imgDecoder *imaging.Decoder, imgData io.ReadSeeker) (img image.Image, imgType string, release func(), err error) { // Decode image bytes into Image object - var imgType string img, imgType, release, err = imgDecoder.DecodeMemBounded(imgData) if err != nil { - return nil, nil, fmt.Errorf("prepareImage: failed to decode image: %w", err) + return nil, "", nil, fmt.Errorf("prepareImage: failed to decode image: %w", err) } - - // Fill in the background of a potentially-transparent png file as white - if imgType == "png" { - imaging.FillImageTransparency(img, image.White) - } - imgData.Seek(0, io.SeekStart) // Flip the image to be upright @@ -992,14 +987,23 @@ func prepareImage(imgDecoder *imaging.Decoder, imgData io.ReadSeeker) (img image } img = imaging.MakeImageUpright(img, orientation) - return img, release, nil + return img, imgType, release, nil } -func (a *App) generateThumbnailImage(img image.Image, thumbnailPath string) { +func (a *App) generateThumbnailImage(img image.Image, imgType, thumbnailPath string) { var buf bytes.Buffer - if err := a.ch.imgEncoder.EncodeJPEG(&buf, imaging.GenerateThumbnail(img, imageThumbnailWidth, imageThumbnailHeight), jpegEncQuality); err != nil { - mlog.Error("Unable to encode image as jpeg", mlog.String("path", thumbnailPath), mlog.Err(err)) - return + + thumb := imaging.GenerateThumbnail(img, imageThumbnailWidth, imageThumbnailHeight) + if imgType == "png" { + if err := a.ch.imgEncoder.EncodePNG(&buf, thumb); err != nil { + mlog.Error("Unable to encode image as png", mlog.String("path", thumbnailPath), mlog.Err(err)) + return + } + } else { + if err := a.ch.imgEncoder.EncodeJPEG(&buf, thumb, jpegEncQuality); err != nil { + mlog.Error("Unable to encode image as jpeg", mlog.String("path", thumbnailPath), mlog.Err(err)) + return + } } if _, err := a.WriteFile(&buf, thumbnailPath); err != nil { @@ -1008,13 +1012,20 @@ func (a *App) generateThumbnailImage(img image.Image, thumbnailPath string) { } } -func (a *App) generatePreviewImage(img image.Image, previewPath string) { +func (a *App) generatePreviewImage(img image.Image, imgType, previewPath string) { var buf bytes.Buffer - preview := imaging.GeneratePreview(img, imagePreviewWidth) - if err := a.ch.imgEncoder.EncodeJPEG(&buf, preview, jpegEncQuality); err != nil { - mlog.Error("Unable to encode image as preview jpg", mlog.Err(err), mlog.String("path", previewPath)) - return + preview := imaging.GeneratePreview(img, imagePreviewWidth) + if imgType == "png" { + if err := a.ch.imgEncoder.EncodePNG(&buf, preview); err != nil { + mlog.Error("Unable to encode image as preview png", mlog.Err(err), mlog.String("path", previewPath)) + return + } + } else { + if err := a.ch.imgEncoder.EncodeJPEG(&buf, preview, jpegEncQuality); err != nil { + mlog.Error("Unable to encode image as preview jpg", mlog.Err(err), mlog.String("path", previewPath)) + return + } } if _, err := a.WriteFile(&buf, previewPath); err != nil { @@ -1033,7 +1044,7 @@ func (a *App) generateMiniPreview(fi *model.FileInfo) { return } defer file.Close() - img, release, err := prepareImage(a.ch.imgDecoder, file) + img, _, release, err := prepareImage(a.ch.imgDecoder, file) if err != nil { mlog.Debug("generateMiniPreview: prepareImage failed", mlog.Err(err), mlog.String("fileinfo_id", fi.Id), mlog.String("channel_id", fi.ChannelId), @@ -1409,3 +1420,10 @@ func (a *App) getCloudFilesSizeLimit() (int64, *model.AppError) { return int64(math.Ceil(float64(*limits.Files.TotalStorage) / 8)), nil } + +func getFileExtFromMimeType(mimeType string) string { + if mimeType == "image/png" { + return "png" + } + return "jpg" +} diff --git a/app/file_test.go b/app/file_test.go index 599c4c6da1..09c03e640e 100644 --- a/app/file_test.go +++ b/app/file_test.go @@ -338,7 +338,7 @@ func TestGenerateThumbnailImage(t *testing.T) { thumbnailPath := filepath.Join(dataPath, thumbnailName) // when - th.App.generateThumbnailImage(img, thumbnailName) + th.App.generateThumbnailImage(img, "jpg", thumbnailName) defer os.Remove(thumbnailPath) // then diff --git a/app/notification_email.go b/app/notification_email.go index 914b729a01..965dd9a292 100644 --- a/app/notification_email.go +++ b/app/notification_email.go @@ -201,6 +201,18 @@ 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 @@ -211,6 +223,7 @@ type postData struct { Time string ShowChannelIcon bool OtherChannelMembersCount int + MessageAttachments []*EmailMessageAttachment } /** @@ -245,6 +258,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) } data := a.Srv().EmailService.NewEmailTemplateData(recipient.Locale) @@ -306,6 +320,81 @@ 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/notification_email_test.go b/app/notification_email_test.go index 8b48b50035..4d0f77f433 100644 --- a/app/notification_email_test.go +++ b/app/notification_email_test.go @@ -339,6 +339,103 @@ func TestGetNotificationEmailBodyFullNotificationLocaleTime24Hour(t *testing.T) require.Contains(t, body, "14:30", fmt.Sprintf("Expected email text '14:30'. Got %s", body)) } +func TestGetNotificationEmailBodyFullNotificationWithSlackAttachments(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + recipient := &model.User{} + 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) + + channel := &model.Channel{ + DisplayName: "ChannelName", + Type: model.ChannelTypeOpen, + } + + channelName := "ChannelName" + senderName := "sender" + teamName := "testteam" + teamURL := "http://localhost:8065/testteam" + emailNotificationContentsType := model.EmailNotificationContentsFull + translateFunc := i18n.GetUserTranslations("en") + + storeMock := th.App.Srv().Store().(*mocks.Store) + teamStoreMock := mocks.TeamStore{} + teamStoreMock.On("GetByName", "testteam").Return(&model.Team{Name: "testteam"}, nil) + storeMock.On("Team").Return(&teamStoreMock) + + body, err := th.App.getNotificationEmailBody(th.Context, recipient, post, channel, channelName, senderName, teamName, teamURL, emailNotificationContentsType, true, translateFunc, "user-avatar.png") + require.NoError(t, err) + require.Contains(t, body, "#FF0000") + require.Contains(t, body, "message attachment 1 pretext") + require.Contains(t, body, "author name") + require.Contains(t, body, "https://example.com/slack_attachment_1/author_link") + require.Contains(t, body, "https://example.com/slack_attachment_1/author_icon") + require.Contains(t, body, "message attachment 1 title") + require.Contains(t, body, "https://example.com/slack_attachment_1/title_link") + require.Contains(t, body, "message attachment 1 text") + require.Contains(t, body, "https://example.com/slack_attachment_1/image") + require.Contains(t, body, "https://example.com/slack_attachment_1/thumb") + require.Contains(t, body, "message attachment 1 field 1 title") + require.Contains(t, body, "message attachment 1 field 1 value") + require.Contains(t, body, "message attachment 1 field 2 title") + require.Contains(t, body, "message attachment 1 field 2 value") + require.Contains(t, body, "message attachment 1 field 3 title") + require.Contains(t, body, "message attachment 1 field 3 value") + require.Contains(t, body, "message attachment 1 field 4 title") + require.Contains(t, body, "message attachment 1 field 4 value") + require.Contains(t, body, "https://example.com/slack_attachment_1/thumb") + require.Contains(t, body, "message attachment 2 pretext") + require.Contains(t, body, "author name 2") + require.Contains(t, body, "message attachment 2 text") +} + // from here func TestGetNotificationEmailBodyGenericNotificationPublicChannel(t *testing.T) { th := SetupWithStoreMock(t) diff --git a/app/notification_push_test.go b/app/notification_push_test.go index 6dd1b5a2ff..73bf38e72b 100644 --- a/app/notification_push_test.go +++ b/app/notification_push_test.go @@ -1440,20 +1440,19 @@ func TestPushNotificationRace(t *testing.T) { Return(&model.Preference{Value: "test"}, nil) mockStore.On("Preference").Return(&mockPreferenceStore) s := &Server{ - products: make(map[string]Product), - Router: mux.NewRouter(), - filestore: &fmocks.FileBackend{}, + products: make(map[string]Product), + Router: mux.NewRouter(), } var err error s.platform, err = platform.New(platform.ServiceConfig{ ConfigStore: memoryStore, - }) + }, platform.SetFileStore(&fmocks.FileBackend{})) s.SetStore(mockStore) require.NoError(t, err) serviceMap := map[ServiceKey]any{ ConfigKey: s.platform, LicenseKey: &licenseWrapper{s}, - FilestoreKey: s.filestore, + FilestoreKey: s.FileBackend(), } ch, err := NewChannels(s, serviceMap) require.NoError(t, err) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 6dd849aefb..97f929c77d 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -11626,6 +11626,23 @@ func (a *OpenTracingAppLayer) IsCRTEnabledForUser(c request.CTX, userID string) return resultVar0 } +func (a *OpenTracingAppLayer) IsFirstAdmin(user *model.User) bool { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsFirstAdmin") + + 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.IsFirstAdmin(user) + + return resultVar0 +} + func (a *OpenTracingAppLayer) IsFirstUserAccount() bool { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsFirstUserAccount") diff --git a/app/options.go b/app/options.go index 15725bf4c3..19133c42fc 100644 --- a/app/options.go +++ b/app/options.go @@ -54,7 +54,7 @@ func ConfigStore(configStore *config.Store) Option { func SetFileStore(filestore filestore.FileBackend) Option { return func(s *Server) error { - s.filestore = filestore + s.platformOptions = append(s.platformOptions, platform.SetFileStore(filestore)) return nil } } diff --git a/app/permissions.go b/app/permissions.go index a992d0a952..2b34b85bb3 100644 --- a/app/permissions.go +++ b/app/permissions.go @@ -29,6 +29,10 @@ type permissionsServiceWrapper struct { app AppIface } +func (s *permissionsServiceWrapper) HasPermissionTo(userID string, permission *model.Permission) bool { + return s.app.HasPermissionTo(userID, permission) +} + func (s *permissionsServiceWrapper) HasPermissionToTeam(userID string, teamID string, permission *model.Permission) bool { return s.app.HasPermissionToTeam(userID, teamID, permission) } diff --git a/app/permissions_migrations.go b/app/permissions_migrations.go index 613adc927f..2b638be162 100644 --- a/app/permissions_migrations.go +++ b/app/permissions_migrations.go @@ -991,6 +991,27 @@ func (a *App) getPlaybooksPermissionsAddManageRoles() (permissionsMap, error) { return transformations, nil } +func (a *App) getProductsBoardsPermissions() (permissionsMap, error) { + transformations := []permissionTransformation{} + + permissionsProductsRead := []string{model.PermissionSysconsoleReadProductsBoards.Id} + permissionsProductsWrite := []string{model.PermissionSysconsoleWriteProductsBoards.Id} + + // Give the new subsection READ permissions to any user with SYSTEM_MANAGER + transformations = append(transformations, permissionTransformation{ + On: permissionOr(isRole(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)), + Add: permissionsProductsWrite, + }) + + return transformations, nil +} + // DoPermissionsMigrations execute all the permissions migrations need by the current version. func (a *App) DoPermissionsMigrations() error { return a.Srv().doPermissionsMigrations() @@ -1032,6 +1053,7 @@ func (s *Server) doPermissionsMigrations() error { {Key: model.MigrationKeyAddPlaybooksPermissions, Migration: a.getAddPlaybooksPermissions}, {Key: model.MigrationKeyAddCustomUserGroupsPermissions, Migration: a.getAddCustomUserGroupsPermissions}, {Key: model.MigrationKeyAddPlayboosksManageRolesPermissions, Migration: a.getPlaybooksPermissionsAddManageRoles}, + {Key: model.MigrationKeyAddProductsBoardsPermissions, Migration: a.getProductsBoardsPermissions}, } roles, err := s.Store().Role().GetAll() diff --git a/app/platform/cluster.go b/app/platform/cluster.go index 47722ba007..6575ab7daa 100644 --- a/app/platform/cluster.go +++ b/app/platform/cluster.go @@ -180,8 +180,8 @@ func (ps *PlatformService) InvokeClusterLeaderChangedListeners() { } func (ps *PlatformService) Publish(message *model.WebSocketEvent) { - if ps.metricsImpl() != nil { - ps.metricsImpl().IncrementWebsocketEvent(message.EventType()) + if ps.metricsIFace != nil { + ps.metricsIFace.IncrementWebsocketEvent(message.EventType()) } ps.PublishSkipClusterSend(message) diff --git a/app/platform/config.go b/app/platform/config.go index 5b0218dcd6..fd5bb05f76 100644 --- a/app/platform/config.go +++ b/app/platform/config.go @@ -32,7 +32,6 @@ type ServiceConfig struct { ConfigStore *config.Store Store store.Store // Optional fields - Metrics einterfaces.MetricsInterface Cluster einterfaces.ClusterInterface } diff --git a/app/platform/enterprise.go b/app/platform/enterprise.go index 37849fb3f3..cf7cdf0399 100644 --- a/app/platform/enterprise.go +++ b/app/platform/enterprise.go @@ -26,8 +26,8 @@ func RegisterLicenseInterface(f func(*PlatformService) einterfaces.LicenseInterf licenseInterface = f } -var metricsInterface func(*PlatformService, string, string) einterfaces.MetricsInterface +var metricsInterfaceFn func(*PlatformService, string, string) einterfaces.MetricsInterface func RegisterMetricsInterface(f func(*PlatformService, string, string) einterfaces.MetricsInterface) { - metricsInterface = f + metricsInterfaceFn = f } diff --git a/app/platform/helper_test.go b/app/platform/helper_test.go index 17e40627a3..9402f8def3 100644 --- a/app/platform/helper_test.go +++ b/app/platform/helper_test.go @@ -4,7 +4,7 @@ package platform import ( - "io/ioutil" + "os" "path/filepath" "sync" "testing" @@ -55,7 +55,7 @@ func (ms *mockSuite) UserCanSeeOtherUser(userID string, otherUserId string) (boo return true, nil } -func Setup(tb testing.TB) *TestHelper { +func Setup(tb testing.TB, options ...Option) *TestHelper { if testing.Short() { tb.SkipNow() } @@ -64,7 +64,7 @@ func Setup(tb testing.TB) *TestHelper { dbStore.MarkSystemRanUnitTests() mainHelper.PreloadMigrations() - return setupTestHelper(dbStore, false, true, tb) + return setupTestHelper(dbStore, false, true, tb, options...) } func (th *TestHelper) InitBasic() *TestHelper { @@ -96,9 +96,9 @@ func (th *TestHelper) InitBasic() *TestHelper { return th } -func SetupWithStoreMock(tb testing.TB) *TestHelper { +func SetupWithStoreMock(tb testing.TB, options ...Option) *TestHelper { mockStore := testlib.GetMockStoreForSetupFunctions() - th := setupTestHelper(mockStore, false, false, tb) + th := setupTestHelper(mockStore, false, false, tb, options...) statusMock := mocks.StatusStore{} statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil) statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil) @@ -126,8 +126,8 @@ func SetupWithCluster(tb testing.TB, cluster einterfaces.ClusterInterface) *Test return th } -func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer bool, tb testing.TB) *TestHelper { - tempWorkspace, err := ioutil.TempDir("", "apptest") +func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer bool, tb testing.TB, options ...Option) *TestHelper { + tempWorkspace, err := os.MkdirTemp("", "apptest") if err != nil { panic(err) } @@ -149,7 +149,7 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo ps, err := New(ServiceConfig{ ConfigStore: configStore, Store: dbStore, - }) + }, options...) if err != nil { panic(err) } @@ -181,7 +181,10 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo th.Service.SetLicense(nil) } - th.Service.Start(th.Suite) + err = th.Service.Start(th.Suite) + if err != nil { + panic(err) + } return th } diff --git a/app/platform/log.go b/app/platform/log.go index 1d2cc8cf33..42ad85b54e 100644 --- a/app/platform/log.go +++ b/app/platform/log.go @@ -89,11 +89,11 @@ func (ps *PlatformService) NotificationsLogger() *mlog.Logger { } func (ps *PlatformService) EnableLoggingMetrics() { - if ps.metrics == nil || ps.metricsImpl() == nil { + if ps.metrics == nil || ps.metricsIFace == nil { return } - ps.logger.SetMetricsCollector(ps.metricsImpl().GetLoggerMetricsCollector(), mlog.DefaultMetricsUpdateFreqMillis) + ps.logger.SetMetricsCollector(ps.metricsIFace.GetLoggerMetricsCollector(), mlog.DefaultMetricsUpdateFreqMillis) // logging config needs to be reloaded when metrics collector is added or changed. if err := ps.initLogging(); err != nil { diff --git a/app/platform/metrics.go b/app/platform/metrics.go index f486f85cbc..2253594011 100644 --- a/app/platform/metrics.go +++ b/app/platform/metrics.go @@ -32,20 +32,13 @@ type platformMetrics struct { metricsImpl einterfaces.MetricsInterface - cfgFn func() *model.Config -} - -func (ps *PlatformService) metricsImpl() einterfaces.MetricsInterface { - if ps.metrics == nil { - return nil - } - - return ps.metrics.metricsImpl + cfgFn func() *model.Config + listenAddr string } // resetMetrics resets the metrics server. Clears the metrics if the metrics are disabled by the config. -func (ps *PlatformService) resetMetrics(metricsImpl einterfaces.MetricsInterface, cfgFn func() *model.Config) error { - if !*cfgFn().MetricsSettings.Enable { +func (ps *PlatformService) resetMetrics() error { + if !*ps.Config().MetricsSettings.Enable { if ps.metrics != nil { return ps.metrics.stopMetricsServer() } @@ -59,8 +52,8 @@ func (ps *PlatformService) resetMetrics(metricsImpl einterfaces.MetricsInterface } ps.metrics = &platformMetrics{ - cfgFn: cfgFn, - metricsImpl: metricsImpl, + cfgFn: ps.Config, + metricsImpl: ps.metricsIFace, logger: ps.logger, } @@ -68,8 +61,8 @@ func (ps *PlatformService) resetMetrics(metricsImpl einterfaces.MetricsInterface return err } - if metricsImpl != nil { - metricsImpl.Register() + if ps.metricsIFace != nil { + ps.metricsIFace.Register() } return ps.metrics.startMetricsServer() @@ -122,7 +115,8 @@ func (pm *platformMetrics) startMetricsServer() error { } }() - pm.logger.Info("Metrics and profiling server is started", mlog.String("address", l.Addr().String())) + pm.listenAddr = l.Addr().String() + pm.logger.Info("Metrics and profiling server is started", mlog.String("address", pm.listenAddr)) return nil } @@ -181,7 +175,7 @@ func (ps *PlatformService) HandleMetrics(route string, h http.Handler) { } func (ps *PlatformService) RestartMetrics() error { - return ps.resetMetrics(ps.serviceConfig.Metrics, ps.configStore.Get) + return ps.resetMetrics() } func (ps *PlatformService) Metrics() einterfaces.MetricsInterface { @@ -189,5 +183,5 @@ func (ps *PlatformService) Metrics() einterfaces.MetricsInterface { return nil } - return ps.metricsImpl() + return ps.metricsIFace } diff --git a/app/platform/options.go b/app/platform/options.go index 99d63d8f26..65471b63f6 100644 --- a/app/platform/options.go +++ b/app/platform/options.go @@ -11,6 +11,7 @@ import ( "github.com/mattermost/mattermost-server/v6/config" "github.com/mattermost/mattermost-server/v6/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/store" "github.com/mattermost/mattermost-server/v6/store/localcachelayer" @@ -46,7 +47,7 @@ func StoreOverride(override any) Option { func StoreOverrideWithCache(override store.Store) Option { return func(ps *PlatformService) error { ps.newStore = func() (store.Store, error) { - lcl, err := localcachelayer.NewLocalCacheLayer(override, ps.metricsImpl(), ps.clusterIFace, ps.cacheProvider) + lcl, err := localcachelayer.NewLocalCacheLayer(override, ps.metricsIFace, ps.clusterIFace, ps.cacheProvider) if err != nil { return nil, err } @@ -74,6 +75,13 @@ func Config(dsn string, readOnly bool, configDefaults *model.Config) Option { } } +func SetFileStore(filestore filestore.FileBackend) Option { + return func(ps *PlatformService) error { + ps.filestore = filestore + return nil + } +} + // ConfigStore applies the given config store, typically to replace the traditional sources with a memory store for testing. func ConfigStore(configStore *config.Store) Option { return func(ps *PlatformService) error { diff --git a/app/platform/service.go b/app/platform/service.go index b5a506ae50..6620b7f487 100644 --- a/app/platform/service.go +++ b/app/platform/service.go @@ -20,6 +20,7 @@ import ( "github.com/mattermost/mattermost-server/v6/services/cache" "github.com/mattermost/mattermost-server/v6/services/searchengine" "github.com/mattermost/mattermost-server/v6/services/searchengine/bleveengine" + "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/store" "github.com/mattermost/mattermost-server/v6/store/localcachelayer" @@ -39,8 +40,9 @@ type PlatformService struct { WebSocketRouter *WebSocketRouter - serviceConfig *ServiceConfig - configStore *config.Store + configStore *config.Store + + filestore filestore.FileBackend cacheProvider cache.Provider statusCache cache.Cache @@ -57,6 +59,7 @@ type PlatformService struct { startMetrics bool metrics *platformMetrics + metricsIFace einterfaces.MetricsInterface featureFlagSynchronizerMutex sync.Mutex featureFlagSynchronizer *featureflag.Synchronizer @@ -100,7 +103,6 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { // Step 0: Create the PlatformService. // ConfigStore is and should be handled on a upper level. ps := &PlatformService{ - serviceConfig: &sc, Store: sc.Store, configStore: sc.ConfigStore, clusterIFace: sc.Cluster, @@ -170,15 +172,20 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { // Depends on step 3 (s.SearchEngine must be non-nil) ps.initEnterprise() - // Step 5: Store. - // Depends on Step 1 (config), 4 (metrics, cluster) and 5 (cacheProvider). + // Step 5: Init Metrics + if metricsInterfaceFn != nil { + ps.metricsIFace = metricsInterfaceFn(ps, *ps.configStore.Get().SqlSettings.DriverName, *ps.configStore.Get().SqlSettings.DataSource) + } + + // Step 6: Store. + // Depends on Step 0 (config), 1 (cacheProvider), 3 (search engine), 5 (metrics) and cluster. if ps.newStore == nil { ps.newStore = func() (store.Store, error) { - ps.sqlStore = sqlstore.New(ps.Config().SqlSettings, ps.Metrics()) + ps.sqlStore = sqlstore.New(ps.Config().SqlSettings, ps.metricsIFace) lcl, err2 := localcachelayer.NewLocalCacheLayer( retrylayer.New(ps.sqlStore), - ps.Metrics(), + ps.metricsIFace, ps.clusterIFace, ps.cacheProvider, ) @@ -204,11 +211,23 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { return timerlayer.New( searchStore, - ps.Metrics(), + ps.metricsIFace, ), nil } } + license := ps.License() + // Step 3: Initialize filestore + if ps.filestore == nil { + insecure := ps.Config().ServiceSettings.EnableInsecureOutgoingConnections + backend, err2 := filestore.NewFileBackend(ps.Config().FileSettings.ToFileBackendSettings(license != nil && *license.Features.Compliance, insecure != nil && *insecure)) + if err2 != nil { + return nil, fmt.Errorf("failed to initialize filebackend: %w", err2) + } + + ps.filestore = backend + } + var err error ps.Store, err = ps.newStore() if err != nil { @@ -234,20 +253,19 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { return nil, fmt.Errorf("could not create session cache: %w", err) } + // Step 7: Init License if model.BuildEnterpriseReady == "true" { ps.LoadLicense() } - if metricsInterface != nil { - sc.Metrics = metricsInterface(ps, *ps.configStore.Get().SqlSettings.DriverName, *ps.configStore.Get().SqlSettings.DataSource) - } - + // Step 8: Init Metrics Server depends on step 6 (store) and 7 (license) if ps.startMetrics { - if err = ps.resetMetrics(sc.Metrics, ps.configStore.Get); err != nil { - return nil, err + if mErr := ps.resetMetrics(); mErr != nil { + return nil, mErr } } + // Step 9: Init AsymmetricSigningKey depends on step 6 (store) if err = ps.EnsureAsymmetricSigningKey(); err != nil { return nil, fmt.Errorf("unable to ensure asymmetric signing key: %w", err) } @@ -299,6 +317,7 @@ func (ps *PlatformService) Start(suite SuiteIFace) error { return } }) + ps.licenseListenerId = ps.AddLicenseListener(func(oldLicense, newLicense *model.License) { ps.regenerateClientConfig() @@ -427,3 +446,7 @@ func (ps *PlatformService) GetPluginStatuses() (model.PluginStatuses, *model.App return pluginStatuses, nil } + +func (ps *PlatformService) FileBackend() filestore.FileBackend { + return ps.filestore +} diff --git a/app/platform/service_test.go b/app/platform/service_test.go index e53afaae98..b2b7fa1e89 100644 --- a/app/platform/service_test.go +++ b/app/platform/service_test.go @@ -4,12 +4,16 @@ package platform import ( + "net/http" "os" + "strings" "testing" "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/store/storetest" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -83,3 +87,69 @@ func TestReadReplicaDisabledBasedOnLicense(t *testing.T) { require.Len(t, ps.Config().SqlSettings.DataSourceSearchReplicas, 1) }) } + +func TestMetrics(t *testing.T) { + t.Run("ensure the metrics server is not started by default", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + require.Nil(t, th.Service.metrics) + }) + + t.Run("ensure the metrics server is started", func(t *testing.T) { + th := Setup(t, StartMetrics()) + defer th.TearDown() + + // 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) + + require.NotNil(t, th.Service.metrics) + metricsAddr := strings.Replace(th.Service.metrics.listenAddr, "[::]", "http://localhost", 1) + + resp, err := http.Get(metricsAddr) + 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) + + _, err = http.Get(metricsAddr) + require.Error(t, err) + }) + + t.Run("ensure the metrics server is started with advanced metrics", func(t *testing.T) { + th := Setup(t, StartMetrics()) + defer th.TearDown() + + mockMetricsImpl := &mocks.MetricsInterface{} + mockMetricsImpl.On("Register").Return() + + th.Service.metricsIFace = mockMetricsImpl + err := th.Service.resetMetrics() + require.NoError(t, err) + + mockMetricsImpl.AssertExpectations(t) + }) + + t.Run("ensure advanced metrics have database metrics", func(t *testing.T) { + mockMetricsImpl := &mocks.MetricsInterface{} + mockMetricsImpl.On("Register").Return() + mockMetricsImpl.On("ObserveStoreMethodDuration", mock.Anything, mock.Anything, mock.Anything).Return() + + th := Setup(t, StartMetrics(), func(ps *PlatformService) error { + ps.metricsIFace = mockMetricsImpl + return nil + }) + defer th.TearDown() + + _ = th.CreateUserOrGuest(false) + + mockMetricsImpl.AssertExpectations(t) + }) +} diff --git a/app/platform/session.go b/app/platform/session.go index 1b085f7dd1..f6da9aec77 100644 --- a/app/platform/session.go +++ b/app/platform/session.go @@ -59,7 +59,7 @@ func (ps *PlatformService) ClearUserSessionCacheLocal(userID string) { if err := ps.sessionCache.Get(key, &session); err == nil { if session.UserId == userID { ps.sessionCache.Remove(key) - if m := ps.metricsImpl(); m != nil { + if m := ps.metricsIFace; m != nil { m.IncrementMemCacheInvalidationCounterSession() } } @@ -100,11 +100,11 @@ func (ps *PlatformService) ClearAllUsersSessionCache() { func (ps *PlatformService) GetSession(token string) (*model.Session, error) { var session = ps.sessionPool.Get().(*model.Session) if err := ps.sessionCache.Get(token, session); err == nil { - if m := ps.metricsImpl(); m != nil { + if m := ps.metricsIFace; m != nil { m.IncrementMemCacheHitCounterSession() } } else { - if m := ps.metricsImpl(); m != nil { + if m := ps.metricsIFace; m != nil { m.IncrementMemCacheMissCounterSession() } } diff --git a/app/platform/web_conn.go b/app/platform/web_conn.go index 2107333428..4e4fc75062 100644 --- a/app/platform/web_conn.go +++ b/app/platform/web_conn.go @@ -409,7 +409,7 @@ func (wc *WebConn) writePump() { wc.logSocketErr("websocket.drainDeadQueue", err) return } - if m := wc.Platform.metricsImpl(); m != nil { + if m := wc.Platform.metricsIFace; m != nil { m.IncrementWebsocketReconnectEvent(reconnectFound) } } else if wc.hasMsgLoss() { @@ -427,11 +427,11 @@ func (wc *WebConn) writePump() { wc.logSocketErr("websocket.sendHello", err) return } - if m := wc.Platform.metricsImpl(); m != nil { + if m := wc.Platform.metricsIFace; m != nil { m.IncrementWebsocketReconnectEvent(reconnectNotFound) } } else { - if m := wc.Platform.metricsImpl(); m != nil { + if m := wc.Platform.metricsIFace; m != nil { m.IncrementWebsocketReconnectEvent(reconnectLossless) } } @@ -488,7 +488,7 @@ func (wc *WebConn) writePump() { return } - if m := wc.Platform.metricsImpl(); m != nil { + if m := wc.Platform.metricsIFace; m != nil { m.IncrementWebSocketBroadcast(msg.EventType()) } case <-ticker.C: diff --git a/app/platform/web_hub.go b/app/platform/web_hub.go index 3a0d5fd6bb..dc83d574ce 100644 --- a/app/platform/web_hub.go +++ b/app/platform/web_hub.go @@ -142,7 +142,7 @@ func (ps *PlatformService) GetHubForUserId(userID string) *Hub { func (ps *PlatformService) HubRegister(webConn *WebConn) { hub := ps.GetHubForUserId(webConn.UserId) if hub != nil { - if metrics := ps.metricsImpl(); metrics != nil { + if metrics := ps.metricsIFace; metrics != nil { metrics.IncrementWebSocketBroadcastUsersRegistered(strconv.Itoa(hub.connectionIndex), 1) } hub.Register(webConn) @@ -153,7 +153,7 @@ func (ps *PlatformService) HubRegister(webConn *WebConn) { func (ps *PlatformService) HubUnregister(webConn *WebConn) { hub := ps.GetHubForUserId(webConn.UserId) if hub != nil { - if metrics := ps.metricsImpl(); metrics != nil { + if metrics := ps.metricsIFace; metrics != nil { metrics.DecrementWebSocketBroadcastUsersRegistered(strconv.Itoa(hub.connectionIndex), 1) } hub.Unregister(webConn) @@ -317,7 +317,7 @@ func (h *Hub) Broadcast(message *model.WebSocketEvent) { // And possibly, we can look into doing the hub initialization inside // NewServer itself. if h != nil && message != nil { - if metrics := h.platform.metricsImpl(); metrics != nil { + if metrics := h.platform.metricsIFace; metrics != nil { metrics.IncrementWebSocketBroadcastBufferSize(strconv.Itoa(h.connectionIndex), 1) } select { @@ -483,7 +483,7 @@ func (h *Hub) Start(suite SuiteIFace) { connIndex.Remove(directMsg.conn) } case msg := <-h.broadcast: - if metrics := h.platform.metricsImpl(); metrics != nil { + if metrics := h.platform.metricsIFace; metrics != nil { metrics.DecrementWebSocketBroadcastBufferSize(strconv.Itoa(h.connectionIndex), 1) } msg = msg.PrecomputeJSON() diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index 1068adaa64..d36c4fadc5 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -196,6 +196,7 @@ func TestPluginAPIGetUserPreferences(t *testing.T) { } func TestPluginAPIDeleteUserPreferences(t *testing.T) { + t.Skip("MM-47612") th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() diff --git a/app/reaction_test.go b/app/reaction_test.go index d9670e4eeb..33d747136b 100644 --- a/app/reaction_test.go +++ b/app/reaction_test.go @@ -230,7 +230,7 @@ func TestGetTopReactionsForTeamSince(t *testing.T) { expectedTopReactions[3] = &model.TopReaction{EmojiName: "sad", Count: int64(3)} expectedTopReactions[4] = &model.TopReaction{EmojiName: "happy", Count: int64(2)} - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-reactions-for-team-since", func(t *testing.T) { topReactions, err := th.App.GetTopReactionsForTeamSince(teamId, userId, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) @@ -401,7 +401,7 @@ func TestGetTopReactionsForUserSince(t *testing.T) { expectedTopReactions[3] = &model.TopReaction{EmojiName: "heart", Count: int64(3)} expectedTopReactions[4] = &model.TopReaction{EmojiName: "blush", Count: int64(2)} - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-reactions-for-user-since", func(t *testing.T) { topReactions, err := th.App.GetTopReactionsForUserSince(userId, teamId, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) diff --git a/app/server.go b/app/server.go index a4688c8b43..0216fa240f 100644 --- a/app/server.go +++ b/app/server.go @@ -137,7 +137,6 @@ type Server struct { openGraphDataCache cache.Cache clusterLeaderListenerId string loggerLicenseListenerId string - filestore filestore.FileBackend platform *platform.PlatformService platformOptions []platform.Option @@ -239,15 +238,6 @@ func NewServer(options ...Option) (*Server, error) { s.LoadLicense() } - license := s.License() - insecure := s.platform.Config().ServiceSettings.EnableInsecureOutgoingConnections - // Step 3: Initialize filestore - backend, err := filestore.NewFileBackend(s.platform.Config().FileSettings.ToFileBackendSettings(license != nil && *license.Features.Compliance, insecure != nil && *insecure)) - if err != nil { - return nil, errors.Wrap(err, "failed to initialize filebackend") - } - s.filestore = backend - s.licenseWrapper = &licenseWrapper{ srv: s, } @@ -272,7 +262,7 @@ func NewServer(options ...Option) (*Server, error) { ChannelKey: &channelsWrapper{srv: s}, ConfigKey: s.platform, LicenseKey: s.licenseWrapper, - FilestoreKey: s.filestore, + FilestoreKey: s.platform.FileBackend(), FileInfoStoreKey: &fileInfoWrapper{srv: s}, ClusterKey: s.platform, UserKey: New(ServerConnector(s.Channels())), @@ -439,6 +429,7 @@ func NewServer(options ...Option) (*Server, error) { mlog.Info("Printing current working", mlog.String("directory", pwd)) mlog.Info("Loaded config", mlog.String("source", s.platform.DescribeConfig())) + license := s.License() allowAdvancedLogging := license != nil && *license.Features.AdvancedLogging if s.Audit == nil { @@ -1425,7 +1416,7 @@ func (s *Server) SendRemoveExpiredLicenseEmail(email string, renewalLink, locale } func (s *Server) FileBackend() filestore.FileBackend { - return s.filestore + return s.platform.FileBackend() } func (s *Server) TotalWebsocketConnections() int { diff --git a/app/server_test.go b/app/server_test.go index f8135d0461..64b85ce938 100644 --- a/app/server_test.go +++ b/app/server_test.go @@ -90,28 +90,29 @@ func TestStartServerNoS3Bucket(t *testing.T) { } s3Endpoint := fmt.Sprintf("%s:%s", s3Host, s3Port) + configStore, _ := config.NewFileStore("config.json", true) + store, _ := config.NewStoreFromBacking(configStore, nil, false) + + cfg := store.Get() + cfg.FileSettings = model.FileSettings{ + DriverName: model.NewString(model.ImageDriverS3), + AmazonS3AccessKeyId: model.NewString(model.MinioAccessKey), + AmazonS3SecretAccessKey: model.NewString(model.MinioSecretKey), + AmazonS3Bucket: model.NewString("nosuchbucket"), + AmazonS3Endpoint: model.NewString(s3Endpoint), + AmazonS3Region: model.NewString(""), + AmazonS3PathPrefix: model.NewString(""), + AmazonS3SSL: model.NewBool(false), + } + *cfg.ServiceSettings.ListenAddress = ":0" + _, _, err := store.Set(cfg) + require.NoError(t, err) s, err := NewServer(func(server *Server) error { - configStore, _ := config.NewFileStore("config.json", true) - store, _ := config.NewStoreFromBacking(configStore, nil, false) - var err error - server.platform, err = platform.New(platform.ServiceConfig{ - ConfigStore: store, - }) - require.NoError(t, err) - server.platform.UpdateConfig(func(cfg *model.Config) { - cfg.FileSettings = model.FileSettings{ - DriverName: model.NewString(model.ImageDriverS3), - AmazonS3AccessKeyId: model.NewString(model.MinioAccessKey), - AmazonS3SecretAccessKey: model.NewString(model.MinioSecretKey), - AmazonS3Bucket: model.NewString("nosuchbucket"), - AmazonS3Endpoint: model.NewString(s3Endpoint), - AmazonS3Region: model.NewString(""), - AmazonS3PathPrefix: model.NewString(""), - AmazonS3SSL: model.NewBool(false), - } - *cfg.ServiceSettings.ListenAddress = ":0" - }) + var err2 error + server.platform, err2 = platform.New(platform.ServiceConfig{}, platform.ConfigStore(store)) + require.NoError(t, err2) + return nil }) require.NoError(t, err) @@ -120,6 +121,8 @@ func TestStartServerNoS3Bucket(t *testing.T) { defer s.Shutdown() // ensure that a new bucket was created + require.IsType(t, &filestore.S3FileBackend{}, s.FileBackend()) + err = s.FileBackend().(*filestore.S3FileBackend).TestConnection() require.NoError(t, err) } diff --git a/app/slack.go b/app/slack.go index f7baeb53bc..b108b42980 100644 --- a/app/slack.go +++ b/app/slack.go @@ -40,12 +40,12 @@ func (a *App) SlackImport(c *request.Context, fileData multipart.File, fileSize GeneratePreviewImage: a.generatePreviewImage, InvalidateAllCaches: func() { a.ch.srv.InvalidateAllCaches() }, MaxPostSize: func() int { return a.ch.srv.platform.MaxPostSize() }, - PrepareImage: func(fileData []byte) (image.Image, func(), error) { - img, release, err := prepareImage(a.ch.imgDecoder, bytes.NewReader(fileData)) + PrepareImage: func(fileData []byte) (image.Image, string, func(), error) { + img, imgType, release, err := prepareImage(a.ch.imgDecoder, bytes.NewReader(fileData)) if err != nil { - return nil, nil, err + return nil, "", nil, err } - return img, release, err + return img, imgType, release, err }, } diff --git a/app/upload.go b/app/upload.go index 15ccfba51a..3909f5e096 100644 --- a/app/upload.go +++ b/app/upload.go @@ -298,8 +298,8 @@ func (a *App) UploadData(c *request.Context, us *model.UploadSession, rd io.Read } nameWithoutExtension := info.Name[:strings.LastIndex(info.Name, ".")] - info.PreviewPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_preview.jpg" - info.ThumbnailPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_thumb.jpg" + info.PreviewPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_preview." + getFileExtFromMimeType(info.MimeType) + info.ThumbnailPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(info.MimeType) imgData, fileErr := a.ReadFile(uploadPath) if fileErr != nil { return nil, fileErr diff --git a/app/user.go b/app/user.go index bdb9fc8841..c0a9773e61 100644 --- a/app/user.go +++ b/app/user.go @@ -209,6 +209,19 @@ func (a *App) IsFirstUserAccount() bool { return a.ch.srv.platform.IsFirstUserAccount() } +func (a *App) IsFirstAdmin(user *model.User) bool { + if !user.IsSystemAdmin() { + return false + } + + adminID, err := a.Srv().Store().User().GetFirstSystemAdminID() + if err != nil { + return false + } + + return adminID == user.Id +} + // CreateUser creates a user and sets several fields of the returned User struct to // their zero values. func (a *App) CreateUser(c request.CTX, user *model.User) (*model.User, *model.AppError) { diff --git a/app/user_test.go b/app/user_test.go index 174c193deb..139cc1c1e5 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -1801,3 +1801,54 @@ func TestCreateUserWithInitialPreferences(t *testing.T) { assert.Equal(t, "false", recommendedNextStepsPref[0].Value) }) } + +func TestIsFirstAdmin(t *testing.T) { + t.Run("should return false if user is not sysadmin", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + Id := model.NewId() + isFirstAdmin := th.App.IsFirstAdmin(&model.User{ + Id: Id, + Roles: model.SystemUserRoleId, + }) + require.False(t, isFirstAdmin) + }) + + t.Run("should return false if user is sysadmin but not the first one", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + Id := model.NewId() + + mockUserStore := storemocks.UserStore{} + mockUserStore.On("GetFirstSystemAdminID").Return(model.NewId(), nil) + + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockStore.On("User").Return(&mockUserStore) + + isFirstAdmin := th.App.IsFirstAdmin(&model.User{ + Id: Id, + Roles: model.SystemAdminRoleId, + }) + require.False(t, isFirstAdmin) + }) + + t.Run("should return true if user is sysadmin and the first one", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + Id := model.NewId() + + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockUserStore := storemocks.UserStore{} + mockUserStore.On("GetFirstSystemAdminID").Return(Id, nil) + mockStore.On("User").Return(&mockUserStore) + + isFirstAdmin := th.App.IsFirstAdmin(&model.User{ + Id: Id, + Roles: model.SystemAdminRoleId, + }) + require.True(t, isFirstAdmin) + }) +} diff --git a/build/Dockerfile b/build/Dockerfile index ab99fe0dcd..55183179d0 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -8,7 +8,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV PATH="/mattermost/bin:${PATH}" ARG PUID=2000 ARG PGID=2000 -ARG MM_PACKAGE="https://releases.mattermost.com/7.3.0/mattermost-7.3.0-linux-amd64.tar.gz?src=docker" +ARG MM_PACKAGE="https://releases.mattermost.com/7.4.0/mattermost-7.4.0-linux-amd64.tar.gz?src=docker" # # Install needed packages and indirect dependencies RUN apt-get update \ diff --git a/config/environment.go b/config/environment.go index 12e6727484..4ded8d7399 100644 --- a/config/environment.go +++ b/config/environment.go @@ -4,6 +4,7 @@ package config import ( + "encoding/json" "os" "reflect" "strconv" @@ -73,6 +74,11 @@ func applyEnvKey(key, value string, rValueSubject reflect.Value) { } case reflect.SliceOf(reflect.TypeOf("")).Kind(): rFieldValue.Set(reflect.ValueOf(strings.Split(value, " "))) + case reflect.Map: + target := reflect.New(rFieldValue.Type()).Interface() + if err := json.Unmarshal([]byte(value), target); err == nil { + rFieldValue.Set(reflect.ValueOf(target).Elem()) + } } } diff --git a/config/environment_test.go b/config/environment_test.go index fb4b3261ed..e86bcc36e9 100644 --- a/config/environment_test.go +++ b/config/environment_test.go @@ -31,15 +31,54 @@ func TestRemoveEnvOverrides(t *testing.T) { expectedConfig *model.Config }{ { - name: "basic override", + name: "config override", inputConfig: modifiedDefault(func(in *model.Config) { *in.ServiceSettings.TLSMinVer = "1.4" + in.PluginSettings.PluginStates = map[string]*model.PluginState{ + "plugin1": { + Enable: false, + }, + } + in.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.plugin-1": { + "key1": "value1", + }, + "com_mattermost_plugin-2": { + "key2": "value2", + }, + } }), env: map[string]string{ "MM_SERVICESETTINGS_TLSMINVER": "1.5", + "MM_PLUGINSETTINGS_PLUGINSTATES": `{ + "plugin1": { + "Enable": true + } + }`, + "MM_PLUGINSETTINGS_PLUGINS": `{ + "com.mattermost.plugin-1": { + "key1": "other-value" + }, + "com_mattermost_plugin-2": { + "key2": "other-value" + } + }`, }, expectedConfig: modifiedDefault(func(in *model.Config) { *in.ServiceSettings.TLSMinVer = "1.5" + in.PluginSettings.PluginStates = map[string]*model.PluginState{ + "plugin1": { + Enable: true, + }, + } + in.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.plugin-1": { + "key1": "other-value", + }, + "com_mattermost_plugin-2": { + "key2": "other-value", + }, + } }), }, { @@ -102,6 +141,41 @@ func TestRemoveEnvOverrides(t *testing.T) { in.SqlSettings.DataSourceReplicas = []string{"otherthing", "alsothis"} }), }, + { + name: "complex env settings", + inputConfig: modifiedDefault(func(in *model.Config) { + }), + env: map[string]string{ + "MM_PLUGINSETTINGS_PLUGINSTATES": `{ + "com.mattermost.plugin-1": { + "enable": true + } + }`, + "MM_PLUGINSETTINGS_PLUGINS": `{ + "com.mattermost.plugin-1": { + "key": { + "key": "(?PKEY)-(?P\\d{1,6})(?P[,;]*)", + "value": "[$key-$id](https://example.com/?$project-$id)$comma" + } + } + }`, + }, + expectedConfig: modifiedDefault(func(in *model.Config) { + in.PluginSettings.PluginStates = map[string]*model.PluginState{ + "com.mattermost.plugin-1": { + Enable: true, + }, + } + in.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.plugin-1": { + "key": map[string]interface{}{ + "key": "(?PKEY)-(?P\\d{1,6})(?P[,;]*)", + "value": "[$key-$id](https://example.com/?$project-$id)$comma", + }, + }, + } + }), + }, { name: "bad env", inputConfig: modifiedDefault(func(in *model.Config) { diff --git a/i18n/bg.json b/i18n/bg.json index 7913a2775f..4031bf4bf6 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -4267,14 +4267,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Каналите в поканата трябва да са част от канещият екип." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Вашият лиценз не поддържа профили за гости" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Профилите за гости са забранени" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Грешка при анулиране на поканите по ел.поща." diff --git a/i18n/de.json b/i18n/de.json index ae6d93035c..f39f0d304e 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1777,7 +1777,7 @@ }, { "id": "api.templates.email_change_verify_body.title", - "translation": "Du hast deine E-Mail-Adresse geändert" + "translation": "Du hast deine E-Mail-Adresse erfolgreich geändert" }, { "id": "api.templates.email_change_verify_subject", @@ -4801,11 +4801,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Version 44+" + "translation": "Version 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Version 100+" + "translation": "Version 106+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5623,14 +5623,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Die Kanäle der Einladung müssen Teil des Teams der Einladung sein." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Deine Lizenz unterstützt Gastkonten nicht" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Gastkonten sind deaktiviert" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Fehler beim Annulieren von E-Mail-Einladungen." @@ -8380,7 +8372,7 @@ }, { "id": "app.notification.body.dm.subTitle", - "translation": "Währen du weg warst, hat dir {{.SenderName}} eine neue Direktnachricht gesendet." + "translation": "Während du weg warst, hat dir {{.SenderName}} eine neue Direktnachricht gesendet." }, { "id": "app.license.generate_renewal_token.no_license", @@ -9537,5 +9529,33 @@ { "id": "app.user.get_badge_count.app_error", "translation": "Wir konnten den Nachrichtenzähler für den Benutzer nicht abfragen." + }, + { + "id": "app.job.error", + "translation": "Fehler bei der Auftragsausführung." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Fehler beim Abrufen der letzten zugänglichen Datei" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Die Datei kann nicht abgerufen werden, da sie das Limit des Cloud-Plans überschritten hat." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "Gruppenname existiert bereits als reservierter Name" + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "Plugin {{.Name}} kann im Produktmodus nicht aktiviert werden." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Deine Lizenz unterstützt Gastkonten nicht" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Gastkonten sind deaktiviert" } ] diff --git a/i18n/en.json b/i18n/en.json index 5ebcc304a4..710054bf1a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3445,7 +3445,7 @@ }, { "id": "api.templates.email_change_verify_body.title", - "translation": "You updated your email" + "translation": "You successfully updated your email" }, { "id": "api.templates.email_change_verify_subject", @@ -8739,6 +8739,10 @@ "id": "model.incoming_hook.username.app_error", "translation": "Invalid username." }, + { + "id": "model.insights.get_start_of_day_for_time_range.time_range.app_error", + "translation": "Invalid time range." + }, { "id": "model.job.is_valid.create_at.app_error", "translation": "Create at must be a valid time." @@ -9473,11 +9477,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Version 100+" + "translation": "Version 106+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Version 44+" + "translation": "Version 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", diff --git a/i18n/en_AU.json b/i18n/en_AU.json index e1dc7cb1c0..6f6f177f60 100644 --- a/i18n/en_AU.json +++ b/i18n/en_AU.json @@ -5459,14 +5459,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "The channels of the invite must be part of the team of the invite." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Your licence does not support guest accounts" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Guest accounts are disabled" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Error invalidating email invites." @@ -9537,5 +9529,21 @@ { "id": "app.cloud.get_current_plan_name.app_error", "translation": "Unable to get current plan name" + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "group name already exists as a reserved name" + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Error fetching last accessible file" + }, + { + "id": "app.job.error", + "translation": "Error during job execution." + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Unable to retrieve file: cloud storage limit exceeded." } ] diff --git a/i18n/es.json b/i18n/es.json index 97cec02eba..91e6c8d027 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -621,7 +621,7 @@ }, { "id": "api.command_help.desc", - "translation": "Abrir la página de ayuda de Mattermost" + "translation": "Muestra el mensaje de ayuda de Mattermost" }, { "id": "api.command_help.name", @@ -1781,7 +1781,7 @@ }, { "id": "api.templates.email_change_verify_body.title", - "translation": "Haz actualizado tu correo electrónico" + "translation": "Haz actualizado satisfactoriamente tu correo electrónico" }, { "id": "api.templates.email_change_verify_subject", @@ -1857,7 +1857,7 @@ }, { "id": "api.templates.post_body.button", - "translation": "Ver mensaje" + "translation": "Responder en Mattermost" }, { "id": "api.templates.reset_body.button", @@ -3305,7 +3305,7 @@ }, { "id": "ent.migration.migratetoldap.duplicate_field", - "translation": "No se pueden migrar los usuarios de AD/LDAP con el campo especificado. Se ha detectado un entrada. Por favor, retire todos los duplicados e inténtalo de nuevo." + "translation": "No se pueden migrar los usuarios de AD/LDAP con el campo especificado. Se ha detectado un entrada duplicada. Por favor, elimina todos los duplicados e inténtalo de nuevo." }, { "id": "ent.migration.migratetoldap.user_not_found", @@ -4573,7 +4573,7 @@ }, { "id": "oauth.gitlab.tos.error", - "translation": "Los Términos de Servicio de GitLab se han actualizado. Por favor, vaya a gitlab.com a aceptar y, a continuación, intente iniciar sesión en Mattermost de nuevo." + "translation": "Los Términos de Servicio de GitLab se han actualizado. Por favor, visita {{.URL}} para aceptarlos y, a continuación, intenta iniciar sesión en Mattermost nuevamente." }, { "id": "plugin.api.update_user_status.bad_status", @@ -4753,7 +4753,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.windows", - "translation": "Windows 7+" + "translation": "Windows 8.1+" }, { "id": "web.error.unsupported_browser.min_os_version.mac", @@ -4761,19 +4761,19 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.safari", - "translation": "Versión 12+" + "translation": "Versión 14.1+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", - "translation": "Versión 78+" + "translation": "Versión 91+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Versión 44+" + "translation": "Versión 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Versión 89+" + "translation": "Versión 106+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5543,14 +5543,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Los canales de la invitación deben ser parte del equipo al cual fue invitado." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "La licencia actual no es compatible con cuentas de huéspedes" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Las cuentas de huéspedes están deshabilitadas" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Error al invalidar las invitaciones por correo electrónico." @@ -5945,7 +5937,7 @@ }, { "id": "api.push_notifications.session.expired", - "translation": "Sesión caducada: Inicie sesión para continuar recibiendo notificaciones. El administrador del sistema configura las sesiones para {{.siteName}} para que caduquen cada {{.daysCount}} día (s)." + "translation": "Sesión vencida: Inicie sesión para continuar recibiendo notificaciones. El administrador del sistema configura las sesiones para {{.siteName}} para que caduquen cada {{.hoursCount}} hora(s)." }, { "id": "api.post.error_get_post_id.pending", @@ -7621,7 +7613,7 @@ }, { "id": "api.user.get_authorization_code.endpoint.app_error", - "translation": "Error all obtener el endpoint para Documento de Descubrimiento." + "translation": "Error al obtener el endpoint desde Documento de Descubrimiento." }, { "id": "api.templates.payment_failed_no_card.title", @@ -7645,15 +7637,15 @@ }, { "id": "api.templates.payment_failed.title", - "translation": "Pago fallido" + "translation": "El pago ha fallado" }, { "id": "api.templates.payment_failed.subject", - "translation": "Se requiere una acción: El pago de Mattermost Cloud no se ha realizado" + "translation": "Se requiere una acción: El pago de {{.Plan}} Mattermost Cloud no se ha realizado" }, { "id": "api.templates.payment_failed.info3", - "translation": "Para asegurar una suscripción ininterrumpida a Mattermost Cloud, por favor, póngase en contacto con su institución financiera para arreglar el problema subyacente o actualizar su información de pago. Una vez actualizada la información de pago, Mattermost intentará liquidar cualquier saldo pendiente." + "translation": "Para asegurar un acceso ininterrumpido a {{.Plan}} Mattermost Cloud, por favor, póngase en contacto con su institución financiera para resolver el problema subyacente o actualizar su información de pago. Una vez actualizada la información de pago, Mattermost intentará liquidar cualquier saldo pendiente." }, { "id": "api.templates.payment_failed.info2", @@ -7981,7 +7973,7 @@ }, { "id": "api.templates.questions_footer.info", - "translation": "Envíenos un correo electrónico en cualquier momento a " + "translation": "¿Necesitas ayuda o tienes alguna pregunta? Envíanos un correo electrónico a " }, { "id": "api.templates.license_up_for_renewal_title", @@ -8025,11 +8017,11 @@ }, { "id": "api.templates.email_footer_v2", - "translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" + "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" }, { "id": "api.templates.cloud_welcome_email.title", - "translation": "¡El período de prueba de 14 días de tu espacio de trabajo {{.WorkSpace}} está listo!" + "translation": "¡Tu espacio de trabajo ya está listo para usar!" }, { "id": "api.templates.cloud_welcome_email.subtitle_info", @@ -8089,7 +8081,7 @@ }, { "id": "api.templates.cloud_welcome_email.add_apps_sub_info", - "translation": "Agiliza tu trabajo con herramientas como Github, Jira y Zoom. Explora todas las integraciones que tenemos en nuestro" + "translation": "Agiliza tu trabajo con herramientas como GitHub, Jira y Zoom. Explora todas las integraciones que tenemos en nuestro" }, { "id": "api.command_share.unknown_action", @@ -9153,7 +9145,7 @@ }, { "id": "api.templates.invite_team_and_channel_subject", - "translation": " " + "translation": "[{{ .SiteName }}] {{ .SenderName }} te invitó a unirte al Canal {{ .ChannelName }} en el Equipo {{ .TeamDisplayName }}" }, { "id": "api.templates.invite_team_and_channels_body.title", @@ -9237,7 +9229,7 @@ }, { "id": "api.templates.invite_team_and_channel_body.title", - "translation": " " + "translation": "{{ .SenderName }} te invitó a unirte al Canal {{ .ChannelName }} en el Equipo {{ .TeamDisplayName}}" }, { "id": "api.templates.server_inactivity_info_bullet", @@ -9270,5 +9262,301 @@ { "id": "api.user.authorize_oauth_user.saml_response_too_long.app_error", "translation": " " + }, + { + "id": "app.cloud.get_subscription_delinquency_date.app_error", + "translation": "La suscripción no es morosa" + }, + { + "id": "app.cloud.get_subscription.app_error", + "translation": "No se pudo recuperar la suscripción de la nube" + }, + { + "id": "app.cloud.get_current_plan_name.app_error", + "translation": "No se puede obtener el nombre del plan actual" + }, + { + "id": "app.cloud.get_cloud_products.app_error", + "translation": "No se pudieron obtener los productos de la nube" + }, + { + "id": "api.templates.delinquency_90.title", + "translation": "Tu workspace Mattermost ha sido degradado" + }, + { + "id": "api.templates.delinquency_90.subtitle3", + "translation": "Para desarchivar tus datos y mantener las características de pago, actualiza tu información de pago." + }, + { + "id": "api.templates.delinquency_90.subtitle2", + "translation": "Además, tus datos pueden haber sido archivados debido a las limitaciones de Cloud Starter." + }, + { + "id": "api.templates.delinquency_90.subtitle1", + "translation": "Si usas características Cloud Professional o Enterprise para operaciones importantes de negocio, ya no estarán activas y experimentarás un rendimiento degradado." + }, + { + "id": "api.templates.delinquency_90.subject", + "translation": "Tu workspace Mattermost Cloud ha sido degradado" + }, + { + "id": "api.templates.delinquency_90.secondary_action_button", + "translation": "Ver Planes y Precios" + }, + { + "id": "api.templates.delinquency_90.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_75.title", + "translation": "Tu workspace será degradado en 15 días" + }, + { + "id": "api.templates.delinquency_75.subtitle3", + "translation": "Actualiza ahora tu información de pago, o degrada a Cloud Starter." + }, + { + "id": "api.templates.delinquency_75.subtitle2", + "translation": "Tu workspace será degradado a Cloud Starter. Las características de tu {{.Plan}} serán bloqueadas y algunos de los datos de tu workspace podrían ser archivados hasta que liquides completamente tu saldo pendiente." + }, + { + "id": "api.templates.delinquency_75.subtitle1", + "translation": "Este es un aviso final de que no hemos recibido el pago por tu workspace Mattermost Cloud desde {{.DelinquencyDate}}" + }, + { + "id": "api.templates.delinquency_75.subject", + "translation": "Tu {{.Plan}} Mattermost será degradado en 15 días" + }, + { + "id": "api.templates.delinquency_75.downgrade_to_starter", + "translation": "Degradar a Cloud Starter" + }, + { + "id": "api.templates.delinquency_75.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_7.title", + "translation": "Tu pago no se completó" + }, + { + "id": "api.templates.delinquency_7.subtitle2", + "translation": "Para mantener tu plan {{.Plan}} activo, por favor contacta a tu institución financiera tan pronto como sea posible, Luego, actualiza tus detalles de pago según sea necesario." + }, + { + "id": "api.templates.delinquency_7.subtitle1", + "translation": "No pudimos procesar tu pago más reciente" + }, + { + "id": "api.templates.delinquency_7.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_60.title", + "translation": "Tu workspace Mattermost será degradado en 30 días" + }, + { + "id": "api.templates.delinquency_60.subtitle3", + "translation": "Actualiza tu información de pago ahora o degrada a Cloud Starter abajo." + }, + { + "id": "api.templates.delinquency_60.subtitle2", + "translation": "Degradaremos tu workspace automáticamente en 30 días si no somos capaces de procesar tu pago." + }, + { + "id": "api.templates.delinquency_60.subtitle1", + "translation": "Por favor actualiza pronto tu información de pago para procesar tus facturas pendientes." + }, + { + "id": "api.templates.delinquency_60.subject", + "translation": "Acción requerida: El workspace será degradado en 30 días" + }, + { + "id": "api.templates.delinquency_60.downgrade_to_starter", + "translation": "Degradar a Cloud Starter" + }, + { + "id": "api.templates.delinquency_60.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_45.title", + "translation": "Tu workspace será degradado pronto" + }, + { + "id": "api.templates.delinquency_45.subtitle3", + "translation": "Actualiza la información de tu tarjeta de crédito ahora." + }, + { + "id": "api.templates.delinquency_45.subtitle2", + "translation": "Un workspace degradado podría afectar negativamente los flujos de trabajo críticos, integraciones y otras actividades críticas de negocio realizadas en tu workspace." + }, + { + "id": "api.templates.delinquency_45.subtitle1", + "translation": "No fuimos capaces de cobrar el pago para las facturas con fecha {{.DelinquencyDate}}. Tu workspace está en riesgo de ser degradado." + }, + { + "id": "api.templates.delinquency_45.subject", + "translation": "Aviso: Tu {{.Plan}} Mattermost será degradado pronto" + }, + { + "id": "api.templates.delinquency_45.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_30.title", + "translation": "Tu workspace será degradado pronto" + }, + { + "id": "api.templates.delinquency_30.subtitle2", + "translation": "si no se toma ninguna medida, tu workspace será degradado y los siguientes datos serán archivados:" + }, + { + "id": "api.templates.delinquency_30.subtitle1", + "translation": "Tienes tiempo para mantener activo tu {{.Plan}} Mattermost pero necesitarás resolver los problemas con tu método de pago." + }, + { + "id": "api.templates.delinquency_30.subject", + "translation": "Actúa para mantener las característica de tu {{.Plan}} Mattermost" + }, + { + "id": "api.templates.delinquency_30.limits_documentation", + "translation": "Ver toda la documentación de límites." + }, + { + "id": "api.templates.delinquency_30.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_30.bullet.plugins", + "translation": "Plugins e integraciones activas" + }, + { + "id": "api.templates.delinquency_30.bullet.message_history", + "translation": "Historial de mensajes" + }, + { + "id": "api.templates.delinquency_30.bullet.files", + "translation": "Archivos" + }, + { + "id": "api.templates.delinquency_30.bullet.cards", + "translation": "Tarjetas de tus Boards" + }, + { + "id": "api.templates.delinquency_14.title", + "translation": "Pago no recibido" + }, + { + "id": "api.templates.delinquency_14.subtitle2", + "translation": "Por favor contacta a tu institución financiera para resolver cualquier problema. Luego, actualiza los detalles de pago como sean necesarios." + }, + { + "id": "api.templates.delinquency_14.subtitle1", + "translation": "No pudimos realizar el cargo a la tarjeta de crédito que tenemos registrada. Por lo cual tu workspace está en riesgo de ser degradado a Cloud Starter." + }, + { + "id": "api.templates.delinquency_14.subject", + "translation": "El pago está vencido para tu {{.Plan}} Mattermost." + }, + { + "id": "api.templates.delinquency_14.button", + "translation": "Actualizar pago" + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Tu licencia no admite cuentas de invitado" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Las cuentas de invitado están deshabilitadas" + }, + { + "id": "api.command_marketplace.unsupported.app_error", + "translation": "El comando marketplace no es soportado por tu dispositivo." + }, + { + "id": "api.command_marketplace.name", + "translation": "marketplace" + }, + { + "id": "api.command_marketplace.desc", + "translation": "Abre el Marketplace" + }, + { + "id": "api.command_help.success", + "translation": "Mattermost es una plataforma de código abierto para comunicación segura, colaboración, y orquestación de trabajo entre herramientas y equipos.\nMattermost contiene tres herramientas clave:\n\n**Channels** - Mantente conectado con tu equipo vía 1:1 y en por mensajes grupales.\n**[Playbooks](/playbooks)** - Construye y configura procesos repetibles para lograr resultados específicos y predecibles.\n**[Tableros](/boards)** - Administra proyectos y tareas en una estructura de tablero Kanban para ayudar a que tú equipo logre los hitos clave.\n\n[Ver documentación y guías]({{.HelpLink}})" + }, + { + "id": "app.cloud.trial_plan_bot_message", + "translation": "los miembros {{.UsersNum}} del espacio de trabajo {{.WorkspaceName}} han solicitado comenzar la prueba Enterprise para acceder a: " + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "Campos faltantes requeridos para enviar el correo electrónico de morosidad." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "el nombre de grupo ya existe como un nombre reservado" + }, + { + "id": "model.config.is_valid.amazons3_timeout.app_error", + "translation": "Valor de timeout inválido {{.Value}}. Debe ser un número positivo." + }, + { + "id": "ent.saml.configure.certificate_parse_error.app_error", + "translation": "SAML no pudo cargar satisfactoriamente el Identity Provider Public Certificate, por favor contacta a tu administrador del sistema." + }, + { + "id": "app.user.get_badge_count.app_error", + "translation": "No pudimos obtener el recuento de insignias para el usuario." + }, + { + "id": "app.team.clear_cache.app_error", + "translation": "Error al limpiar el caché de los miembros del equipo" + }, + { + "id": "app.post_reminder_dm", + "translation": "Hola, este es un recordatorio acerca de este mensaje de @{{.Username}}: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" + }, + { + "id": "app.post.get_top_dms_for_user_since.app_error", + "translation": "No es posible obtener los DMs top para el usuario." + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "El plugin {{.Name}} no se pudo activar en el modo producto." + }, + { + "id": "app.notify_admin.send_notification_post.app_error", + "translation": "No es posible enviar la publicación de notificación." + }, + { + "id": "app.notify_admin.save.app_error", + "translation": "No es posible guardar los datos de notificación." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Error al obtener el último archivo accesible" + }, + { + "id": "app.job.error", + "translation": "Error durante la ejecución de un job." + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "No se puede obtener el archivo porque superó los límites del plan en la nube." + }, + { + "id": "app.cloud.upgrade_plan_bot_message_single", + "translation": "el miembro {{.UsersNum}} del workspace {{.WorkspaceName}} ha solicitado una actualización del workspace para: " + }, + { + "id": "app.cloud.upgrade_plan_bot_message", + "translation": "los miembros {{.UsersNum}} del workspace {{.WorkspaceName}} han solicitado una actualización del workspace para: " + }, + { + "id": "app.cloud.trial_plan_bot_message_single", + "translation": "el miembro {{.UsersNum}} del workspace {{.WorkspaceName}} ha solicitado comenzar la prueba Enterprise para acceder a: " } ] diff --git a/i18n/fa.json b/i18n/fa.json index b71235d64b..88186a1d34 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -5159,14 +5159,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "کانال های دعوت باید بخشی از تیم دعوت باشند." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "مجوز شما از حساب های مهمان پشتیبانی نمی کند" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "حساب های مهمان غیرفعال شده است" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "خطا در عدم اعتبار دعوت نامه های ایمیل." diff --git a/i18n/fr.json b/i18n/fr.json index 043cda6743..c94978c9da 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -5583,14 +5583,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Les canaux présents dans l'invitation doivent faire partie de la même équipe pour laquelle vous êtes invité." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Votre licence ne prend pas en charge les comptes invités." - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Les comptes invités sont désactivés" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Une erreur s'est produite lors de l'invalidation des e-mails d'invitation." diff --git a/i18n/hu.json b/i18n/hu.json index b10693e9f8..930d7fd5d3 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -3125,7 +3125,7 @@ }, { "id": "oauth.gitlab.tos.error", - "translation": "A GitLab Általános Szerződési Feltételei frissültek. Kérjük, látogassa meg a gitlab.com webhelyet, hogy elfogadja őket, majd próbáljon meg újra bejelentkezni a Mattermost-ba." + "translation": "A GitLab Általános Szerződési Feltételei frissültek. Kérjük, látogassa meg a {{.URL}} webhelyet, hogy elfogadja őket, majd próbáljon meg újra bejelentkezni a Mattermost-ba." }, { "id": "model.websocket_client.connect_fail.app_error", @@ -5469,15 +5469,15 @@ }, { "id": "api.templates.payment_failed.title", - "translation": "Sikertelen fizetés" + "translation": "A fizetési művelet nem sikerült" }, { "id": "api.templates.payment_failed.subject", - "translation": "Művelet szükséges: Sikertelen fizetés a Mattermost Cloudra" + "translation": "Művelet szükséges: Sikertelen fizetés a Mattermost {{.Plan}} csomagra" }, { "id": "api.templates.payment_failed.info3", - "translation": "A Mattermost Cloud zavartalan előfizetésének biztosítása érdekében kérjük, vegye fel a kapcsolatot a pénzintézetével a probléma megoldása érdekében, vagy frissítse fizetési adatait. A fizetési adatok frissítése után a Mattermost megpróbálja rendezni a fennmaradó egyenleget." + "translation": "A Mattermost {{.Plan}} zavartalan elérésének biztosítása érdekében kérjük, vegye fel a kapcsolatot a pénzintézetével a probléma megoldása érdekében, vagy frissítse fizetési adatait. A fizetési adatok frissítése után a Mattermost megpróbálja rendezni a fennmaradó egyenleget." }, { "id": "api.templates.payment_failed.info2", @@ -5795,14 +5795,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "A meghívás csatornáinak a meghívó csapat részének kell lenniük." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Az Ön licence nem támogatja a vendég fiókokat" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "A vendég fiókok le vannak tiltva" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Hiba történt az e-mail meghívók érvénytelenítésekor." @@ -9354,5 +9346,45 @@ { "id": "api.templates.delinquency_30.subject", "translation": "Lépjen most, hogy megtarthassa a Mattermost {{.Plan}} funkcióit" + }, + { + "id": "app.notify_admin.send_notification_post.app_error", + "translation": "Nem sikerült elküldeni az értesítési bejegyzést." + }, + { + "id": "app.notify_admin.save.app_error", + "translation": "Nem sikerült elmenteni az értesítési beállításokat." + }, + { + "id": "app.job.error", + "translation": "Hiba a művelet végrehajtása közben." + }, + { + "id": "app.cloud.get_current_plan_name.app_error", + "translation": "Nem sikerült lekérni a jelenlegi előfizetési csomag nevét" + }, + { + "id": "api.templates.delinquency_30.button", + "translation": "Fizetés frissítése" + }, + { + "id": "api.templates.delinquency_30.bullet.plugins", + "translation": "Aktív bővítmények és integrációk" + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "Hiányzó kötelező mezők a késedelmes e-mail küldéséhez." + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Nem sikerült lekérni a fájlt, mivel az meghaladta a felhőcsomag határértékét." + }, + { + "id": "app.user.get_badge_count.app_error", + "translation": "Nem sikerült lekérni a felhasználó jelvény számlálóját." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "csoport név létezik mint lefoglalt név" } ] diff --git a/i18n/it.json b/i18n/it.json index 2b4114bad9..f9c6ae0911 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -5211,10 +5211,6 @@ "id": "api.team.add_members.user_denied", "translation": "Questa squadra è gestita dai gruppi. Questo utente non appartiene a un gruppo sincronizzato con questa squadra." }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Gli account ospite sono disattivati" - }, { "id": "api.team.remove_member.group_constrained.app_error", "translation": "Impossibile rimuovere un utente da una squadra con vincoli di gruppo." @@ -5643,10 +5639,6 @@ "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Errore invalidando gli inviti email." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "La tua licenza non supporta gli account ospite" - }, { "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "I canali dell'invito devono far parte della squadra dell'invito." diff --git a/i18n/ja.json b/i18n/ja.json index 5918ca411b..2b9e6beead 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -5579,14 +5579,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "招待チャンネルは招待チーム内のチャンネルでなくてはなりません。" }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "現在のライセンスではゲストアカウントはサポートされていません" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "ゲストアカウントは無効化されています" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "電子メール招待の無効化でエラーが発生しました。" diff --git a/i18n/ko.json b/i18n/ko.json index 6efc6c5e61..7d4b36ccb3 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -619,7 +619,7 @@ }, { "id": "api.command_help.desc", - "translation": "Mattermost 도움말 페이지 열기" + "translation": "Mattermost 도움말 보기" }, { "id": "api.command_help.name", @@ -5575,14 +5575,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "초대 채널은 초대한 팀의 채널이어야 합니다." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "게스트 계정을 지원하지 않는 라이선스입니다" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "게스트 계정이 비활성화되어 있습니다" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "전자우편 초대에서 유효하지 않는 오류가 발생했습니다." @@ -7934,5 +7926,41 @@ { "id": "api.cloud.teams_limit_reached.create", "translation": "팀 제한에 도달했기 때문에 팀을 만들 수 없습니다" + }, + { + "id": "api.command_marketplace.desc", + "translation": "마켓플레이스 열기" + }, + { + "id": "api.command_help.success", + "translation": "Mattermost는 도구와 팀을 넘나드는 안전한 소통, 협업, 작업 조율을 위한 오픈소스 플랫폼입니다.\nMattermost는 세가지 핵심 도구를 갖고 있습니다:\n\n**채널** - 1:1 및 그룹 채팅을 통해 팀과의 소통을 유지하세요.\n**[플레이북](/playbooks)** - 반복가능한 작업들을 구성하여 구체적이고 예측 가능한 결과를 만드세요.\n**[보드](/boards)** - 팀의 핵심 마일스톤에 도달하기 위해 칸반 보드에서 작업을 만들고 프로젝트를 관리하세요.\n\n[문서 및 가이드 살펴보기]({{.HelpLink}})" + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "연체 이메일을 보내기 위한 필수 항목이 누락되었습니다." + }, + { + "id": "api.error_set_first_admin_complete_setup", + "translation": "스토어에서 첫 번째 관리자의 설정을 저장하는 동안 오류가 발생했습니다." + }, + { + "id": "api.error_get_first_admin_visit_marketplace_status", + "translation": "스토어에서 첫 번째 관리자의 마켓플레이스 방문 상태를 검색하는 동안 오류가 발생했습니다." + }, + { + "id": "api.error_get_first_admin_complete_setup", + "translation": "스토어에서 첫 번째 관리자 설정을 검색하는 동안 오류가 발생했습니다." + }, + { + "id": "api.command_remote.invite_summary", + "translation": "AES-256으로 암호화된 초대를 비밀번호와 함께 외부 Mattermost 시스템 관리자에게 보냅니다. 초대를 받기 위해 `{{.Command}}` 슬래시 명령어를 사용합니다.\n\n```\n{{.Invitation}}\n```\n\n**보안 연결이 다음을 통해 Mattermost에 액세스할 수 있는지 확인하십시오** {{.SiteURL}}" + }, + { + "id": "api.command_marketplace.unsupported.app_error", + "translation": "마켓플레이스 명령어는 현재 기기에서 지원하지 않습니다." + }, + { + "id": "api.command_marketplace.name", + "translation": "마켓플레이스" } ] diff --git a/i18n/nl.json b/i18n/nl.json index 6f883eaa1a..c23bb81c98 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -1777,7 +1777,7 @@ }, { "id": "api.templates.email_change_verify_body.title", - "translation": "Je hebt je e-mailadres bijgewerkt" + "translation": "Je hebt je e-mailadres succesvol bijgewerkt" }, { "id": "api.templates.email_change_verify_subject", @@ -4325,7 +4325,7 @@ }, { "id": "model.post.is_valid.create_at.app_error", - "translation": "Aangemaakt op moet een geldige tijd bevatten" + "translation": "Aangemaakt op moet een geldige tijd bevatten." }, { "id": "model.post.is_valid.file_ids.app_error", @@ -4781,11 +4781,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Versie 44+" + "translation": "Versie 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Versie 100+" + "translation": "Versie 106+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5607,14 +5607,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "De kanalen van de uitgenodigde moet een kanaal dat deel uitmaakt van het team." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Je licentie ondersteunt geen gastgebruikers" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Gast accounts zijn uitgeschakeld" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Fout bij afkeuren e-mailuitnodigingen." @@ -9537,5 +9529,33 @@ { "id": "api.templates.delinquency_90.title", "translation": "Jouw Mattermost-werkruimte werd gedowngraded" + }, + { + "id": "app.job.error", + "translation": "Fout tijdens het uitvoeren van de opdracht." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "groepsnaam bestaat al als een gereserveerde naam" + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "Plugin {{.Name}} kan niet worden ingeschakeld in de productmodus." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Fout bij het ophalen van het laatst toegankelijke bestand" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Kan het bestand niet ophalen omdat dit bestand over de limiet van het cloud plan is." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Jouw licentie ondersteunt geen gastaccounts" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Gastaccounts zijn uitgeschakeld" } ] diff --git a/i18n/pl.json b/i18n/pl.json index de2e7d8a68..f62b244a3d 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -4761,11 +4761,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Wersja 44+" + "translation": "Wersja 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Wersja 100+" + "translation": "Wersja 106+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5583,14 +5583,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Kanały do których chcesz zaprosić, muszą być częścią zespołu do którego chcesz zaprosić." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Twoja licencja nie obsługuje kont gości" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Konta gości są wyłączone" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Błąd unieważniania zaproszenia E-Mail." @@ -9542,5 +9534,29 @@ { "id": "app.job.error", "translation": "Błąd podczas wykonywania zadania." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Błąd pobierania ostatniego dostępnego pliku" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Nie można pobrać pliku, ponieważ przekroczony został limit planu chmury." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "nazwa grupy już istnieje jako nazwa zastrzeżona" + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "Wtyczka {{.Name}} nie może być włączona w trybie produktu." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Twoja licencja nie wspiera kont gości" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Konta gości są wyłączone" } ] diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index 94b78b3e58..2873fe7a1d 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -621,7 +621,7 @@ }, { "id": "api.command_help.desc", - "translation": "Abrir a página de ajuda do Mattermost" + "translation": "Exibir descrição de ajuda do Mattermost" }, { "id": "api.command_help.name", @@ -1173,7 +1173,7 @@ }, { "id": "api.marshal_error", - "translation": "marshal error" + "translation": "Falha para marshal." }, { "id": "api.oauth.allow_oauth.redirect_callback.app_error", @@ -1789,7 +1789,7 @@ }, { "id": "api.templates.email_footer", - "translation": "Para alterar suas preferencias de notificação, faça login no site da sua equipe e vá para Configurações de Conta > Notificações." + "translation": "Para alterar suas preferencias de notificação, faça login no site da sua equipe e vá para Configurações > Notificações." }, { "id": "api.templates.email_info1", @@ -1813,7 +1813,7 @@ }, { "id": "api.templates.invite_body.button", - "translation": "Entrar na Equipe" + "translation": "Entrar agora" }, { "id": "api.templates.invite_body.title", @@ -1857,7 +1857,7 @@ }, { "id": "api.templates.post_body.button", - "translation": "Ir Para a Publicação" + "translation": "Responder no Mattermost" }, { "id": "api.templates.reset_body.button", @@ -1865,7 +1865,7 @@ }, { "id": "api.templates.reset_body.title", - "translation": "Você solicitou uma redefinição de senha" + "translation": "Redefina sua senha" }, { "id": "api.templates.reset_subject", @@ -4333,11 +4333,11 @@ }, { "id": "model.post.is_valid.file_ids.app_error", - "translation": "Ids do arquivo inválido. Observe que os uploads estão limitados a cinco arquivos no máximo. Por favor, use publicações adicionais para mais arquivos." + "translation": "Ids do arquivo inválido. Observe que os uploads estão limitados a 10 arquivos no máximo. Por favor, use publicações adicionais para mais arquivos." }, { "id": "model.post.is_valid.filenames.app_error", - "translation": "Nomes de arquivo inválidos" + "translation": "Nomes de arquivo inválidos." }, { "id": "model.post.is_valid.hashtags.app_error", @@ -4573,7 +4573,7 @@ }, { "id": "oauth.gitlab.tos.error", - "translation": "Os Termos de Serviço do GitLab foram atualizados. Por favor vá até gitlab.com para aceitar os termos e depois tente se logar novamente no Mattermost." + "translation": "Os Termos de Serviço do GitLab foram atualizados. Por favor vá até {{.URL}} para aceitar os termos e depois tente se logar novamente no Mattermost." }, { "id": "plugin.api.update_user_status.bad_status", @@ -4745,19 +4745,19 @@ }, { "id": "web.error.unsupported_browser.min_os_version.windows", - "translation": "Windows 7+" + "translation": "Windows 8.1+" }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.9+" + "translation": "macOS 10.14+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", - "translation": "Versão 12+" + "translation": "Versão 14.1+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", - "translation": "Versão 60+" + "translation": "Versão 91+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", @@ -4765,7 +4765,7 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Versão 61+" + "translation": "Version 100+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5587,14 +5587,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Os canais do convite devem ser parte da equipe do convite." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Sua licença não suporta contas convidado" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Contas convidado estão desativadas" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Erro ao invalidar convites por email." @@ -5945,7 +5937,7 @@ }, { "id": "api.push_notifications.session.expired", - "translation": "Sessão expirada: faça login para continuar recebendo notificações. As sessões para {{.siteName}} estão configuradas pelo Administrador do Sistema para expirar a cada {{.daysCount}} dia(s)." + "translation": "Sessão expirada: faça login para continuar recebendo notificações. As sessões para {{.siteName}} estão configuradas pelo Administrador do Sistema para expirar a cada {{.hoursCount}} hour(s)." }, { "id": "api.post.error_get_post_id.pending", @@ -6189,7 +6181,7 @@ }, { "id": "app.preference.permanent_delete_by_user.app_error", - "translation": "Encontramos um erro enquanto excluía as preferências." + "translation": "Encontramos um erro enquanto excluiamos as preferências." }, { "id": "app.preference.get_category.app_error", @@ -6897,7 +6889,7 @@ }, { "id": "model.upload_session.is_valid.user_id.app_error", - "translation": "Valor inválido para user_id." + "translation": "Valor inválido para UserId" }, { "id": "model.upload_session.is_valid.type.app_error", @@ -6905,15 +6897,15 @@ }, { "id": "model.upload_session.is_valid.path.app_error", - "translation": "Valor inválido para path." + "translation": "Valor inválido para path" }, { "id": "model.upload_session.is_valid.id.app_error", - "translation": "Valor inválido para id." + "translation": "Valor inválido para id" }, { "id": "model.upload_session.is_valid.create_at.app_error", - "translation": "Valor inválido para create_at." + "translation": "Valor inválido para Create_At" }, { "id": "app.upload.upload_data.large_image.app_error", @@ -7601,7 +7593,7 @@ }, { "id": "api.templates.copyright", - "translation": "© 2020 Mattermost, Inc. 855 El Camino Real, 13A-168, Palo Alto, CA, 94301" + "translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" }, { "id": "api.roles.patch_roles.not_allowed_permission.error", @@ -7645,15 +7637,15 @@ }, { "id": "api.templates.payment_failed.title", - "translation": "Pagamento Falhou" + "translation": "O Pagamento não foi bem sucedido" }, { "id": "api.templates.payment_failed.subject", - "translation": "Ação necessária: Falha no pagamento para Mattermost Cloud" + "translation": "Ação necessária: Falha no pagamento para Mattermost {{.Plan}}" }, { "id": "api.templates.payment_failed.info3", - "translation": "Para garantir a assinatura ininterrupta do Mattermost Cloud, entre em contato com sua instituição financeira para corrigir o problema subjacente ou atualize suas informações de pagamento. Assim que as informações de pagamento forem atualizadas, a Mattermost tentará liquidar o saldo pendente." + "translation": "Para garantir a acesso ininterrupto do Mattermost {{.Plan}}, entre em contato com sua instituição financeira para corrigir o problema subjacente ou atualize suas informações de pagamento. Assim que as informações de pagamento forem atualizadas, a Mattermost tentará liquidar o saldo pendente." }, { "id": "api.templates.payment_failed.info2", @@ -8069,7 +8061,7 @@ }, { "id": "api.user.get_authorization_code.endpoint.app_error", - "translation": "Erro ao recuperar o endpoint da Descoberta de Documento." + "translation": "Erro ao recuperar o endpoint do Documento de Descoberta." }, { "id": "api.templates.welcome_body.subTitle2", @@ -8117,11 +8109,11 @@ }, { "id": "api.templates.questions_footer.info", - "translation": "Envie-nos um email a qualquer momento em " + "translation": "Precisa de ajuda ou tem dúvidas? Envie-nos um e-mail em " }, { "id": "api.templates.email_footer_v2", - "translation": "© 2020 Mattermost, Inc. 855 El Camino Real, 13A-168, Palo Alto, CA, 94301" + "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" }, { "id": "api.post.search_files.invalid_body.app_error", @@ -8333,7 +8325,7 @@ }, { "id": "api.context.remote_id_invalid.app_error", - "translation": "Não foi possível encontrar o id do cluster remoto {{.RemoteId}}." + "translation": "Não foi possível encontrar o ID de uma conexão segura {{.RemoteId}}." }, { "id": "api.context.json_encoding.app_error", @@ -8357,15 +8349,15 @@ }, { "id": "api.command_share.remote_uninvited", - "translation": "Remoto `{{.RemoteId}}` sem convite." + "translation": "Conexão segura `{{.RemoteId}}` sem convite." }, { "id": "api.command_share.remote_id_invalid.error", - "translation": "O id do cluster remoto é inválido: {{.Error}}" + "translation": "O ID de conexão segura é inválido: {{.Error}}" }, { "id": "api.command_share.remote_id.help", - "translation": "Id de uma instância remota existente. Veja o comando `remote` para adicionar uma instância remota." + "translation": "ID de uma conexão segura existente. Veja o comando `remote` para adicionar uma conexão segura." }, { "id": "api.command_share.permission_required", @@ -8437,7 +8429,7 @@ }, { "id": "api.templates.cloud_welcome_email.signin_sub_info2", - "translation": "para a melhor experiência em PC, Mac, iOS e Android." + "translation": "para a melhor experiência em Windows, Linux, Mac, iOS e Android." }, { "id": "api.templates.cloud_welcome_email.signin_sub_info", @@ -8469,7 +8461,7 @@ }, { "id": "api.templates.cloud_welcome_email.add_apps_sub_info", - "translation": "Otimize seu trabalho com ferramentas como Github, Google Agenda e Chrome. Explore todas as integrações que temos" + "translation": "Otimize seu trabalho com ferramentas como GitHub, Jira e Zoom. Explore todas as integrações que temos em nosso" }, { "id": "api.templates.cloud_welcome_email.add_apps_info", @@ -8481,11 +8473,11 @@ }, { "id": "api.remote_cluster.update_not_unique.app_error", - "translation": "Já existe um cluster remoto com a mesmo url." + "translation": "Já existe uma conexão segura com a mesmo url." }, { "id": "api.remote_cluster.update.app_error", - "translation": "Encontramos um erro ao atualizar o cluster remoto." + "translation": "Encontramos um erro ao atualizar a conexão segura." }, { "id": "api.remote_cluster.service_not_enabled.app_error", @@ -8493,11 +8485,11 @@ }, { "id": "api.remote_cluster.save_not_unique.app_error", - "translation": "O cluster remoto já foi adicionado." + "translation": "A conexão segura já foi adicionada." }, { "id": "api.remote_cluster.save.app_error", - "translation": "Encontramos um erro ao salvar o cluster remoto." + "translation": "Encontramos um erro ao salvar a conexão segura." }, { "id": "api.remote_cluster.invalid_topic.app_error", @@ -8509,11 +8501,11 @@ }, { "id": "api.remote_cluster.get.app_error", - "translation": "Encontramos um erro ao recuperar um cluster remoto." + "translation": "Encontramos um erro ao recuperar uma conexão segura." }, { "id": "api.remote_cluster.delete.app_error", - "translation": "Encontramos um erro ao excluir o cluster remoto." + "translation": "Encontramos um erro ao excluir uma conexão segura." }, { "id": "api.job.unable_to_create_job.incorrect_job_type", @@ -8533,11 +8525,11 @@ }, { "id": "api.context.remote_id_missing.app_error", - "translation": "Falta a id do cluster remoto." + "translation": "Falta a ID de conexão sergura." }, { "id": "api.context.remote_id_mismatch.app_error", - "translation": "Incompatibilidade de id do cluster remoto." + "translation": "Incompatibilidade de ID da conexão segura." }, { "id": "api.context.invitation_expired.error", @@ -8549,11 +8541,11 @@ }, { "id": "api.command_share.uninvite_remote_id.help", - "translation": "Id da instância remota para desconvidar." + "translation": "ID de uma conexão segura para desconvidar." }, { "id": "api.command_share.uninvite_remote.help", - "translation": "Cancela o convite de uma instância remota deste canal compartilhado" + "translation": "Cancela o convite de uma conexão segura deste canal compartilhado" }, { "id": "api.command_share.shared_channel_unavailable", @@ -8569,11 +8561,11 @@ }, { "id": "api.command_share.remote_not_valid", - "translation": "Deve especificar um cluster remoto válido para cancelar o convite" + "translation": "Deve especificar uma ID de conexão segura válida para cancelar o convite" }, { "id": "api.command_share.remote_already_invited", - "translation": "O cluster remoto já foi convidado." + "translation": "A conexão segura já foi convidada." }, { "id": "api.command_share.not_shared_channel_unshare", @@ -8581,15 +8573,15 @@ }, { "id": "api.command_share.no_remote_invited", - "translation": "Nenhum remoto foi convidado para este canal compartilhado." + "translation": "Nenhuma conexão remota foi convidada a este canal." }, { "id": "api.command_share.name", - "translation": "compartilhar" + "translation": "compartilhar-canal" }, { "id": "api.command_share.must_specify_valid_remote", - "translation": "Deve especificar um ID de conjunto remoto válido para convidar." + "translation": "Deve especificar um ID de conjunto válido seguro para convidar." }, { "id": "api.command_share.missing_action", @@ -8650,5 +8642,13 @@ { "id": "Boards", "translation": "Quadros" + }, + { + "id": "Playbooks", + "translation": "Playbooks" + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "Campos faltando para envio de email." } ] diff --git a/i18n/ro.json b/i18n/ro.json index c51b4666e0..6c9fe29085 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -5587,14 +5587,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Canalele invitației trebuie să facă parte din echipa invitației." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Licența dvs. nu acceptă conturile de invitați" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Conturile oaspeților sunt dezactivate" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Eroare la invalidarea invitațiilor de e-mail." diff --git a/i18n/ru.json b/i18n/ru.json index 3d371f6a36..0b4fd7d438 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -5235,10 +5235,6 @@ "id": "api.team.demote_user_to_guest.license.error", "translation": "Ваша лицензия не поддерживает гостевые учётные записи" }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Ваша лицензия не поддерживает гостевые учётные записи" - }, { "id": "api.team.remove_member.group_constrained.app_error", "translation": "Невозможно удалить пользователя из управляемой группами команды." @@ -5675,10 +5671,6 @@ "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Ошибка аннулирования e-mail приглашений." }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Гостевые учётные записи отключены" - }, { "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Каналы в приглашении должны быть частью команды в приглашении." diff --git a/i18n/sv.json b/i18n/sv.json index 2374ce62ad..290ff4f7e5 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -5275,14 +5275,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "De kanaler som ingår i inbjudan måste tillhöra de team som inbjudan gäller för." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Din licens tillåter inte gäståtkomst" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Gäståtkomst har inaktiverats" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Fel vid ogiltiggörande av e-postinbjudan." @@ -9541,5 +9533,25 @@ { "id": "app.job.error", "translation": "Fel under utförandet av jobbet." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "gruppnamnet finns redan som ett reserverat namn" + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Fel vid hämtning av den senast tillgängliga filen" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Ditt abonnemang begränsar möjligheten att hämta filen." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Din licens tillåter inte gäståtkomst" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Gäståtkomst har inaktiverats" } ] diff --git a/i18n/tr.json b/i18n/tr.json index 563015338b..cbe04ebdb2 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -2725,7 +2725,7 @@ }, { "id": "app.import.validate_team_import_data.name_reserved.error", - "translation": "Takım adında ayrılmış bir sözcük var." + "translation": "Takım adında sistem kullanımına ayrılmış bir sözcük var." }, { "id": "app.import.validate_team_import_data.scheme_invalid.error", @@ -5577,7 +5577,7 @@ }, { "id": "api.ldap_groups.existing_reserved_name_error", - "translation": "aynı adlı bir grup zaten ayrılmış bir ad olarak var" + "translation": "aynı adlı bir grup zaten sistem kullanımına ayrılmış bir ad olarak var" }, { "id": "api.ldap_groups.existing_user_name_error", @@ -5655,14 +5655,6 @@ "id": "api.team.invalidate_all_email_invites.app_error", "translation": "E-posta çağrıları geçersiz kılınırken sorun çıktı." }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Konuk hesapları kullanılmıyor" - }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Lisansınızda konuk hesaplarını kullanma özelliği yok" - }, { "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Çağrılan kanallar çağrı yapılan takımın bir parçası olmalıdır." @@ -5849,11 +5841,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Sürüm 100+" + "translation": "Sürüm 106+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Sürüm 44+" + "translation": "Sürüm 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", @@ -9537,5 +9529,33 @@ { "id": "app.user.get_badge_count.app_error", "translation": "Kullanıcının nişan sayısını alamadık." + }, + { + "id": "app.job.error", + "translation": "Görev yürütülürken sorun çıktı." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Erişilebilen son dosya alınırken sorun çıktı" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Cloud tarifesinin sınırlarını aştığından dosya alınamadı." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "aynı adlı bir grup zaten sistem kullanımına ayrılmış bir ad olarak var" + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "{{.Name}} uygulama eki ürün kipinde etkinleştirilemez." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Lisansınız konuk hesaplarının kullanılmasını desteklemiyor" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Konuk hesapları devre dışı bırakılmış" } ] diff --git a/i18n/uk.json b/i18n/uk.json index 21f427b84f..c05c7895b3 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -5583,14 +5583,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "The channels of the invite must be part of the team of the invite." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Редакція не підтримує Elasticsearch. " - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Guest accounts are disabled" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Error invalidating email invites." @@ -6858,5 +6850,17 @@ { "id": "api.admin.add_certificate.parseform.app_error", "translation": "Помилка синтаксісу багато-форматного запиту" + }, + { + "id": "api.channel.patch_channel_moderations.cache_invalidation.error", + "translation": "Помилка валідації кешу" + }, + { + "id": "api.back_to_app", + "translation": "Повернутися до {{.SiteName}}" + }, + { + "id": "Channels", + "translation": "Канали" } ] diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 4dba1375ff..cecf6c9252 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -5611,10 +5611,6 @@ "id": "api.team.invalidate_all_email_invites.app_error", "translation": "作废邮件邀请错误。" }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "您的许可证不支持访客帐号" - }, { "id": "api.team.remove_member.group_constrained.app_error", "translation": "无法从组受限团队移除用户。" @@ -5627,10 +5623,6 @@ "id": "api.team.search_teams.pagination_not_implemented.public_team_search", "translation": "仅搜索公共团队不支持分页。" }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "访客帐号已停用" - }, { "id": "api.templates.remove_expired_license.subject", "translation": "Mattermost 企业许可证已停用。" diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index a24b40c159..11b8fcb9ef 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -421,7 +421,7 @@ }, { "id": "api.command_channel_header.update_channel.app_error", - "translation": "更新當前頻道時錯誤。" + "translation": "更新當前頻道名稱時錯誤。" }, { "id": "api.command_channel_purpose.channel.app_error", @@ -453,7 +453,7 @@ }, { "id": "api.command_channel_purpose.update_channel.app_error", - "translation": "更新當前頻道時錯誤。" + "translation": "更新當前頻道用途時錯誤。" }, { "id": "api.command_channel_remove.channel.app_error", @@ -619,7 +619,7 @@ }, { "id": "api.command_help.desc", - "translation": "開啟 Mattermost 說明頁面" + "translation": "顯示 Mattermost 說明頁面" }, { "id": "api.command_help.name", @@ -5579,14 +5579,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "邀請的頻道必須屬於邀請的團隊" }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "授權不支援訪客帳號" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "訪客帳號已停用" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "使電子郵件邀請無效時失敗。" @@ -7302,5 +7294,57 @@ { "id": "api.cloud.teams_limit_reached.create", "translation": "無法建立團隊,已達到團隊數量上限" + }, + { + "id": "api.command_custom_status.success", + "translation": "你的狀態已設為 “{{.EmojiName}} {{.StatusMessage}}”。您可以從頻道側邊欄中的狀態彈出視窗更改您的狀態。" + }, + { + "id": "api.command_custom_status.name", + "translation": "狀態" + }, + { + "id": "api.command_custom_status.hint", + "translation": "[:emoji_name:] [status_message] 或清除" + }, + { + "id": "api.command_custom_status.desc", + "translation": "設定或清除您的狀態" + }, + { + "id": "api.command_custom_status.clear.success", + "translation": "狀態已清除。" + }, + { + "id": "api.command_custom_status.clear.app_error", + "translation": "清除狀態錯誤。" + }, + { + "id": "api.command_custom_status.app_error", + "translation": "設定狀態錯誤。" + }, + { + "id": "api.command_channel_purpose.update_channel.max_length", + "translation": "輸入的文本超出字數限制。頻道用途不得超過 {{.MaxLength}} 個字數。" + }, + { + "id": "api.command_channel_header.update_channel.max_length", + "translation": "輸入的文本超出字數限制。頻道標題不得超過 {{.MaxLength}} 個字數。" + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "缺少發送拖欠電子郵件的必要欄位。" + }, + { + "id": "api.cloud.cws_webhook_event_missing_error", + "translation": "Webhook 事件未處理。事件可能遺失或無效。" + }, + { + "id": "api.channel.create_channel.direct_channel.team_restricted_error", + "translation": "無法在這些使用者間創建直連頻道因為他們不屬於同一個團隊。" + }, + { + "id": "api.admin.saml.failure_reset_authdata_to_email.app_error", + "translation": "無法寄出重設AuthData欄位失敗的電子郵件。" } ] diff --git a/model/client4.go b/model/client4.go index cfe0f9244e..b569a8fa86 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8004,8 +8004,8 @@ func (c *Client4) ConfirmCustomerPayment(confirmRequest *ConfirmPaymentMethodReq return BuildResponse(r), nil } -func (c *Client4) RequestCloudTrial(email *StartCloudTrialRequest) (*Subscription, *Response, error) { - payload, err := json.Marshal(email) +func (c *Client4) RequestCloudTrial(cloudTrialRequest *StartCloudTrialRequest) (*Subscription, *Response, error) { + payload, err := json.Marshal(cloudTrialRequest) if err != nil { return nil, nil, NewAppError("RequestCloudTrial", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/model/config.go b/model/config.go index 886b7e5820..bccc3904a1 100644 --- a/model/config.go +++ b/model/config.go @@ -2747,6 +2747,16 @@ func (s *CloudSettings) SetDefaults() { } } +type ProductSettings struct { + EnablePublicSharedBoards *bool +} + +func (s *ProductSettings) SetDefaults() { + if s.EnablePublicSharedBoards == nil { + s.EnablePublicSharedBoards = NewBool(false) + } +} + type PluginState struct { Enable bool } @@ -3136,6 +3146,7 @@ type Config struct { DataRetentionSettings DataRetentionSettings MessageExportSettings MessageExportSettings JobSettings JobSettings + ProductSettings ProductSettings PluginSettings PluginSettings DisplaySettings DisplaySettings GuestAccountsSettings GuestAccountsSettings @@ -3235,6 +3246,7 @@ func (o *Config) SetDefaults() { o.ThemeSettings.SetDefaults() o.ClusterSettings.SetDefaults() o.PluginSettings.SetDefaults(o.LogSettings) + o.ProductSettings.SetDefaults() o.AnalyticsSettings.SetDefaults() o.ComplianceSettings.SetDefaults() o.LocalizationSettings.SetDefaults() diff --git a/model/feature_flags.go b/model/feature_flags.go index 04b2eab18d..87dbf6b42d 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -72,7 +72,12 @@ type FeatureFlags struct { PlanUpgradeButtonText string + // A/B Test on posting a welcome message + SendWelcomePost bool + PostPriority bool + + PeopleProduct bool } func (f *FeatureFlags) SetDefaults() { @@ -99,7 +104,9 @@ func (f *FeatureFlags) SetDefaults() { f.CallsEnabled = true f.BoardsProduct = false f.PlanUpgradeButtonText = "upgrade" + f.SendWelcomePost = true f.PostPriority = false + f.PeopleProduct = false } func (f *FeatureFlags) Plugins() map[string]string { diff --git a/model/insights.go b/model/insights.go index 438ca9402c..d66d48c283 100644 --- a/model/insights.go +++ b/model/insights.go @@ -4,6 +4,7 @@ package model import ( + "net/http" "time" ) @@ -260,6 +261,23 @@ func StartOfDayForTimeRange(timeRange string, location *time.Location) *time.Tim return &resultTime } +// GetStartOfDayForTimeRange 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 GetStartOfDayForTimeRange(timeRange string, location *time.Location) (*time.Time, *AppError) { + now := time.Now().In(location) + resultTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + switch timeRange { + case TimeRangeToday: + case TimeRange7Day: + resultTime = resultTime.Add(time.Hour * time.Duration(-144)) + case TimeRange28Day: + resultTime = resultTime.Add(time.Hour * time.Duration(-648)) + default: + return nil, NewAppError("GetStartOfDayForTimeRange", "model.insights.get_start_of_day_for_time_range.time_range.app_error", nil, "", http.StatusBadRequest) + } + return &resultTime, nil +} + // GetTopReactionListWithPagination adds a rank to each item in the given list of TopReaction and checks if there is // another page that can be fetched based on the given limit and offset. The given list of TopReaction is assumed to be // sorted by Count. Returns a TopReactionList. diff --git a/model/migration.go b/model/migration.go index 4958f80e85..e0e9ae2267 100644 --- a/model/migration.go +++ b/model/migration.go @@ -38,4 +38,5 @@ const ( MigrationKeyAddPlaybooksPermissions = "playbooks_permissions" MigrationKeyAddCustomUserGroupsPermissions = "custom_groups_permissions" MigrationKeyAddPlayboosksManageRolesPermissions = "playbooks_manage_roles" + MigrationKeyAddProductsBoardsPermissions = "products_boards" ) diff --git a/model/permission.go b/model/permission.go index 65f8edaca8..76cf07c872 100644 --- a/model/permission.go +++ b/model/permission.go @@ -354,6 +354,9 @@ var PermissionRunManageProperties *Permission var PermissionRunManageMembers *Permission var PermissionRunView *Permission +var PermissionSysconsoleReadProductsBoards *Permission +var PermissionSysconsoleWriteProductsBoards *Permission + // General permission that encompasses all system admin functions // in the future this could be broken up to allow access to some // admin functions but not others @@ -2070,6 +2073,19 @@ func initializePermissions() { PermissionScopeRun, } + PermissionSysconsoleReadProductsBoards = &Permission{ + "sysconsole_read_products_boards", + "", + "", + PermissionScopeSystem, + } + PermissionSysconsoleWriteProductsBoards = &Permission{ + "sysconsole_write_products_boards", + "", + "", + PermissionScopeSystem, + } + SysconsoleReadPermissions = []*Permission{ PermissionSysconsoleReadAboutEditionAndLicense, PermissionSysconsoleReadBilling, @@ -2125,6 +2141,7 @@ func initializePermissions() { PermissionSysconsoleReadExperimentalFeatures, PermissionSysconsoleReadExperimentalFeatureFlags, PermissionSysconsoleReadExperimentalBleve, + PermissionSysconsoleReadProductsBoards, } SysconsoleWritePermissions = []*Permission{ @@ -2182,6 +2199,7 @@ func initializePermissions() { PermissionSysconsoleWriteExperimentalFeatures, PermissionSysconsoleWriteExperimentalFeatureFlags, PermissionSysconsoleWriteExperimentalBleve, + PermissionSysconsoleWriteProductsBoards, } SystemScopedPermissionsMinusSysconsole := []*Permission{ diff --git a/model/post.go b/model/post.go index 4916779b52..3f9c5a630c 100644 --- a/model/post.go +++ b/model/post.go @@ -44,6 +44,7 @@ const ( PostTypeChannelRestored = "system_channel_restored" PostTypeEphemeral = "system_ephemeral" PostTypeChangeChannelPrivacy = "system_change_chan_privacy" + PostTypeWelcomePost = "system_welcome_post" PostTypeAddBotTeamsChannels = "add_bot_teams_channels" PostTypeSystemWarnMetricStatus = "warn_metric_status" PostTypeMe = "me" @@ -387,6 +388,7 @@ func (o *Post) IsValid(maxPostSize int) *AppError { PostTypeChangeChannelPrivacy, PostTypeAddBotTeamsChannels, PostTypeSystemWarnMetricStatus, + PostTypeWelcomePost, PostTypeMe: default: if !strings.HasPrefix(o.Type, PostCustomTypePrefix) { diff --git a/model/role.go b/model/role.go index 7823f133b4..ac3fa3204e 100644 --- a/model/role.go +++ b/model/role.go @@ -261,6 +261,7 @@ func init() { PermissionSysconsoleReadExperimentalFeatures.Id, PermissionSysconsoleReadExperimentalFeatureFlags.Id, PermissionSysconsoleReadExperimentalBleve.Id, + PermissionSysconsoleReadProductsBoards.Id, } SystemManagerDefaultPermissions = []string{ @@ -339,6 +340,8 @@ func init() { PermissionSysconsoleWriteIntegrationsBotAccounts.Id, PermissionSysconsoleWriteIntegrationsGif.Id, PermissionSysconsoleWriteIntegrationsCors.Id, + PermissionSysconsoleReadProductsBoards.Id, + PermissionSysconsoleWriteProductsBoards.Id, } SystemCustomGroupAdminDefaultPermissions = []string{ diff --git a/model/version.go b/model/version.go index be63d87971..4bfc461767 100644 --- a/model/version.go +++ b/model/version.go @@ -13,6 +13,7 @@ import ( // It should be maintained in chronological order with most current // release at the front of the list. var versions = []string{ + "7.5.0", "7.4.0", "7.3.0", "7.2.0", diff --git a/product/api.go b/product/api.go index 4b4ec8a93c..88eb9744e4 100644 --- a/product/api.go +++ b/product/api.go @@ -39,6 +39,7 @@ type PostService interface { // // The service shall be registered via app.PermissionKey service key. type PermissionService interface { + HasPermissionTo(userID string, permission *model.Permission) bool HasPermissionToTeam(userID, teamID string, permission *model.Permission) bool HasPermissionToChannel(askingUserID string, channelID string, permission *model.Permission) bool } diff --git a/services/slackimport/slackimport.go b/services/slackimport/slackimport.go index 03863b202a..5b3b15df9f 100644 --- a/services/slackimport/slackimport.go +++ b/services/slackimport/slackimport.go @@ -91,11 +91,11 @@ type Actions struct { CreateGroupChannel func(request.CTX, []string) (*model.Channel, *model.AppError) CreateChannel func(*model.Channel, bool) (*model.Channel, *model.AppError) DoUploadFile func(time.Time, string, string, string, string, []byte) (*model.FileInfo, *model.AppError) - GenerateThumbnailImage func(image.Image, string) - GeneratePreviewImage func(image.Image, string) + GenerateThumbnailImage func(image.Image, string, string) + GeneratePreviewImage func(image.Image, string, string) InvalidateAllCaches func() MaxPostSize func() int - PrepareImage func(fileData []byte) (image.Image, func(), error) + PrepareImage func(fileData []byte) (image.Image, string, func(), error) } // SlackImporter is a service that allows to import slack dumps into mattermost @@ -793,13 +793,13 @@ func (si *SlackImporter) oldImportFile(timestamp time.Time, file io.Reader, team } if fileInfo.IsImage() && !fileInfo.IsSvg() { - img, release, err := si.actions.PrepareImage(data) + img, imgType, release, err := si.actions.PrepareImage(data) if err != nil { return nil, err } defer release() - si.actions.GenerateThumbnailImage(img, fileInfo.ThumbnailPath) - si.actions.GeneratePreviewImage(img, fileInfo.PreviewPath) + si.actions.GenerateThumbnailImage(img, imgType, fileInfo.ThumbnailPath) + si.actions.GeneratePreviewImage(img, imgType, fileInfo.PreviewPath) } return fileInfo, nil diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index f19e5a4188..821b4c8a91 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -67,6 +67,7 @@ const ( TrackConfigBleve = "config_bleve" TrackConfigExport = "config_export" TrackFeatureFlags = "config_feature_flags" + TrackConfigProducts = "products" TrackPermissionsGeneral = "permissions_general" TrackPermissionsSystemScheme = "permissions_system_scheme" TrackPermissionsTeamSchemes = "permissions_team_schemes" @@ -827,6 +828,10 @@ func (ts *TelemetryService) trackConfig() { "retention_days": *cfg.ExportSettings.RetentionDays, }) + ts.SendTelemetry(TrackConfigProducts, map[string]any{ + "enable_public_shared_boards": *cfg.ProductSettings.EnablePublicSharedBoards, + }) + // Convert feature flags to map[string]any for sending flags := cfg.FeatureFlags.ToMap() interfaceFlags := make(map[string]any) diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index eb65b1f798..44f7d09e86 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -10801,6 +10801,24 @@ func (s *OpenTracingLayerUserStore) GetEtagForProfilesNotInTeam(teamID string) s return result } +func (s *OpenTracingLayerUserStore) GetFirstSystemAdminID() (string, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetFirstSystemAdminID") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.UserStore.GetFirstSystemAdminID() + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetForLogin") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 4efa7172ba..292924476a 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -12324,6 +12324,27 @@ func (s *RetryLayerUserStore) GetEtagForProfilesNotInTeam(teamID string) string } +func (s *RetryLayerUserStore) GetFirstSystemAdminID() (string, error) { + + tries := 0 + for { + result, err := s.UserStore.GetFirstSystemAdminID() + 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 *RetryLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) { tries := 0 diff --git a/store/searchlayer/user_layer.go b/store/searchlayer/user_layer.go index 2c63dabd3a..268b18cacc 100644 --- a/store/searchlayer/user_layer.go +++ b/store/searchlayer/user_layer.go @@ -37,7 +37,7 @@ func (s *SearchUserStore) deleteUserIndex(user *model.User) { func (s *SearchUserStore) Search(teamId, term string, options *model.UserSearchOptions) ([]*model.User, error) { for _, engine := range s.rootStore.searchEngine.GetActiveEngines() { if engine.IsSearchEnabled() { - listOfAllowedChannels, nErr := s.getListOfAllowedChannelsForTeam(teamId, options.ViewRestrictions) + listOfAllowedChannels, nErr := s.getListOfAllowedChannels(teamId, "", options.ViewRestrictions) if nErr != nil { mlog.Warn("Encountered error on Search.", mlog.String("search_engine", engine.GetName()), mlog.Err(nErr)) continue @@ -148,7 +148,7 @@ func (s *SearchUserStore) autocompleteUsersInChannelByEngine(engine searchengine return autocomplete, nil } -// getListOfAllowedChannelsForTeam return the list of allowed channels to search user based on the +// 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) @@ -159,7 +159,7 @@ func (s *SearchUserStore) autocompleteUsersInChannelByEngine(engine searchengine // - If we receive channels restrictions we get: // - If we don't have team id, we get those restricted channels (guest accounts and quick search) // - If we have a team id then we only return those restricted channels that belongs to that team -func (s *SearchUserStore) getListOfAllowedChannelsForTeam(teamId string, viewRestrictions *model.ViewUsersRestrictions) ([]string, error) { +func (s *SearchUserStore) getListOfAllowedChannels(teamId, channelId string, viewRestrictions *model.ViewUsersRestrictions) ([]string, error) { var listOfAllowedChannels []string if viewRestrictions == nil && teamId == "" { // nil return without error means all channels are allowed @@ -174,6 +174,20 @@ func (s *SearchUserStore) getListOfAllowedChannelsForTeam(teamId string, viewRes for _, channel := range channels { listOfAllowedChannels = append(listOfAllowedChannels, channel.Id) } + + if channelId != "" { + ch, err := s.rootStore.Channel().Get(channelId, true) + if err != nil { + return nil, errors.Wrapf(err, "failed to get channel with id: %s", channelId) + } + // Check if DM/GM channel, and add to the list. + // This is because GetTeamChannels does not return DM/GM channels. + // And since the channelId is passed from the API layer, it is already + // auth checked to confirm that the user has permission. + if ch.IsGroupOrDirect() { + listOfAllowedChannels = append(listOfAllowedChannels, channelId) + } + } return listOfAllowedChannels, nil } @@ -196,7 +210,7 @@ func (s *SearchUserStore) getListOfAllowedChannelsForTeam(teamId string, viewRes func (s *SearchUserStore) AutocompleteUsersInChannel(teamId, channelId, term string, options *model.UserSearchOptions) (*model.UserAutocompleteInChannel, error) { for _, engine := range s.rootStore.searchEngine.GetActiveEngines() { if engine.IsAutocompletionEnabled() { - listOfAllowedChannels, nErr := s.getListOfAllowedChannelsForTeam(teamId, options.ViewRestrictions) + listOfAllowedChannels, nErr := s.getListOfAllowedChannels(teamId, channelId, options.ViewRestrictions) if nErr != nil { mlog.Warn("Encountered error on AutocompleteUsersInChannel.", mlog.String("search_engine", engine.GetName()), mlog.Err(nErr)) continue @@ -205,6 +219,7 @@ func (s *SearchUserStore) AutocompleteUsersInChannel(teamId, channelId, term str return &model.UserAutocompleteInChannel{}, nil } options.ListOfAllowedChannels = listOfAllowedChannels + autocomplete, nErr := s.autocompleteUsersInChannelByEngine(engine, teamId, channelId, term, options) if nErr != nil { mlog.Warn("Encountered error on AutocompleteUsersInChannel.", mlog.String("search_engine", engine.GetName()), mlog.Err(nErr)) diff --git a/store/sqlstore/channel_store_categories.go b/store/sqlstore/channel_store_categories.go index 2a1799b01a..27317fe3cf 100644 --- a/store/sqlstore/channel_store_categories.go +++ b/store/sqlstore/channel_store_categories.go @@ -535,6 +535,13 @@ func (s SqlChannelStore) getSidebarCategoriesT(db dbSelecter, userId string, opt Select("SidebarCategories.*", "SidebarChannels.ChannelId"). From("SidebarCategories"). LeftJoin("SidebarChannels ON SidebarChannels.CategoryId=Id"). + InnerJoin("Teams ON Teams.Id=SidebarCategories.TeamId"). + InnerJoin("TeamMembers ON TeamMembers.TeamId=SidebarCategories.TeamId"). + Where(sq.And{ + sq.Eq{"TeamMembers.UserId": userId}, + sq.Eq{"TeamMembers.DeleteAt": 0}, + sq.Eq{"Teams.DeleteAt": 0}, + }). Where(sq.And{ sq.Eq{"SidebarCategories.UserId": userId}, }). diff --git a/store/sqlstore/file_info_store.go b/store/sqlstore/file_info_store.go index dbf47020e2..47d847b9d1 100644 --- a/store/sqlstore/file_info_store.go +++ b/store/sqlstore/file_info_store.go @@ -181,6 +181,7 @@ func (fs SqlFileInfoStore) Upsert(info *model.FileInfo) (*model.FileInfo, error) "Width": info.Width, "Height": info.Height, "HasPreviewImage": info.HasPreviewImage, + "MiniPreview": info.MiniPreview, "Content": info.Content, "RemoteId": info.RemoteId, }). diff --git a/store/sqlstore/user_store.go b/store/sqlstore/user_store.go index 0f724405d2..9eb8535d90 100644 --- a/store/sqlstore/user_store.go +++ b/store/sqlstore/user_store.go @@ -742,6 +742,8 @@ func (us SqlUserStore) GetProfilesInChannel(options *model.UserGetOptions) ([]*m query = query.Where("u.DeleteAt = 0") } + query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles, us.DriverName() == model.DatabaseDriverPostgres) + queryString, args, err := query.ToSql() if err != nil { return nil, errors.Wrap(err, "get_profiles_in_channel_tosql") @@ -1748,6 +1750,16 @@ func (us SqlUserStore) InferSystemInstallDate() (int64, error) { return createAt, nil } +func (us SqlUserStore) GetFirstSystemAdminID() (string, error) { + var id string + err := us.GetReplicaX().Get(&id, "SELECT Id FROM Users WHERE Roles LIKE ? ORDER BY CreateAt ASC LIMIT 1", "%system_admin%") + if err != nil { + return "", errors.Wrap(err, "failed to get first system admin") + } + + return id, nil +} + func (us SqlUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { users := []*model.User{} usersQuery, args, err := us.usersQuery. @@ -1793,7 +1805,16 @@ func (us SqlUserStore) GetUsersBatchForIndexing(startTime int64, startFileID str `). From("ChannelMembers cm"). Join("Channels c ON cm.ChannelId = c.Id"). - Where(sq.Eq{"c.Type": model.ChannelTypeOpen, "cm.UserId": userIds}). + Where(sq.And{ + sq.Eq{ + "cm.UserId": userIds, + }, + sq.Or{ + sq.Eq{"c.Type": model.ChannelTypeOpen}, + sq.Eq{"c.Type": model.ChannelTypeDirect}, + sq.Eq{"c.Type": model.ChannelTypeGroup}, + }, + }). ToSql() if err != nil { return nil, errors.Wrap(err, "GetUsersBatchForIndexing_ToSql2") diff --git a/store/store.go b/store/store.go index b917adbede..f7c33a938e 100644 --- a/store/store.go +++ b/store/store.go @@ -481,6 +481,7 @@ type UserStore interface { IsEmpty(excludeBots bool) (bool, error) GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error) InsertUsers(users []*model.User) error + GetFirstSystemAdminID() (string, error) } type BotStore interface { diff --git a/store/storetest/channel_store_categories.go b/store/storetest/channel_store_categories.go index faaa0dd75c..5b8e47bab2 100644 --- a/store/storetest/channel_store_categories.go +++ b/store/storetest/channel_store_categories.go @@ -28,13 +28,38 @@ func TestChannelStoreCategories(t *testing.T, ss store.Store, s SqlStore) { t.Run("SidebarCategoryDeadlock", func(t *testing.T) { testSidebarCategoryDeadlock(t, ss) }) } +func setupTeam(t *testing.T, ss store.Store, userIds ...string) *model.Team { + team, err := ss.Team().Save(&model.Team{ + DisplayName: "Name", + Name: NewTestId(), + Email: MakeEmail(), + Type: model.TeamOpen, + }) + assert.NoError(t, err) + + members := make([]*model.TeamMember, 0, len(userIds)) + for _, userId := range userIds { + members = append(members, &model.TeamMember{ + TeamId: team.Id, + UserId: userId, + }) + } + if len(members) > 0 { + _, err = ss.Team().SaveMultipleMembers(members, len(userIds)+1) + assert.NoError(t, err) + } + + return team +} + func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { t.Run("should create initial favorites/channels/DMs categories", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } @@ -45,25 +70,25 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) assert.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) - res2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + res2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) assert.NoError(t, err) assert.Equal(t, res, res2) }) t.Run("should create initial favorites/channels/DMs categories for multiple users", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + userId2 := model.NewId() + + team := setupTeam(t, ss, userId, userId2) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - userId2 := model.NewId() - res, nErr = ss.Channel().CreateInitialSidebarCategories(userId2, opts) assert.NoError(t, nErr) assert.Len(t, res.Categories, 3) @@ -71,26 +96,27 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) assert.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) - res2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId2, teamId) + res2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId2, team.Id) assert.NoError(t, err) assert.Equal(t, res, res2) }) t.Run("should create initial favorites/channels/DMs categories on different teams", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) + team2 := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - teamId2 := model.NewId() opts = &store.SidebarCategorySearchOpts{ - TeamID: teamId2, + TeamID: team2.Id, ExcludeTeam: false, } res, nErr = ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -100,24 +126,25 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) assert.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) - res2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId2) + res2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team2.Id) assert.NoError(t, err) assert.Equal(t, res, res2) }) t.Run("shouldn't create additional categories when ones already exist", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Equal(t, res, initialCategories) @@ -126,14 +153,15 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { assert.NoError(t, nErr) assert.NotEmpty(t, res) - res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) assert.NoError(t, err) assert.Equal(t, initialCategories.Categories, res.Categories) }) t.Run("shouldn't create additional categories when ones already exist even when ran simultaneously", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) var wg sync.WaitGroup @@ -144,7 +172,7 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { defer wg.Done() opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } _, _ = ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -153,18 +181,19 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { wg.Wait() - res, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + res, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) assert.NoError(t, err) assert.Len(t, res.Categories, 3) }) t.Run("should populate the Favorites category with regular channels", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) // Set up two channels, one favorited and one not channel1, nErr := ss.Channel().Save(&model.Channel{ - TeamId: teamId, + TeamId: team.Id, Type: model.ChannelTypeOpen, Name: "channel1", }, 1000) @@ -177,7 +206,7 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { require.NoError(t, err) channel2, nErr := ss.Channel().Save(&model.Channel{ - TeamId: teamId, + TeamId: team.Id, Type: model.ChannelTypeOpen, Name: "channel2", }, 1000) @@ -201,7 +230,7 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { // Create the categories opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } categories, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -213,18 +242,19 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, []string{channel2.Id}, categories.Categories[1].Channels) // Get and check the categories for channels - categories2, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories2, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, nErr) require.Equal(t, categories, categories2) }) t.Run("should populate the Favorites category in alphabetical order", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) // Set up two channels channel1, nErr := ss.Channel().Save(&model.Channel{ - TeamId: teamId, + TeamId: team.Id, Type: model.ChannelTypeOpen, Name: "channel1", DisplayName: "zebra", @@ -238,7 +268,7 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { require.NoError(t, err) channel2, nErr := ss.Channel().Save(&model.Channel{ - TeamId: teamId, + TeamId: team.Id, Type: model.ChannelTypeOpen, Name: "channel2", DisplayName: "aardvark", @@ -269,7 +299,7 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { // Create the categories opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } categories, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -279,14 +309,15 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, []string{channel2.Id, channel1.Id}, categories.Categories[0].Channels) // Get and check the categories for channels - categories2, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories2, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, nErr) require.Equal(t, categories, categories2) }) t.Run("should populate the Favorites category with DMs and GMs", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) otherUserId1 := model.NewId() otherUserId2 := model.NewId() @@ -336,7 +367,7 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { // Create the categories opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } categories, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -348,19 +379,20 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, []string{dmChannel2.Id}, categories.Categories[2].Channels) // Get and check the categories for channels - categories2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Equal(t, categories, categories2) }) t.Run("should not populate the Favorites category with channels from other teams", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() - teamId2 := model.NewId() + + team := setupTeam(t, ss, userId) + team2 := setupTeam(t, ss, userId) // Set up a channel on another team and favorite it channel1, nErr := ss.Channel().Save(&model.Channel{ - TeamId: teamId2, + TeamId: team2.Id, Type: model.ChannelTypeOpen, Name: "channel1", }, 1000) @@ -384,7 +416,7 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { // Create the categories opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } categories, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -396,7 +428,7 @@ func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, []string{}, categories.Categories[1].Channels) // Get and check the categories for channels - categories2, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories2, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, nErr) require.Equal(t, categories, categories2) }) @@ -465,10 +497,11 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { t.Run("should place the new category second if Favorites comes first", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -476,7 +509,7 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { require.NotEmpty(t, res) // Create the category - created, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{ + created, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ SidebarCategory: model.SidebarCategory{ DisplayName: model.NewId(), }, @@ -484,7 +517,7 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { require.NoError(t, err) // Confirm that it comes second - res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Len(t, res.Categories, 4) assert.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) @@ -494,10 +527,11 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { t.Run("should place the new category first if Favorites is not first", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -505,12 +539,12 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { require.NotEmpty(t, res) // Re-arrange the categories so that Favorites comes last - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Len(t, categories.Categories, 3) require.Equal(t, model.SidebarCategoryFavorites, categories.Categories[0].Type) - err = ss.Channel().UpdateSidebarCategoryOrder(userId, teamId, []string{ + err = ss.Channel().UpdateSidebarCategoryOrder(userId, team.Id, []string{ categories.Categories[1].Id, categories.Categories[2].Id, categories.Categories[0].Id, @@ -518,7 +552,7 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { require.NoError(t, err) // Create the category - created, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{ + created, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ SidebarCategory: model.SidebarCategory{ DisplayName: model.NewId(), }, @@ -526,7 +560,7 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { require.NoError(t, err) // Confirm that it comes first - res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Len(t, res.Categories, 4) assert.Equal(t, model.SidebarCategoryCustom, res.Categories[0].Type) @@ -535,10 +569,10 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { t.Run("should create the category with its channels", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -548,19 +582,19 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { // Create some channels channel1, err := ss.Channel().Save(&model.Channel{ Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, Name: model.NewId(), }, 100) require.NoError(t, err) channel2, err := ss.Channel().Save(&model.Channel{ Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, Name: model.NewId(), }, 100) require.NoError(t, err) // Create the category - created, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{ + created, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ SidebarCategory: model.SidebarCategory{ DisplayName: model.NewId(), }, @@ -577,17 +611,17 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { t.Run("should remove any channels from their previous categories", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Len(t, categories.Categories, 3) @@ -599,13 +633,13 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { // Create some channels channel1, nErr := ss.Channel().Save(&model.Channel{ Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, Name: model.NewId(), }, 100) require.NoError(t, nErr) channel2, nErr := ss.Channel().Save(&model.Channel{ Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, Name: model.NewId(), }, 100) require.NoError(t, nErr) @@ -613,14 +647,14 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { // Assign them to categories favoritesCategory.Channels = []string{channel1.Id} channelsCategory.Channels = []string{channel2.Id} - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ favoritesCategory, channelsCategory, }) require.NoError(t, err) // Create the category - created, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{ + created, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ SidebarCategory: model.SidebarCategory{ DisplayName: model.NewId(), }, @@ -643,14 +677,14 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { t.Run("should return a custom category with its Channels field set", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) channelId1 := model.NewId() channelId2 := model.NewId() channelId3 := model.NewId() opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -658,10 +692,10 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { require.NotEmpty(t, res) // Create a category and assign some channels to it - created, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{ + created, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ SidebarCategory: model.SidebarCategory{ UserId: userId, - TeamId: teamId, + TeamId: team.Id, DisplayName: model.NewId(), }, Channels: []string{channelId1, channelId2, channelId3}, @@ -680,18 +714,18 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { t.Run("should return any orphaned channels with the Channels category", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories and find the channels category opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) channelsCategory := categories.Categories[1] @@ -701,7 +735,7 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { channel1, nErr := ss.Channel().Save(&model.Channel{ Name: "channel1", DisplayName: "DEF", - TeamId: teamId, + TeamId: team.Id, Type: model.ChannelTypePrivate, }, 10) require.NoError(t, nErr) @@ -715,7 +749,7 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { channel2, nErr := ss.Channel().Save(&model.Channel{ Name: "channel2", DisplayName: "ABC", - TeamId: teamId, + TeamId: team.Id, Type: model.ChannelTypeOpen, }, 10) require.NoError(t, nErr) @@ -748,18 +782,18 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { t.Run("shouldn't return orphaned channels on another team with the Channels category", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories and find the channels category opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Equal(t, model.SidebarCategoryChannels, categories.Categories[1].Type) @@ -791,10 +825,10 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { t.Run("shouldn't return non-orphaned channels with the Channels category", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } // Create the initial categories and find the channels category @@ -802,7 +836,7 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) favoritesCategory := categories.Categories[0] @@ -814,7 +848,7 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { channel1, nErr := ss.Channel().Save(&model.Channel{ Name: "channel1", DisplayName: "DEF", - TeamId: teamId, + TeamId: team.Id, Type: model.ChannelTypePrivate, }, 10) require.NoError(t, nErr) @@ -828,7 +862,7 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { channel2, nErr := ss.Channel().Save(&model.Channel{ Name: "channel2", DisplayName: "ABC", - TeamId: teamId, + TeamId: team.Id, Type: model.ChannelTypeOpen, }, 10) require.NoError(t, nErr) @@ -840,7 +874,7 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { require.NoError(t, nErr) // And assign one to another category - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory.SidebarCategory, Channels: []string{channel2.Id}, @@ -858,18 +892,18 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { t.Run("should return any orphaned DM channels with the Direct Messages category", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories and find the DMs category opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Equal(t, model.SidebarCategoryDirectMessages, categories.Categories[2].Type) @@ -903,18 +937,18 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { t.Run("should return any orphaned GM channels with the Direct Messages category", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories and find the DMs category opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Equal(t, model.SidebarCategoryDirectMessages, categories.Categories[2].Type) @@ -945,18 +979,18 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { t.Run("should return orphaned DM channels in the DMs category which are in a custom category on another team", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories and find the DMs category opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Equal(t, model.SidebarCategoryDirectMessages, categories.Categories[2].Type) @@ -981,19 +1015,19 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { require.NoError(t, nErr) // Create another team and assign the DM to a custom category on that team - otherTeamId := model.NewId() + otherTeam := setupTeam(t, ss, userId) opts = &store.SidebarCategorySearchOpts{ - TeamID: otherTeamId, + TeamID: otherTeam.Id, ExcludeTeam: false, } res, nErr = ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - _, err = ss.Channel().CreateSidebarCategory(userId, otherTeamId, &model.SidebarCategoryWithChannels{ + _, err = ss.Channel().CreateSidebarCategory(userId, otherTeam.Id, &model.SidebarCategoryWithChannels{ SidebarCategory: model.SidebarCategory{ UserId: userId, - TeamId: teamId, + TeamId: team.Id, }, Channels: []string{dmChannel.Id}, }) @@ -1011,10 +1045,10 @@ func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { func testGetSidebarCategories(t *testing.T, ss store.Store) { t.Run("should return channels in the same order between different ways of getting categories", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -1027,7 +1061,7 @@ func testGetSidebarCategories(t *testing.T, ss store.Store) { model.NewId(), } - newCategory, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{ + newCategory, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ Channels: channelIds, }) require.NoError(t, err) @@ -1036,7 +1070,7 @@ func testGetSidebarCategories(t *testing.T, ss store.Store) { gotCategory, err := ss.Channel().GetSidebarCategory(newCategory.Id) require.NoError(t, err) - res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Len(t, res.Categories, 4) @@ -1047,23 +1081,83 @@ func testGetSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, gotCategory.Channels, res.Categories[1].Channels) assert.Equal(t, channelIds, res.Categories[1].Channels) }) + t.Run("should not return categories for teams deleted, or no longer a member", func(t *testing.T) { + userId := model.NewId() + + teamMember1 := setupTeam(t, ss, userId) + teamMember2 := setupTeam(t, ss, userId) + teamDeleted := setupTeam(t, ss, userId) + teamDeleted.DeleteAt = model.GetMillis() + ss.Team().Update(teamDeleted) + teamNotMember := setupTeam(t, ss) + teamDeletedMember := setupTeam(t, ss, userId) + + members, err := ss.Team().GetMembersByIds(teamDeletedMember.Id, []string{userId}, nil) + require.NoError(t, err) + require.NotEmpty(t, members) + member := members[0] + member.DeleteAt = model.GetMillis() + ss.Team().UpdateMember(member) + + teamIds := []string{ + teamMember1.Id, + teamMember2.Id, + teamDeleted.Id, + teamNotMember.Id, + teamDeletedMember.Id, + } + + for _, id := range teamIds { + res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, &store.SidebarCategorySearchOpts{TeamID: id}) + require.NoError(t, nErr) + require.NotEmpty(t, res) + } + + opts := &store.SidebarCategorySearchOpts{ + TeamID: teamMember1.Id, + ExcludeTeam: false, + } + + // Team member and not exclude + res, err := ss.Channel().GetSidebarCategories(userId, opts) + require.NoError(t, err) + assert.Equal(t, 3, len(res.Categories)) + + // No team member and not exclude + opts.TeamID = teamDeleted.Id + res, err = ss.Channel().GetSidebarCategories(userId, opts) + require.NoError(t, err) + assert.Equal(t, 0, len(res.Categories)) + + // No team member and exclude + opts.ExcludeTeam = true + res, err = ss.Channel().GetSidebarCategories(userId, opts) + require.NoError(t, err) + assert.Equal(t, 6, len(res.Categories)) + + // Team member and exclude + opts.TeamID = teamMember1.Id + res, err = ss.Channel().GetSidebarCategories(userId, opts) + require.NoError(t, err) + assert.Equal(t, 3, len(res.Categories)) + }) } func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("ensure the query to update SidebarCategories hasn't been polluted by UpdateSidebarCategoryOrder", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, err := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, err) require.NotEmpty(t, res) - initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) favoritesCategory := initialCategories.Categories[0] @@ -1071,7 +1165,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { dmsCategory := initialCategories.Categories[2] // And then update one of them - updated, _, err := ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + updated, _, err := ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ channelsCategory, }) require.NoError(t, err) @@ -1079,7 +1173,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, "Channels", updated[0].DisplayName) // And then reorder the categories - err = ss.Channel().UpdateSidebarCategoryOrder(userId, teamId, []string{dmsCategory.Id, favoritesCategory.Id, channelsCategory.Id}) + err = ss.Channel().UpdateSidebarCategoryOrder(userId, team.Id, []string{dmsCategory.Id, favoritesCategory.Id, channelsCategory.Id}) require.NoError(t, err) // Which somehow blanks out stuff because ??? @@ -1090,18 +1184,18 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("categories should be returned in their original order", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, err := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, err) require.NotEmpty(t, res) - initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) favoritesCategory := initialCategories.Categories[0] @@ -1109,7 +1203,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { dmsCategory := initialCategories.Categories[2] // And then update them - updatedCategories, _, err := ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + updatedCategories, _, err := ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ favoritesCategory, channelsCategory, dmsCategory, @@ -1122,24 +1216,24 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("should silently fail to update read only fields", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) favoritesCategory := initialCategories.Categories[0] channelsCategory := initialCategories.Categories[1] dmsCategory := initialCategories.Categories[2] - customCategory, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{}) + customCategory, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{}) require.NoError(t, err) categoriesToUpdate := []*model.SidebarCategoryWithChannels{ @@ -1177,7 +1271,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { }, } - updatedCategories, _, err := ss.Channel().UpdateSidebarCategories(userId, teamId, categoriesToUpdate) + updatedCategories, _, err := ss.Channel().UpdateSidebarCategories(userId, team.Id, categoriesToUpdate) assert.NoError(t, err) assert.NotEqual(t, "Favorites", categoriesToUpdate[0].DisplayName) @@ -1192,18 +1286,18 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("should add and remove favorites preferences based on the Favorites category", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories and find the favorites category opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) favoritesCategory := categories.Categories[0] @@ -1213,7 +1307,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { channel, nErr := ss.Channel().Save(&model.Channel{ Name: "channel", Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, }, 10) require.NoError(t, nErr) _, nErr = ss.Channel().SaveMember(&model.ChannelMember{ @@ -1224,7 +1318,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { require.NoError(t, nErr) // Assign it to favorites - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory.SidebarCategory, Channels: []string{channel.Id}, @@ -1241,7 +1335,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { channelsCategory := categories.Categories[1] require.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: channelsCategory.SidebarCategory, Channels: []string{channel.Id}, @@ -1257,18 +1351,18 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("should add and remove favorites preferences for DMs", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create the initial categories and find the favorites category opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) favoritesCategory := categories.Categories[0] @@ -1294,7 +1388,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.NoError(t, nErr) // Assign it to favorites - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory.SidebarCategory, Channels: []string{dmChannel.Id}, @@ -1311,7 +1405,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { dmsCategory := categories.Categories[2] require.Equal(t, model.SidebarCategoryDirectMessages, dmsCategory.Type) - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: dmsCategory.SidebarCategory, Channels: []string{dmChannel.Id}, @@ -1327,33 +1421,33 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("should add and remove favorites preferences, even if the channel is already favorited in preferences", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() - teamId2 := model.NewId() + team := setupTeam(t, ss, userId) + team2 := setupTeam(t, ss, userId) // Create the initial categories and find the favorites categories in each team opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) favoritesCategory := categories.Categories[0] require.Equal(t, model.SidebarCategoryFavorites, favoritesCategory.Type) opts = &store.SidebarCategorySearchOpts{ - TeamID: teamId2, + TeamID: team2.Id, ExcludeTeam: false, } res, nErr = ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId2) + categories2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team2.Id) require.NoError(t, err) favoritesCategory2 := categories2.Categories[0] @@ -1379,7 +1473,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.NoError(t, nErr) // Assign it to favorites on the first team. The favorites preference gets set for all teams. - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory.SidebarCategory, Channels: []string{dmChannel.Id}, @@ -1393,7 +1487,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, "true", res2.Value) // Assign it to favorites on the second team. The favorites preference is already set. - updated, _, err := ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + updated, _, err := ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory2.SidebarCategory, Channels: []string{dmChannel.Id}, @@ -1408,7 +1502,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, "true", res2.Value) // Remove it from favorites on the first team. This clears the favorites preference for all teams. - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory.SidebarCategory, Channels: []string{}, @@ -1421,7 +1515,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Nil(t, res2) // Remove it from favorites on the second team. The favorites preference was already deleted. - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId2, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory2.SidebarCategory, Channels: []string{}, @@ -1436,18 +1530,19 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("should not affect other users' favorites preferences", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + userId2 := model.NewId() + team := setupTeam(t, ss, userId, userId2) // Create the initial categories and find the favorites category opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + categories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) favoritesCategory := categories.Categories[0] @@ -1456,13 +1551,11 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { require.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) // Create the other users' categories - userId2 := model.NewId() - res, nErr = ss.Channel().CreateInitialSidebarCategories(userId2, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - categories2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId2, teamId) + categories2, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId2, team.Id) require.NoError(t, err) favoritesCategory2 := categories2.Categories[0] @@ -1474,7 +1567,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { channel, nErr := ss.Channel().Save(&model.Channel{ Name: "channel", Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, }, 10) require.NoError(t, nErr) _, nErr = ss.Channel().SaveMember(&model.ChannelMember{ @@ -1491,7 +1584,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { require.NoError(t, nErr) // Have user1 favorite it - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory.SidebarCategory, Channels: []string{channel.Id}, @@ -1513,7 +1606,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Nil(t, res2) // And user2 favorite it - _, _, err = ss.Channel().UpdateSidebarCategories(userId2, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId2, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: favoritesCategory2.SidebarCategory, Channels: []string{channel.Id}, @@ -1536,7 +1629,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, "true", res2.Value) // And then user1 unfavorite it - _, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: channelsCategory.SidebarCategory, Channels: []string{channel.Id}, @@ -1558,7 +1651,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, "true", res2.Value) // And finally user2 favorite it - _, _, err = ss.Channel().UpdateSidebarCategories(userId2, teamId, []*model.SidebarCategoryWithChannels{ + _, _, err = ss.Channel().UpdateSidebarCategories(userId2, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: channelsCategory2.SidebarCategory, Channels: []string{channel.Id}, @@ -1581,13 +1674,13 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("channels removed from Channels or DMs categories should be re-added", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Create some channels channel, nErr := ss.Channel().Save(&model.Channel{ Name: "channel", Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, }, 10) require.NoError(t, nErr) _, err := ss.Channel().SaveMember(&model.ChannelMember{ @@ -1615,7 +1708,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { require.NoError(t, nErr) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -1623,7 +1716,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { require.NotEmpty(t, res) // And some categories - initialCategories, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + initialCategories, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, nErr) channelsCategory := initialCategories.Categories[1] @@ -1644,7 +1737,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { }, } - updatedCategories, _, nErr := ss.Channel().UpdateSidebarCategories(userId, teamId, categoriesToUpdate) + updatedCategories, _, nErr := ss.Channel().UpdateSidebarCategories(userId, team.Id, categoriesToUpdate) assert.NoError(t, nErr) // The channels should still exist in the category because they would otherwise be orphaned @@ -1654,7 +1747,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("should be able to move DMs into and out of custom categories", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) otherUserId := model.NewId() dmChannel, nErr := ss.Channel().SaveDirectChannel( @@ -1674,7 +1767,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { require.NoError(t, nErr) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) @@ -1682,14 +1775,14 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { require.NotEmpty(t, res) // The DM should start in the DMs category - initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) dmsCategory := initialCategories.Categories[2] require.Equal(t, []string{dmChannel.Id}, dmsCategory.Channels) // Now move the DM into a custom category - customCategory, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{}) + customCategory, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{}) require.NoError(t, err) categoriesToUpdate := []*model.SidebarCategoryWithChannels{ @@ -1703,7 +1796,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { }, } - updatedCategories, _, err := ss.Channel().UpdateSidebarCategories(userId, teamId, categoriesToUpdate) + updatedCategories, _, err := ss.Channel().UpdateSidebarCategories(userId, team.Id, categoriesToUpdate) assert.NoError(t, err) assert.Equal(t, dmsCategory.Id, updatedCategories[0].Id) assert.Equal(t, []string{}, updatedCategories[0].Channels) @@ -1730,7 +1823,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { }, } - updatedCategories, _, err = ss.Channel().UpdateSidebarCategories(userId, teamId, categoriesToUpdate) + updatedCategories, _, err = ss.Channel().UpdateSidebarCategories(userId, team.Id, categoriesToUpdate) assert.NoError(t, err) assert.Equal(t, dmsCategory.Id, updatedCategories[0].Id) assert.Equal(t, []string{dmChannel.Id}, updatedCategories[0].Channels) @@ -1748,13 +1841,13 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("should successfully move channels between categories", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Join a channel channel, nErr := ss.Channel().Save(&model.Channel{ Name: "channel", Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, }, 10) require.NoError(t, nErr) _, err := ss.Channel().SaveMember(&model.ChannelMember{ @@ -1766,24 +1859,24 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { // And then create the initial categories so that it includes the channel opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - initialCategories, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + initialCategories, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, nErr) channelsCategory := initialCategories.Categories[1] require.Equal(t, []string{channel.Id}, channelsCategory.Channels) - customCategory, nErr := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{}) + customCategory, nErr := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{}) require.NoError(t, nErr) // Move the channel one way - updatedCategories, _, nErr := ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + updatedCategories, _, nErr := ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: channelsCategory.SidebarCategory, Channels: []string{}, @@ -1799,7 +1892,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, []string{channel.Id}, updatedCategories[1].Channels) // And then the other - updatedCategories, _, nErr = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + updatedCategories, _, nErr = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: channelsCategory.SidebarCategory, Channels: []string{channel.Id}, @@ -1816,13 +1909,13 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { t.Run("should correctly return the original categories that were modified", func(t *testing.T) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) // Join a channel channel, nErr := ss.Channel().Save(&model.Channel{ Name: "channel", Type: model.ChannelTypeOpen, - TeamId: teamId, + TeamId: team.Id, }, 10) require.NoError(t, nErr) _, err := ss.Channel().SaveMember(&model.ChannelMember{ @@ -1834,20 +1927,20 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { // And then create the initial categories so that Channels includes the channel opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - initialCategories, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + initialCategories, nErr := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, nErr) channelsCategory := initialCategories.Categories[1] require.Equal(t, []string{channel.Id}, channelsCategory.Channels) - customCategory, nErr := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{ + customCategory, nErr := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ SidebarCategory: model.SidebarCategory{ DisplayName: "originalName", }, @@ -1855,7 +1948,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { require.NoError(t, nErr) // Rename the custom category - updatedCategories, originalCategories, nErr := ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + updatedCategories, originalCategories, nErr := ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: model.SidebarCategory{ Id: customCategory.Id, @@ -1869,7 +1962,7 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { assert.Equal(t, "updatedName", updatedCategories[0].DisplayName) // Move a channel - updatedCategories, originalCategories, nErr = ss.Channel().UpdateSidebarCategories(userId, teamId, []*model.SidebarCategoryWithChannels{ + updatedCategories, originalCategories, nErr = ss.Channel().UpdateSidebarCategories(userId, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: channelsCategory.SidebarCategory, Channels: []string{}, @@ -1893,21 +1986,21 @@ func testUpdateSidebarCategories(t *testing.T, ss store.Store) { func setupInitialSidebarCategories(t *testing.T, ss store.Store) (string, string) { userId := model.NewId() - teamId := model.NewId() + team := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId, + TeamID: team.Id, ExcludeTeam: false, } res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, nErr) require.NotEmpty(t, res) - res, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId) + res, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) require.NoError(t, err) require.Len(t, res.Categories, 3) - return userId, teamId + return userId, team.Id } func testClearSidebarOnTeamLeave(t *testing.T, ss store.Store, s SqlStore) { @@ -2020,17 +2113,17 @@ func testClearSidebarOnTeamLeave(t *testing.T, ss store.Store, s SqlStore) { } // Create a second team and set up the sidebar categories for it - teamId2 := model.NewId() + team2 := setupTeam(t, ss, userId) opts := &store.SidebarCategorySearchOpts{ - TeamID: teamId2, + TeamID: team2.Id, ExcludeTeam: false, } res, err := ss.Channel().CreateInitialSidebarCategories(userId, opts) require.NoError(t, err) require.NotEmpty(t, res) - res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId2) + res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team2.Id) require.NoError(t, err) require.Len(t, res.Categories, 3) @@ -2053,12 +2146,12 @@ func testClearSidebarOnTeamLeave(t *testing.T, ss store.Store, s SqlStore) { // Do the same on the second team channel2, nErr := ss.Channel().Save(&model.Channel{ Name: model.NewId(), - TeamId: teamId2, + TeamId: team2.Id, Type: model.ChannelTypeOpen, }, 1000) require.NoError(t, nErr) - _, err = ss.Channel().CreateSidebarCategory(userId, teamId2, &model.SidebarCategoryWithChannels{ + _, err = ss.Channel().CreateSidebarCategory(userId, team2.Id, &model.SidebarCategoryWithChannels{ Channels: []string{channel2.Id, dmChannel1.Id}, }) require.NoError(t, err) @@ -2087,7 +2180,7 @@ func testClearSidebarOnTeamLeave(t *testing.T, ss store.Store, s SqlStore) { assert.Equal(t, int64(2), count) // Confirm that the categories on the second team are unchanged - res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, teamId2) + res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team2.Id) require.NoError(t, err) assert.Len(t, res.Categories, 4) @@ -2267,13 +2360,13 @@ func testUpdateSidebarChannelsByPreferences(t *testing.T, ss store.Store) { // to catch a bug. func testSidebarCategoryDeadlock(t *testing.T, ss store.Store) { userID := model.NewId() - teamID := model.NewId() + team := setupTeam(t, ss, userID) // Join a channel channel, err := ss.Channel().Save(&model.Channel{ Name: "channel", Type: model.ChannelTypeOpen, - TeamId: teamID, + TeamId: team.Id, }, 10) require.NoError(t, err) _, err = ss.Channel().SaveMember(&model.ChannelMember{ @@ -2285,20 +2378,20 @@ func testSidebarCategoryDeadlock(t *testing.T, ss store.Store) { // And then create the initial categories so that it includes the channel opts := &store.SidebarCategorySearchOpts{ - TeamID: teamID, + TeamID: team.Id, ExcludeTeam: false, } res, err := ss.Channel().CreateInitialSidebarCategories(userID, opts) require.NoError(t, err) require.NotEmpty(t, res) - initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userID, teamID) + initialCategories, err := ss.Channel().GetSidebarCategoriesForTeamForUser(userID, team.Id) require.NoError(t, err) channelsCategory := initialCategories.Categories[1] require.Equal(t, []string{channel.Id}, channelsCategory.Channels) - customCategory, err := ss.Channel().CreateSidebarCategory(userID, teamID, &model.SidebarCategoryWithChannels{}) + customCategory, err := ss.Channel().CreateSidebarCategory(userID, team.Id, &model.SidebarCategoryWithChannels{}) require.NoError(t, err) var wg sync.WaitGroup @@ -2306,7 +2399,7 @@ func testSidebarCategoryDeadlock(t *testing.T, ss store.Store) { go func() { defer wg.Done() - _, _, err := ss.Channel().UpdateSidebarCategories(userID, teamID, []*model.SidebarCategoryWithChannels{ + _, _, err := ss.Channel().UpdateSidebarCategories(userID, team.Id, []*model.SidebarCategoryWithChannels{ { SidebarCategory: channelsCategory.SidebarCategory, Channels: []string{}, diff --git a/store/storetest/file_info_store.go b/store/storetest/file_info_store.go index 1dc72b0536..4df67ae24a 100644 --- a/store/storetest/file_info_store.go +++ b/store/storetest/file_info_store.go @@ -28,6 +28,7 @@ func TestFileInfoStore(t *testing.T, ss store.Store) { t.Run("FileInfoPermanentDelete", func(t *testing.T) { testFileInfoPermanentDelete(t, ss) }) t.Run("FileInfoPermanentDeleteBatch", func(t *testing.T) { testFileInfoPermanentDeleteBatch(t, ss) }) t.Run("FileInfoPermanentDeleteByUser", func(t *testing.T) { testFileInfoPermanentDeleteByUser(t, ss) }) + t.Run("FileInfoUpdateMinipreview", func(t *testing.T) { testFileInfoUpdateMinipreview(t, ss) }) t.Run("GetFilesBatchForIndexing", func(t *testing.T) { testFileInfoStoreGetFilesBatchForIndexing(t, ss) }) t.Run("CountAll", func(t *testing.T) { testFileInfoStoreCountAll(t, ss) }) t.Run("GetStorageUsage", func(t *testing.T) { testFileInfoGetStorageUsage(t, ss) }) @@ -608,6 +609,39 @@ func testFileInfoPermanentDeleteByUser(t *testing.T, ss store.Store) { require.NoError(t, err) } +func testFileInfoUpdateMinipreview(t *testing.T, ss store.Store) { + info := &model.FileInfo{ + CreatorId: model.NewId(), + Path: "image.png", + } + + info, err := ss.FileInfo().Save(info) + require.NoError(t, err) + require.NotEqual(t, len(info.Id), 0) + + defer func() { + ss.FileInfo().PermanentDelete(info.Id) + }() + + rinfo, err := ss.FileInfo().Get(info.Id) + require.NoError(t, err) + require.Equal(t, info.Id, rinfo.Id) + require.Nil(t, rinfo.MiniPreview) + + miniPreview := []byte{0x0, 0x1, 0x2} + + rinfo.MiniPreview = &miniPreview + + rinfo, err = ss.FileInfo().Upsert(rinfo) + require.NoError(t, err) + require.Equal(t, info.Id, rinfo.Id) + + tinfo, err := ss.FileInfo().Get(info.Id) + require.NoError(t, err) + require.Equal(t, info.Id, tinfo.Id) + require.Equal(t, *tinfo.MiniPreview, miniPreview) +} + func testFileInfoStoreGetFilesBatchForIndexing(t *testing.T, ss store.Store) { c1 := &model.Channel{} c1.TeamId = model.NewId() diff --git a/store/storetest/mocks/UserStore.go b/store/storetest/mocks/UserStore.go index ea0fdcfcc0..290b37dae7 100644 --- a/store/storetest/mocks/UserStore.go +++ b/store/storetest/mocks/UserStore.go @@ -569,6 +569,27 @@ func (_m *UserStore) GetEtagForProfilesNotInTeam(teamID string) string { return r0 } +// GetFirstSystemAdminID provides a mock function with given fields: +func (_m *UserStore) GetFirstSystemAdminID() (string, error) { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetForLogin provides a mock function with given fields: loginID, allowSignInWithUsername, allowSignInWithEmail func (_m *UserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) { ret := _m.Called(loginID, allowSignInWithUsername, allowSignInWithEmail) diff --git a/store/storetest/user_store.go b/store/storetest/user_store.go index 98d6f2bc4f..c938056249 100644 --- a/store/storetest/user_store.go +++ b/store/storetest/user_store.go @@ -94,6 +94,7 @@ func TestUserStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("ResetLastPictureUpdate", func(t *testing.T) { testUserStoreResetLastPictureUpdate(t, ss) }) t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, ss) }) t.Run("GetUsersWithInvalidEmails", func(t *testing.T) { testGetUsersWithInvalidEmails(t, ss) }) + t.Run("GetFirstSystemAdminID", func(t *testing.T) { testUserStoreGetFirstSystemAdminID(t, ss) }) } func testUserStoreSave(t *testing.T, ss store.Store) { @@ -1002,6 +1003,36 @@ func testUserStoreGetProfilesInChannel(t *testing.T, ss store.Store) { require.NoError(t, err) assert.Equal(t, []*model.User{sanitized(u1)}, users) }) + + t.Run("Filter by channel members and channel admins", func(t *testing.T) { + // save admin for c1 + user2Admin, err := ss.User().Save(&model.User{ + Email: MakeEmail(), + Username: "bbb" + model.NewId(), + }) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(user2Admin.Id)) }() + _, nErr = ss.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: user2Admin.Id}, -1) + require.NoError(t, nErr) + + _, nErr = ss.Channel().SaveMember(&model.ChannelMember{ + ChannelId: c1.Id, + UserId: user2Admin.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + ExplicitRoles: "channel_admin", + }) + require.NoError(t, nErr) + ss.Channel().UpdateMembersRole(c1.Id, []string{user2Admin.Id}) + + users, err := ss.User().GetProfilesInChannel(&model.UserGetOptions{ + InChannelId: c1.Id, + ChannelRoles: []string{model.ChannelAdminRoleId}, + Page: 0, + PerPage: 5, + }) + require.NoError(t, err) + assert.Equal(t, user2Admin.Id, users[0].Id) + }) } func testUserStoreGetProfilesInChannelByAdmin(t *testing.T, ss store.Store, s SqlStore) { @@ -4163,6 +4194,30 @@ func testCount(t *testing.T, ss store.Store) { } } +func testUserStoreGetFirstSystemAdminID(t *testing.T, ss store.Store) { + sysAdmin := &model.User{} + sysAdmin.Email = MakeEmail() + sysAdmin.Roles = model.SystemAdminRoleId + " " + model.SystemUserRoleId + sysAdmin, err := ss.User().Save(sysAdmin) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(sysAdmin.Id)) }() + + // We need the second system admin to be created after the first one + // our granulirity is ms + time.Sleep(1 * time.Millisecond) + + sysAdmin2 := &model.User{} + sysAdmin2.Email = MakeEmail() + sysAdmin2.Roles = model.SystemAdminRoleId + " " + model.SystemUserRoleId + sysAdmin2, err = ss.User().Save(sysAdmin2) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(sysAdmin2.Id)) }() + + returnedId, err := ss.User().GetFirstSystemAdminID() + require.NoError(t, err) + require.Equal(t, sysAdmin.Id, returnedId) +} + func testUserStoreAnalyticsActiveCount(t *testing.T, ss store.Store, s SqlStore) { cleanupStatusStore(t, s) @@ -4857,10 +4912,35 @@ func testUserStoreGetUsersBatchForIndexing(t *testing.T, ss store.Store) { }) require.NoError(t, err) + cDM := &model.Channel{ + Name: model.NewId() + "__" + model.NewId(), + Type: model.ChannelTypeDirect, + } + cm1 := &model.ChannelMember{ + UserId: u3.Id, + ChannelId: cDM.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + cm2 := &model.ChannelMember{ + UserId: u2.Id, + ChannelId: cDM.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + cDM, nErr = ss.Channel().SaveDirectChannel(cDM, cm1, cm2) + require.NoError(t, nErr) + // Getting all users res1List, err := ss.User().GetUsersBatchForIndexing(u1.CreateAt-1, "", 100) require.NoError(t, err) assert.Len(t, res1List, 3) + for _, user := range res1List { + switch user.Id { + case u2.Id: + assert.ElementsMatch(t, user.ChannelsIds, []string{cPub1.Id, cPub2.Id, cDM.Id}) + case u3.Id: + assert.ElementsMatch(t, user.ChannelsIds, []string{cPub2.Id, cDM.Id}) + } + } // Testing pagination res2List, err := ss.User().GetUsersBatchForIndexing(u1.CreateAt-1, "", 1) diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 0d7b338767..0558fa66c1 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -9727,6 +9727,22 @@ func (s *TimerLayerUserStore) GetEtagForProfilesNotInTeam(teamID string) string return result } +func (s *TimerLayerUserStore) GetFirstSystemAdminID() (string, error) { + start := time.Now() + + result, err := s.UserStore.GetFirstSystemAdminID() + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserStore.GetFirstSystemAdminID", success, elapsed) + } + return result, err +} + func (s *TimerLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) { start := time.Now() diff --git a/templates/email_change_verify_body.html b/templates/email_change_verify_body.html index 0babf28737..22a1cec84d 100644 --- a/templates/email_change_verify_body.html +++ b/templates/email_change_verify_body.html @@ -1,46 +1,203 @@ {{define "email_change_verify_body"}} - - - - + + +
- - - - -
- + + + + + + + +
+
+
+ + + - -
+
+ + - - - - - - {{template "email_footer" . }} - +
- + +
+ + + + + + +
+ + + + + + +
+ +
+
+
- - - - - - {{template "email_info" . }} - -
-

{{.Props.Title}}

-

{{.Props.Info}}

-

- {{.Props.VerifyButton}} -

-
-
-
-
+ +
+ + + + + + +
+
+ + + + + + + + + + + + +
+

{{.Props.Title}}

+
+
{{.Props.Info}}
+
+ +
+
+
+
+
+ + + + + + +
+
+ + + + + + +
+ + + + + + +
+ +
+
+
+
+
+
+ + + + + + +
+
+ + + + + + +
+

{{.Props.QuestionTitle}}

+

{{.Props.EmailInfo1}}{{.Props.SupportEmail}}

+
+
+
+
+
+ + + + + + +
+
+ + + + + + +
+

+ {{.Props.FooterV2}} +

+
+
+
+
+
+ + {{end}} diff --git a/templates/invite_body.mjml b/templates/invite_body.mjml index 869a359b2c..d16c20af68 100644 --- a/templates/invite_body.mjml +++ b/templates/invite_body.mjml @@ -8,7 +8,13 @@ {{if .Props.Message}} {{range .Props.Posts}}
- + + + + + + +
{{end}}
{{else}} diff --git a/templates/messages_notification.html b/templates/messages_notification.html index 9c40227203..7b47478cfa 100644 --- a/templates/messages_notification.html +++ b/templates/messages_notification.html @@ -356,7 +356,160 @@ max-width: 100% !important; } } + + .messageAttachments * { + font-family: Open Sans, sans-serif !important; + } + + .messageAttachmentContent { + padding: 0px; + border: 1px solid rgba(63, 67, 80, 0.16); + margin-bottom: 20px; + border-radius: 0 4px 4px 0; + } + + .messageAttachmentContent>table, + .attachment__body { + font-family: Open Sans, sans-serif; + text-align: left; + font-size: 14px; + line-height: 20px; + color: #3F4350; + } + + .messageAttachmentContent .attachment__author-icon { + width: 14px; + height: 14px; + margin-right: 5px; + border-radius: 50px; + vertical-align: middle; + } + + .attachment__author-name { + opacity: 0.6; + } + + .messageAttachmentContent p { + margin: 0; + } + + .attachment__title { + padding: 0; + margin: 5px 0; + font-size: 14px; + font-weight: 600; + line-height: 18px; + } + + .attachment__image { + max-height: 300px; + border: 1px solid transparent; + margin-bottom: 1em; + } + + .attachment__thumb-image { + max-width: 100%; + max-height: 75px; + } + + .attachment__thumb-container { + max-width: 80px; + width: max-content; + } + + .attachment__wrapper { + width: 100%; + display: flex; + flex-direction: row; + gap: 12px; + } + + .attachment__body { + flex: 1; + } + + .messageAttachmentContent>table.attachment__footer-container { + color: #a3a3a3; + font-size: 12px; + } + + .attachment__footer-icon { + width: 16px; + height: 16px; + } + + .attachment__footer-container { + color: #a3a3a3; + font-size: 12px; + } + + .messageAttachment_title { + padding-top: 1em; + font-weight: 600; + margin-bottom: 4px; + } + + .pretext h1 { + font-size: 28px; + line-height: 32px; + } + + .pretext h2 { + font-size: 25px; + line-height: 30px; + } + + .pretext h3 { + font-size: 22px; + line-height: 25px; + } + + .pretext h4 { + font-size: 19px; + line-height: 24px; + } + + .pretext h5 { + font-size: 15px; + line-height: 20px; + } + + .pretext h6 { + font-size: 1em; + line-height: 1.4em; + } + + .pretext>* { + margin-bottom: 16px; + } + + .messageAttachments { + margin-top: 22px; + } + + .messageAttachments h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: 500; + } + + code { + padding: 2px 4px; + font-size: 90%; + background-color: rgba(63, 67, 80, 0.1); + border-radius: 4px; + } + + .messageAttachments a { + background-color: unset !important; + border: none !important; + padding: unset !important; + } + @@ -507,7 +660,96 @@ - + + {{if .MessageAttachments}} +
+ {{range .MessageAttachments}} +
+
+ {{.Pretext}} +
+
+ + + {{if or .AuthorIcon .AuthorName}} + + + + {{end}} + {{if .Title}} + + + + {{end}} + +
+ {{if .AuthorLink}}{{end}} + {{if .AuthorIcon}}attachment author icon{{end}} + {{if .AuthorName}}{{.AuthorName}}{{end}} + {{if .AuthorLink}}{{end}} +
+

+ {{if .TitleLink}}{{end}} + {{.Title}} + {{if .Title}}{{end}} +

+
+
+
+ + + + + + {{if .ImageURL}} + + + + {{end}} + +
+
{{.Text}}
+
+ +
+ + {{range .FieldRows}} + + {{ $length := len .Cells }} + {{range .Cells}} + + {{end}} + + {{end}} +
+
{{.Title}}
+
{{.Value}}
+
+
+ {{if .ThumbURL}} +
+ +
+ {{end}} +
+ {{if .Footer}} + + + + + + + + {{end}} +
+
+ {{end}} +
+ {{end}} +
diff --git a/templates/messages_notification.mjml b/templates/messages_notification.mjml index ad840ff420..a2b53b6b16 100644 --- a/templates/messages_notification.mjml +++ b/templates/messages_notification.mjml @@ -1,13 +1,21 @@ + {{range .Props.Posts}}
- + + + + + + + +
{{end}}
diff --git a/templates/partials/card.mjml b/templates/partials/card.mjml deleted file mode 100644 index f1c835e04c..0000000000 --- a/templates/partials/card.mjml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - -
- - - - - {{.Message}} - - - - {{if .MessageURL}} - {{$.Props.MessageButton}} - {{end}} - - - diff --git a/templates/partials/message_attachment.css b/templates/partials/message_attachment.css new file mode 100644 index 0000000000..ee65c7f8a3 --- /dev/null +++ b/templates/partials/message_attachment.css @@ -0,0 +1,146 @@ +.messageAttachments * { + font-family: Open Sans, sans-serif !important; +} + +.messageAttachmentContent { + padding: 0px; + border: 1px solid rgba(63, 67, 80, 0.16); + margin-bottom: 20px; + border-radius: 0 4px 4px 0; +} + +.messageAttachmentContent > table, .attachment__body { + font-family: Open Sans, sans-serif; + text-align: left; + font-size: 14px; + line-height: 20px; + color: #3F4350; +} + +.messageAttachmentContent .attachment__author-icon { + width: 14px; + height: 14px; + margin-right: 5px; + border-radius: 50px; + vertical-align: middle; +} + +.attachment__author-name { + opacity: 0.6; +} + +.messageAttachmentContent p { + margin: 0; +} + +.attachment__title { + padding: 0; + margin: 5px 0; + font-size: 14px; + font-weight: 600; + line-height: 18px; +} + +.attachment__image { + max-height: 300px; + border: 1px solid transparent; + margin-bottom: 1em; +} + + +.attachment__thumb-image { + max-width: 100%; + max-height: 75px; +} + +.attachment__thumb-container { + max-width: 80px; + width: max-content; +} + +.attachment__wrapper { + width: 100%; + display: flex; + flex-direction: row; + gap: 12px; +} + +.attachment__body { + flex: 1; +} + +.messageAttachmentContent > table.attachment__footer-container { + color: #a3a3a3; + font-size: 12px; +} + +.attachment__footer-icon { + width: 16px; + height: 16px; +} + +.attachment__footer-container { + color: #a3a3a3; + font-size: 12px; +} + +.messageAttachment_title { + padding-top: 1em; + font-weight: 600; + margin-bottom: 4px; +} + +.pretext h1 { + font-size: 28px; + line-height: 32px; +} + +.pretext h2 { + font-size: 25px; + line-height: 30px; +} + +.pretext h3 { + font-size: 22px; + line-height: 25px; +} + +.pretext h4 { + font-size: 19px; + line-height: 24px; +} + +.pretext h5 { + font-size: 15px; + line-height: 20px; +} + +.pretext h6 { + font-size: 1em; + line-height: 1.4em; +} + +.pretext > * { + margin-bottom: 16px; +} + +.messageAttachments { + margin-top: 22px; +} + +.messageAttachments h1, h2, h3, h4, h5, h6 { + font-weight: 500; +} + +code { + padding: 2px 4px; + font-size: 90%; + background-color: rgba(63, 67, 80, 0.1); + border-radius: 4px; +} + +.messageAttachments a { + background-color: unset !important; + border: none !important; + padding: unset !important; +} diff --git a/templates/partials/message_attachment.html b/templates/partials/message_attachment.html new file mode 100644 index 0000000000..68dd5fe21b --- /dev/null +++ b/templates/partials/message_attachment.html @@ -0,0 +1,95 @@ +{{if .MessageAttachments}} +
+ {{range .MessageAttachments}} +
+
+ {{.Pretext}} +
+
+
-
-
{{.SenderName}}
- {{if .Time}} -
{{.Time}}
- {{end}} - {{if .ChannelName}} -
- {{if .ShowChannelIcon}} - - {{end}} -
- {{if .OtherChannelMembersCount}} - {{.OtherChannelMembersCount}} - {{end}} - {{.ChannelName}} -
-
- {{end}} -
-
+ + {{if or .AuthorIcon .AuthorName}} + + + + {{end}} + {{if .Title}} + + + + {{end}} + +
+ {{if .AuthorLink}}{{end}} + {{if .AuthorIcon}}attachment author icon{{end}} + {{if .AuthorName}}{{.AuthorName}}{{end}} + {{if .AuthorLink}}{{end}} +
+

+ {{if .TitleLink}}{{end}} + {{.Title}} + {{if .Title}}{{end}} +

+
+ +
+
+ + + + + + {{if .ImageURL}} + + + + {{end}} +
+
{{.Text}}
+
+ +
+ + + {{range .FieldRows}} + + {{ $length := len .Cells }} + {{range .Cells}} + + {{end}} + + {{end}} +
+
{{.Title}}
+
{{.Value}}
+
+
+ + {{if .ThumbURL}} +
+ +
+ {{end}} +
+ + {{if .Footer}} + + + + + + + + {{end}} + +
+ + {{end}} + +{{end}} diff --git a/templates/partials/message_attachment_styles.mjml b/templates/partials/message_attachment_styles.mjml new file mode 100644 index 0000000000..0e0de4dd3a --- /dev/null +++ b/templates/partials/message_attachment_styles.mjml @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/partials/message_avatar_col.mjml b/templates/partials/message_avatar_col.mjml new file mode 100644 index 0000000000..dc4686896d --- /dev/null +++ b/templates/partials/message_avatar_col.mjml @@ -0,0 +1,3 @@ + + + diff --git a/templates/partials/message_button.mjml b/templates/partials/message_button.mjml new file mode 100644 index 0000000000..997edac1bc --- /dev/null +++ b/templates/partials/message_button.mjml @@ -0,0 +1,5 @@ + + {{if .MessageURL}} + {{$.Props.MessageButton}} + {{end}} + diff --git a/templates/partials/sender_info_col.mjml b/templates/partials/sender_info_col.mjml new file mode 100644 index 0000000000..69b37d1853 --- /dev/null +++ b/templates/partials/sender_info_col.mjml @@ -0,0 +1,30 @@ + + + + +
+
{{.SenderName}}
+ {{if .Time}} +
{{.Time}}
+ {{end}} + {{if .ChannelName}} +
+ {{if .ShowChannelIcon}} + + {{end}} +
+ {{if .OtherChannelMembersCount}} + {{.OtherChannelMembersCount}} + {{end}} + {{.ChannelName}} +
+
+ {{end}} +
+ + +
+ + {{.Message}} + +
diff --git a/testlib/store.go b/testlib/store.go index 5566989362..800764da07 100644 --- a/testlib/store.go +++ b/testlib/store.go @@ -69,6 +69,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { systemStore.On("GetByName", model.MigrationKeyAddCustomUserGroupsPermissions).Return(&model.System{Name: model.MigrationKeyAddCustomUserGroupsPermissions, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyAddPlayboosksManageRolesPermissions).Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) + systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil) systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(&model.System{}, nil).Once() systemStore.On("Save", mock.AnythingOfType("*model.System")).Return(nil) diff --git a/tests/10000x1_expected_preview.jpeg b/tests/10000x1_expected_preview.jpeg deleted file mode 100644 index c547e21eef..0000000000 Binary files a/tests/10000x1_expected_preview.jpeg and /dev/null differ diff --git a/tests/10000x1_expected_preview.png b/tests/10000x1_expected_preview.png new file mode 100644 index 0000000000..bf2ca9cc21 Binary files /dev/null and b/tests/10000x1_expected_preview.png differ diff --git a/tests/10000x1_expected_thumb.jpeg b/tests/10000x1_expected_thumb.jpeg deleted file mode 100644 index 90f2925324..0000000000 Binary files a/tests/10000x1_expected_thumb.jpeg and /dev/null differ diff --git a/tests/10000x1_expected_thumb.png b/tests/10000x1_expected_thumb.png new file mode 100644 index 0000000000..a354c41047 Binary files /dev/null and b/tests/10000x1_expected_thumb.png differ diff --git a/tests/1x10000_expected_preview.jpeg b/tests/1x10000_expected_preview.jpeg deleted file mode 100644 index e2511cb26f..0000000000 Binary files a/tests/1x10000_expected_preview.jpeg and /dev/null differ diff --git a/tests/1x10000_expected_preview.png b/tests/1x10000_expected_preview.png new file mode 100644 index 0000000000..b4317244e9 Binary files /dev/null and b/tests/1x10000_expected_preview.png differ diff --git a/tests/1x10000_expected_thumb.jpeg b/tests/1x10000_expected_thumb.jpeg deleted file mode 100644 index 4845434877..0000000000 Binary files a/tests/1x10000_expected_thumb.jpeg and /dev/null differ diff --git a/tests/1x10000_expected_thumb.png b/tests/1x10000_expected_thumb.png new file mode 100644 index 0000000000..71dd5eba8f Binary files /dev/null and b/tests/1x10000_expected_thumb.png differ