From ee40eb956cbeda7a62d3ab3ad33104929f1f0968 Mon Sep 17 00:00:00 2001 From: Shivashis Padhi Date: Tue, 22 Nov 2022 11:09:04 +0530 Subject: [PATCH 01/80] [MM-47384] Make OpenID Connect free for all (#21556) * wip: make OpenID Connect free-for-all * Deprecation note: GoogleOAuth, Office365OAuth * Improve deprecation comments Co-authored-by: Martin Kraft * Lint fix * Add model/oauthproviders, move google, openid, office365 from enterprise * Vet fixes * Remove redundant log Co-authored-by: Martin Kraft Co-authored-by: Mattermod --- api4/user_test.go | 2 +- app/user_test.go | 2 +- cmd/mattermost/main.go | 6 +- config/client.go | 24 +- model/license.go | 24 +- model/{ => oauthproviders}/gitlab/gitlab.go | 0 model/oauthproviders/google/google.go | 158 ++++++++ model/oauthproviders/google/google_test.go | 61 +++ model/oauthproviders/office365/office365.go | 116 ++++++ .../office365/office365_test.go | 43 +++ model/oauthproviders/openid/openid.go | 243 ++++++++++++ model/oauthproviders/openid/openid_test.go | 352 ++++++++++++++++++ 12 files changed, 1000 insertions(+), 31 deletions(-) rename model/{ => oauthproviders}/gitlab/gitlab.go (100%) create mode 100644 model/oauthproviders/google/google.go create mode 100644 model/oauthproviders/google/google_test.go create mode 100644 model/oauthproviders/office365/office365.go create mode 100644 model/oauthproviders/office365/office365_test.go create mode 100644 model/oauthproviders/openid/openid.go create mode 100644 model/oauthproviders/openid/openid_test.go diff --git a/api4/user_test.go b/api4/user_test.go index db15a06604..8185ce6c03 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -25,7 +25,7 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mail" "github.com/mattermost/mattermost-server/v6/utils/testutils" - _ "github.com/mattermost/mattermost-server/v6/model/gitlab" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" ) func TestCreateUser(t *testing.T) { diff --git a/app/user_test.go b/app/user_test.go index 139cc1c1e5..64aa833295 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -23,7 +23,7 @@ import ( "github.com/mattermost/mattermost-server/v6/einterfaces" "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" - oauthgitlab "github.com/mattermost/mattermost-server/v6/model/gitlab" + oauthgitlab "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" "github.com/mattermost/mattermost-server/v6/store" storemocks "github.com/mattermost/mattermost-server/v6/store/storetest/mocks" "github.com/mattermost/mattermost-server/v6/utils/testutils" diff --git a/cmd/mattermost/main.go b/cmd/mattermost/main.go index e39ed27059..b5f73a31f9 100644 --- a/cmd/mattermost/main.go +++ b/cmd/mattermost/main.go @@ -10,7 +10,11 @@ import ( // Import and register app layer slash commands _ "github.com/mattermost/mattermost-server/v6/app/slashcommands" // Plugins - _ "github.com/mattermost/mattermost-server/v6/model/gitlab" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/google" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/office365" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/openid" + // Enterprise Imports _ "github.com/mattermost/mattermost-server/v6/imports" ) diff --git a/config/client.go b/config/client.go index 1eaef48c60..c6a8587865 100644 --- a/config/client.go +++ b/config/client.go @@ -298,11 +298,11 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["SamlLoginButtonColor"] = "" props["SamlLoginButtonBorderColor"] = "" props["SamlLoginButtonTextColor"] = "" - props["EnableSignUpWithGoogle"] = "false" - props["EnableSignUpWithOffice365"] = "false" - props["EnableSignUpWithOpenId"] = "false" - props["OpenIdButtonText"] = "" - props["OpenIdButtonColor"] = "" + props["EnableSignUpWithOpenId"] = strconv.FormatBool(*c.OpenIdSettings.Enable) + props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor + props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText + props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable) + props["EnableSignUpWithOffice365"] = strconv.FormatBool(*c.Office365Settings.Enable) props["CWSURL"] = "" props["EnableCustomBrand"] = strconv.FormatBool(*c.TeamSettings.EnableCustomBrand) props["CustomBrandText"] = *c.TeamSettings.CustomBrandText @@ -329,20 +329,6 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["SamlLoginButtonTextColor"] = *c.SamlSettings.LoginButtonTextColor } - if *license.Features.GoogleOAuth { - props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable) - } - - if *license.Features.Office365OAuth { - props["EnableSignUpWithOffice365"] = strconv.FormatBool(*c.Office365Settings.Enable) - } - - if *license.Features.OpenId { - props["EnableSignUpWithOpenId"] = strconv.FormatBool(*c.OpenIdSettings.Enable) - props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor - props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText - } - if *license.Features.CustomTermsOfService { props["EnableCustomTermsOfService"] = strconv.FormatBool(*c.SupportSettings.CustomTermsOfServiceEnabled) props["CustomTermsOfServiceReAcceptancePeriod"] = strconv.FormatInt(int64(*c.SupportSettings.CustomTermsOfServiceReAcceptancePeriod), 10) diff --git a/model/license.go b/model/license.go index ba0797c168..43a4c6af69 100644 --- a/model/license.go +++ b/model/license.go @@ -77,12 +77,18 @@ type TrialLicenseRequest struct { } type Features struct { - Users *int `json:"users"` - LDAP *bool `json:"ldap"` - LDAPGroups *bool `json:"ldap_groups"` - MFA *bool `json:"mfa"` - GoogleOAuth *bool `json:"google_oauth"` - Office365OAuth *bool `json:"office365_oauth"` + Users *int `json:"users"` + LDAP *bool `json:"ldap"` + LDAPGroups *bool `json:"ldap_groups"` + MFA *bool `json:"mfa"` + + // Deprecated: This feature will be removed from the license because it's available without a license. + GoogleOAuth *bool `json:"google_oauth"` + + // Deprecated: This feature will be removed from the license because it's available without a license. + Office365OAuth *bool `json:"office365_oauth"` + + // Deprecated: This feature will be removed from the license because it's available without a license. OpenId *bool `json:"openid"` Compliance *bool `json:"compliance"` Cluster *bool `json:"cluster"` @@ -164,15 +170,15 @@ func (f *Features) SetDefaults() { } if f.GoogleOAuth == nil { - f.GoogleOAuth = NewBool(*f.FutureFeatures) + f.GoogleOAuth = NewBool(true) } if f.Office365OAuth == nil { - f.Office365OAuth = NewBool(*f.FutureFeatures) + f.Office365OAuth = NewBool(true) } if f.OpenId == nil { - f.OpenId = NewBool(*f.FutureFeatures) + f.OpenId = NewBool(true) } if f.Compliance == nil { diff --git a/model/gitlab/gitlab.go b/model/oauthproviders/gitlab/gitlab.go similarity index 100% rename from model/gitlab/gitlab.go rename to model/oauthproviders/gitlab/gitlab.go diff --git a/model/oauthproviders/google/google.go b/model/oauthproviders/google/google.go new file mode 100644 index 0000000000..19c4cf3353 --- /dev/null +++ b/model/oauthproviders/google/google.go @@ -0,0 +1,158 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthgoogle + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" +) + +type GoogleProvider struct { +} + +type SourceElement struct { + Type string `json:"type"` + ID string `json:"id"` + Etag string `json:"etag"` + ProfileMetadata ProfileMetadata `json:"profileMetadata"` +} + +type ProfileMetadata struct { + ObjectType string `json:"objectType"` + UserTypes []string `json:"userTypes"` +} + +type GoogleUserRootMetadata struct { + Sources []SourceElement `json:"sources"` +} + +type GoogleUserMetadata struct { + Source map[string]string `json:"source"` +} + +type GoogleUserNameNode struct { + Metadata GoogleUserMetadata `json:"metadata"` + GivenName string `json:"givenName"` + FamilyName string `json:"familyName"` +} + +type GoogleGenericInfoNode struct { + Metadata GoogleUserMetadata `json:"metadata"` + Value string `json:"value"` +} + +type GoogleUser struct { + Metadata GoogleUserRootMetadata `json:"metadata"` + Nicknames []GoogleGenericInfoNode `json:"nicknames"` + Emails []GoogleGenericInfoNode `json:"emailAddresses"` + Names []GoogleUserNameNode `json:"names"` +} + +func init() { + provider := &GoogleProvider{} + einterfaces.RegisterOAuthProvider(model.ServiceGoogle, provider) +} + +func userFromGoogleUser(gu *GoogleUser) *model.User { + user := &model.User{} + + for _, e := range gu.Emails { + if e.Metadata.Source["type"] == "ACCOUNT" || e.Metadata.Source["type"] == "DOMAIN_PROFILE" { + user.Email = e.Value + user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) + break + } + } + + for _, e := range gu.Names { + if e.Metadata.Source["type"] == "PROFILE" || e.Metadata.Source["type"] == "DOMAIN_PROFILE" { + user.FirstName = e.GivenName + user.LastName = e.FamilyName + break + } + } + + if len(gu.Nicknames) > 0 { + user.Nickname = gu.Nicknames[0].Value + } + + user.AuthData = new(string) + *user.AuthData = gu.getAuthData() + user.AuthService = model.ServiceGoogle + + return user +} + +func googleUserFromJSON(data io.Reader) (*GoogleUser, error) { + decoder := json.NewDecoder(data) + var gu GoogleUser + err := decoder.Decode(&gu) + if err != nil { + return nil, err + } + + return &gu, nil +} + +func (gu *GoogleUser) IsValid() error { + if len(gu.Metadata.Sources) == 0 { + return errors.New("invalid metadata sources") + } + + if len(gu.Emails) == 0 { + return errors.New("invalid emails") + } + + return nil +} + +func (gu *GoogleUser) getAuthData() string { + if len(gu.Metadata.Sources) > 0 { + return gu.Metadata.Sources[0].ID + } + + return "" +} + +func (m *GoogleProvider) GetIdentifier() string { + return model.ServiceGoogle +} + +func (m *GoogleProvider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { + gu, err := googleUserFromJSON(data) + if err != nil { + return nil, err + } + return userFromGoogleUser(gu), nil +} + +func (m *GoogleProvider) GetAuthDataFromJSON(data io.Reader) (string, error) { + gu, err := googleUserFromJSON(data) + if err != nil { + return "", err + } + + if err = gu.IsValid(); err != nil { + return "", err + } + + return gu.getAuthData(), nil +} + +func (m *GoogleProvider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { + return &config.GoogleSettings, nil +} + +func (m *GoogleProvider) GetUserFromIdToken(idToken string) (*model.User, error) { + return nil, nil +} + +func (m *GoogleProvider) IsSameUser(dbUser, oauthUser *model.User) bool { + return dbUser.AuthData == oauthUser.AuthData +} diff --git a/model/oauthproviders/google/google_test.go b/model/oauthproviders/google/google_test.go new file mode 100644 index 0000000000..1f30efb8eb --- /dev/null +++ b/model/oauthproviders/google/google_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthgoogle + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGoogleUserFromJSON(t *testing.T) { + gu := GoogleUser{ + Metadata: GoogleUserRootMetadata{ + Sources: []SourceElement{ + { + Etag: "tag", + }, + }, + }, + Emails: []GoogleGenericInfoNode{ + { + Value: "ali@test.com", + }, + }, + Names: []GoogleUserNameNode{ + { + GivenName: "ali", + }, + }, + Nicknames: []GoogleGenericInfoNode{ + { + Value: "ila", + }, + }, + } + + provider := &GoogleProvider{} + + t.Run("valid google user", func(t *testing.T) { + b, err := json.Marshal(gu) + require.NoError(t, err) + + _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) + require.NoError(t, err) + }) + + t.Run("empty body should fail without panic", func(t *testing.T) { + _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) + require.Error(t, err) + }) +} diff --git a/model/oauthproviders/office365/office365.go b/model/oauthproviders/office365/office365.go new file mode 100644 index 0000000000..7f23d9e51c --- /dev/null +++ b/model/oauthproviders/office365/office365.go @@ -0,0 +1,116 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthoffice365 + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" +) + +type Office365Provider struct { +} + +type Office365User struct { + Id string `json:"id"` + FirstName string `json:"givenName"` + LastName string `json:"surname"` + Mail string `json:"mail"` + UserPrincipalName string `json:"userPrincipalName"` +} + +func init() { + provider := &Office365Provider{} + einterfaces.RegisterOAuthProvider(model.ServiceOffice365, provider) +} + +func userFromOffice365User(of *Office365User) *model.User { + user := &model.User{} + user.FirstName = of.FirstName + user.LastName = of.LastName + + if of.Mail != "" { + user.Email = of.Mail + } else if strings.Contains(of.UserPrincipalName, "@") { + user.Email = of.UserPrincipalName + } + + if user.Email != "" { + user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) + } + + user.AuthData = new(string) + *user.AuthData = of.Id + user.AuthService = model.ServiceOffice365 + + return user +} + +func office365UserFromJSON(data io.Reader) (*Office365User, error) { + decoder := json.NewDecoder(data) + var of Office365User + err := decoder.Decode(&of) + if err != nil { + return nil, err + } + + return &of, nil +} + +func (of *Office365User) IsValid() error { + if of.Id == "" { + return errors.New("invalid user id") + } + + if of.Mail == "" && !strings.Contains(of.UserPrincipalName, "@") { + return errors.New("invalid email") + } + + return nil +} + +func (of *Office365User) getAuthData() string { + return of.Id +} + +func (m *Office365Provider) GetIdentifier() string { + return model.ServiceOffice365 +} + +func (m *Office365Provider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { + of, err := office365UserFromJSON(data) + if err != nil { + return nil, err + } + return userFromOffice365User(of), nil +} + +func (m *Office365Provider) GetAuthDataFromJSON(data io.Reader) (string, error) { + of, err := office365UserFromJSON(data) + if err != nil { + return "", err + } + + if err = of.IsValid(); err != nil { + return "", err + } + + return of.getAuthData(), nil +} + +func (m *Office365Provider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { + return config.Office365Settings.SSOSettings(), nil +} + +func (m *Office365Provider) GetUserFromIdToken(idToken string) (*model.User, error) { + return nil, nil +} + +func (m *Office365Provider) IsSameUser(dbUser, oauthUser *model.User) bool { + return dbUser.AuthData == oauthUser.AuthData +} diff --git a/model/oauthproviders/office365/office365_test.go b/model/oauthproviders/office365/office365_test.go new file mode 100644 index 0000000000..85496dd7ab --- /dev/null +++ b/model/oauthproviders/office365/office365_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthoffice365 + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOffice365UserFromJSON(t *testing.T) { + ou := Office365User{ + FirstName: "ali", + Id: "12345", + LastName: "maya", + Mail: "ali@test.com", + } + + provider := &Office365Provider{} + + t.Run("valid office365 user", func(t *testing.T) { + b, err := json.Marshal(ou) + require.NoError(t, err) + + _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) + require.NoError(t, err) + }) + + t.Run("empty body should fail without panic", func(t *testing.T) { + _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) + require.Error(t, err) + }) +} diff --git a/model/oauthproviders/openid/openid.go b/model/oauthproviders/openid/openid.go new file mode 100644 index 0000000000..e48be5d913 --- /dev/null +++ b/model/oauthproviders/openid/openid.go @@ -0,0 +1,243 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthopenid + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" +) + +type CacheData struct { + Service string + Expires int64 + Settings model.SSOSettings +} + +type OpenIdMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserEndpoint string `json:"userinfo_endpoint"` + JwksURI string `json:"jwks_uri"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` +} + +type OpenIdProvider struct { + CacheData *CacheData +} + +type OpenIdUser struct { + Id string `json:"sub"` + Oid string `json:"oid"` //Office 365 only + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + Name string `json:"name"` + Nickname string `json:"nickname"` + Email string `json:"email"` +} + +func init() { + provider := &OpenIdProvider{} + einterfaces.RegisterOAuthProvider(model.ServiceOpenid, provider) +} + +func (o *OpenIdProvider) userFromOpenIdUser(u *OpenIdUser) *model.User { + user := &model.User{} + + user.Email = u.Email + user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) + if o.CacheData.Service == model.ServiceGitlab && u.Nickname != "" { + user.Username = u.Nickname + } + + user.FirstName = u.FirstName + user.LastName = u.LastName + user.Nickname = u.Nickname + + user.AuthData = new(string) + *user.AuthData = o.getAuthData(u) + + return user +} + +func (o *OpenIdProvider) getAuthData(u *OpenIdUser) string { + if o.CacheData.Service == model.ServiceOffice365 { + if u.Oid != "" { + return u.Oid + } + } + return u.Id +} + +func openIDUserFromJSON(data io.Reader) (*OpenIdUser, error) { + decoder := json.NewDecoder(data) + var u OpenIdUser + err := decoder.Decode(&u) + if err != nil { + return nil, err + } + return &u, nil +} + +func (u *OpenIdUser) IsValid() error { + if u.Id == "" { + return errors.New("invalid id") + } + + if u.Email == "" { + return errors.New("invalid emails") + } + return nil +} + +func (u *OpenIdUser) GetIdentifier() string { + return model.ServiceOpenid +} + +func (o *OpenIdProvider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { + oid, err := openIDUserFromJSON(data) + if err != nil { + return nil, err + } + jsonUser := o.userFromOpenIdUser(oid) + + if tokenUser != nil { + jsonUser = o.combineUsers(jsonUser, tokenUser) + } + return jsonUser, nil +} + +func (o *OpenIdProvider) combineUsers(jsonUser *model.User, tokenUser *model.User) *model.User { + if o.CacheData.Service == model.ServiceOffice365 { + jsonUser.AuthData = tokenUser.AuthData + } + return jsonUser +} + +func (o *OpenIdProvider) GetAuthDataFromJSON(data io.Reader) (string, error) { + u, err := openIDUserFromJSON(data) + if err != nil { + return "", err + } + + err = u.IsValid() + if err != nil { + return "", err + } + return o.getAuthData(u), nil +} + +// GetSSOSettings returns SSO Settings from Cache or Discovery Document +func (o *OpenIdProvider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { + settings := config.OpenIdSettings + if service == model.ServiceOffice365 { + settings = *config.Office365Settings.SSOSettings() + } else if service == model.ServiceGoogle { + settings = config.GoogleSettings + } else if service == model.ServiceGitlab { + settings = config.GitLabSettings + } + + if o.CacheData != nil && !settingsChanged(*o.CacheData, service, settings) && o.CacheData.Expires > time.Now().Unix() { + return &o.CacheData.Settings, nil + } + + var age int64 = 0 + if *settings.DiscoveryEndpoint != "" { + response, err := http.Get(*settings.DiscoveryEndpoint) + if err != nil { + return nil, err + } + defer response.Body.Close() + + for _, v := range strings.Split(response.Header.Get("Cache-Control"), ",") { + if strings.Contains(v, "max-age") { + ageValue := strings.Split(v, "=")[1] + age, _ = strconv.ParseInt(ageValue, 10, 64) + } + } + responseData, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + var openIDResponse OpenIdMetadata + err = json.Unmarshal(responseData, &openIDResponse) + if err != nil { + return nil, err + } + + settings.AuthEndpoint = &openIDResponse.AuthorizationEndpoint + settings.TokenEndpoint = &openIDResponse.TokenEndpoint + settings.UserAPIEndpoint = &openIDResponse.UserEndpoint + } + expires := time.Now().Unix() + age + + o.CacheData = &CacheData{ + Service: service, + Expires: expires, + Settings: settings, + } + return &settings, nil +} + +func settingsChanged(cacheData CacheData, service string, configSettings model.SSOSettings) bool { + if cacheData.Service == service && + cacheData.Settings.DiscoveryEndpoint == configSettings.DiscoveryEndpoint && + cacheData.Settings.Secret == configSettings.Secret && + cacheData.Settings.Id == configSettings.Id { + return false + } + return true +} + +func (o *OpenIdProvider) GetUserFromIdToken(idToken string) (*model.User, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return nil, errors.New("invalid Id Token") + } + + b, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + + claims := &OpenIdUser{} + json.Unmarshal(b, &claims) + + return o.userFromOpenIdUser(claims), nil +} + +func (o *OpenIdProvider) IsSameUser(dbUser, oauthUser *model.User) bool { + // Office365 OAuth would store Ids without dashes. (ie. 0e8fddd450d344999a93a390ee8cb83d) + // Office365 OpenId will return as a formatted GUID (ie. '0e8fddd4-50d3-4499-9a93-a390ee8cb83d') + // If this is a UUID that starts with all zero. (ie. 00000000-0000-0000-be95-fe607df5dbeb) + // For backwards compatibility we store the auth data from OAuth as be95fe607df5dbeb + if dbUser.AuthData == nil || oauthUser.AuthData == nil { + return false + } + dbID := *dbUser.AuthData + oauthID := *oauthUser.AuthData + if dbID == "" || oauthID == "" { + return false + } + parts := strings.Split(oauthID, "-") + for _, part := range parts { + if strings.Count(part, "0") != len(part) { + if !strings.Contains(dbID, part) { + return false + } + } + } + return true +} diff --git a/model/oauthproviders/openid/openid_test.go b/model/oauthproviders/openid/openid_test.go new file mode 100644 index 0000000000..7e157c6a88 --- /dev/null +++ b/model/oauthproviders/openid/openid_test.go @@ -0,0 +1,352 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthopenid + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func TestGetAuthData(t *testing.T) { + ou := OpenIdUser{ + Id: "12345", + FirstName: "firstname", + LastName: "lastname", + Nickname: "nickname", + Email: "name@test.com", + Oid: "0e8fddd4-50d3-4499-9a93-a390ee8cb83d", + } + + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceGitlab, + }, + } + + t.Run("validate return id", func(t *testing.T) { + authData := provider.getAuthData(&ou) + assert.Equal(t, ou.Id, authData) + }) + + provider.CacheData.Service = model.ServiceOffice365 + + fmt.Println(provider.CacheData.Service) + t.Run("validate Oid return", func(t *testing.T) { + authData := provider.getAuthData(&ou) + assert.Equal(t, ou.Oid, authData) + }) +} +func TestOpenIdUserFromJSON(t *testing.T) { + ou := OpenIdUser{ + Id: "12345", + FirstName: "firstname", + LastName: "lastname", + Nickname: "nickname", + Email: "name@test.com", + } + + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceOpenid, + }, + } + + t.Run("valid OpenId user", func(t *testing.T) { + b, err := json.Marshal(ou) + require.NoError(t, err) + + _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) + require.NoError(t, err) + }) + + t.Run("empty body should fail without panic", func(t *testing.T) { + _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) + require.Error(t, err) + }) + + t.Run("test getUserFromIdToken", func(t *testing.T) { + header := "dummyHeader" + payload := "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlNjb3R0IiwiZmFtaWx5X25hbWUiOiJCaXNoZWwiLCJsb2NhbGUiOiJlbiIsImlhdCI6MTYwODI0OTg5MSwiZXhwIjoxNjA4MjUzNDkxfQ" + signature := "dummysignature" + + testToken := header + _, err := provider.GetUserFromIdToken(testToken) + require.Error(t, err) + + testToken = header + "." + payload + _, err = provider.GetUserFromIdToken(testToken) + require.Error(t, err) + + t.Run("non ascii string encoded in the payload", func(t *testing.T) { + cases := []struct { + payload string + expectedName string + }{ + { + payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6InRlc3TFiMWhxb4iLCJmYW1pbHlfbmFtZSI6IkJpc2hlbCIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNjA4MjQ5ODkxLCJleHAiOjE2MDgyNTM0OTF9", + expectedName: "testňšž", + }, + { + payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlNjb3R0IiwiZmFtaWx5X25hbWUiOiJCaXNoZWwiLCJsb2NhbGUiOiJlbiIsImlhdCI6MTYwODI0OTg5MSwiZXhwIjoxNjA4MjUzNDkxfQ", + expectedName: "Scott", + }, + { + payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6InRlc3TEjcSNxI0iLCJmYW1pbHlfbmFtZSI6IkJpc2hlbCIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNjA4MjQ5ODkxLCJleHAiOjE2MDgyNTM0OTF9", + expectedName: "testččč", + }, + } + for _, c := range cases { + testToken = header + "." + c.payload + "." + signature + user, err := provider.GetUserFromIdToken(testToken) + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, c.expectedName, user.FirstName) + } + }) + + }) +} + +func TestGetSSOSettings(t *testing.T) { + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceOpenid, + }, + } + validJSON := `{ + "issuer": "issuer", + "authorization_endpoint": "authorization_endpoint", + "token_endpoint": "token_endpoint", + "userinfo_endpoint": "userinfo_endpoint", + "jwks_uri": "jwks_uri", + "id_token_signing_alg_values_supported": ["RS256"] + }` + var validFunctionCalled int + validServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", "max-age=3600") + fmt.Fprintln(w, validJSON) + validFunctionCalled++ + })) + defer validServer.Close() + + validConfig := model.Config{ + OpenIdSettings: model.SSOSettings{ + Enable: model.NewBool(true), + Secret: model.NewString("secret string"), + Id: model.NewString("id"), + Scope: model.NewString("profile openid email"), + AuthEndpoint: model.NewString(""), + TokenEndpoint: model.NewString(""), + UserAPIEndpoint: model.NewString(""), + DiscoveryEndpoint: model.NewString(validServer.URL), + }, + } + + t.Run("Error", func(t *testing.T) { + errorFunctionCalled := 0 + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + errorFunctionCalled++ + w.Header().Add("Cache-Control", "max-age=3600") + http.Error(w, "Not found", 404) + })) + + errCfg := validConfig + errCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(errorServer.URL) + _, err := provider.GetSSOSettings(&errCfg, model.ServiceOpenid) + assert.Error(t, err) + assert.Equal(t, 1, errorFunctionCalled) + }) + + t.Run("UseCache", func(t *testing.T) { + validFunctionCalled = 0 + + settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) + assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) + assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) + assert.Equal(t, 1, validFunctionCalled) + // Should set cache + assert.Equal(t, provider.CacheData.Settings, *settings) + assert.True(t, provider.CacheData.Expires > 0) + currentCacheExpires := provider.CacheData.Expires + + // Call again should come from cache + settings, _ = provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + assert.Equal(t, provider.CacheData.Settings, *settings) + assert.Equal(t, currentCacheExpires, provider.CacheData.Expires) + // should still be 1 + assert.Equal(t, 1, validFunctionCalled) + }) + + t.Run("CacheExpired", func(t *testing.T) { + // reset to original cache settings + settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + // Should set cache + assert.Equal(t, provider.CacheData.Settings, *settings) + + // set cache to expired + provider.CacheData.Expires = time.Now().Add(time.Duration(-1) * time.Minute).Unix() + + // same config, should call endpoint + validFunctionCalled = 0 + provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + assert.Equal(t, 1, validFunctionCalled) + assert.True(t, provider.CacheData.Expires > time.Now().Unix()) + }) + + t.Run("NoCache", func(t *testing.T) { + noCacheFunctionCalled := 0 + noCacheServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, validJSON) + noCacheFunctionCalled++ + })) + defer noCacheServer.Close() + + newCfg := validConfig + newCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(noCacheServer.URL) + + settings, err := provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) + assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) + assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) + assert.Equal(t, 1, noCacheFunctionCalled) + // Should set cache + assert.Equal(t, provider.CacheData.Settings, *settings) + // Cache Expires, set, less than, equal now. + assert.True(t, provider.CacheData.Expires <= time.Now().Unix()) + + // Call again, should call server again + _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, 2, noCacheFunctionCalled) + }) + + t.Run("ChangeService", func(t *testing.T) { + // reset to original cache settings + settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + // Should set cache + assert.Equal(t, provider.CacheData.Settings, *settings) + assert.True(t, provider.CacheData.Expires > time.Now().Unix()) + + // create identical setting for Google + googleCfg := model.Config{ + GoogleSettings: model.SSOSettings{}, + } + googleCfg.GoogleSettings = validConfig.OpenIdSettings + + // call with different service, same config settings + validFunctionCalled = 0 + provider.GetSSOSettings(&googleCfg, model.ServiceGoogle) + assert.Equal(t, model.ServiceGoogle, provider.CacheData.Service) + assert.Equal(t, 1, validFunctionCalled) + }) + + t.Run("ChangeConfigSettings", func(t *testing.T) { + secondFunctionCalled := 0 + secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", "max-age=3600") + fmt.Fprintln(w, validJSON) + secondFunctionCalled++ + })) + defer secondServer.Close() + + newCfg := validConfig + newCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(secondServer.URL) + + // new URL + settings, err := provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) + assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) + assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) + assert.Equal(t, 1, secondFunctionCalled) + + // new secret + newCfg.OpenIdSettings.Secret = model.NewString("NewSecret") + _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, newCfg.OpenIdSettings.Secret, provider.CacheData.Settings.Secret) + assert.Equal(t, 2, secondFunctionCalled) + + // new Id + newCfg.OpenIdSettings.Id = model.NewString("NewId") + _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, newCfg.OpenIdSettings.Id, provider.CacheData.Settings.Id) + assert.Equal(t, 3, secondFunctionCalled) + }) +} + +func TestCacheControlPanic(t *testing.T) { + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceOpenid, + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "no header") + })) + defer ts.Close() + + cfg := &model.Config{ + OpenIdSettings: model.SSOSettings{ + DiscoveryEndpoint: model.NewString(ts.URL), + }, + } + + require.NotPanics(t, func() { + provider.GetSSOSettings(cfg, model.ServiceOpenid) + }) +} + +func TestIsSameUser(t *testing.T) { + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceOpenid, + }, + } + cases := []struct { + dbUser model.User + oauthUser model.User + verified bool + }{ + {model.User{AuthData: model.NewString("202993a800824dc1b4496d598d47c58a")}, model.User{AuthData: model.NewString("202993a8-0082-4dc1-b449-6d598d47c58a")}, true}, + {model.User{AuthData: model.NewString("202993a85a824dc1b4496d598d47c58a")}, model.User{AuthData: model.NewString("")}, false}, + {model.User{AuthData: model.NewString("")}, model.User{AuthData: model.NewString("202993a8-5a82-4dc1-b449-6d598d47c58a")}, false}, + {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be95-fe607df5dbeb")}, true}, + {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be90-fe607df5dbeb")}, false}, + {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be95-fe607df5dbe0")}, false}, + {model.User{AuthData: model.NewString("hello")}, model.User{}, false}, + } + for _, c := range cases { + verified := provider.IsSameUser(&c.dbUser, &c.oauthUser) + if verified != c.verified { + if c.verified { + t.Logf("'%v' should have matched '%v'", c.dbUser, c.oauthUser) + } else { + t.Logf("'%v' should not have matched '%v'", c.dbUser, c.oauthUser) + } + t.FailNow() + } + } +} From 65e731e1b77c66124c4a30a4e3ed7cf8cb1487db Mon Sep 17 00:00:00 2001 From: Ben Cooke Date: Tue, 22 Nov 2022 08:43:26 -0500 Subject: [PATCH 02/80] [MM-42335] Fix in RenderMobileError (#21536) * tools updates * Revert "tools updates" This reverts commit 6293297b55803c5a263e200ebd80192899666ae9. * fix Co-authored-by: Benjamin Cooke Co-authored-by: Benjamin Cooke Co-authored-by: Benjamin Cooke Co-authored-by: Benjamin Cooke --- utils/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/api.go b/utils/api.go index 4680c66fb9..11d3767de1 100644 --- a/utils/api.go +++ b/utils/api.go @@ -101,7 +101,7 @@ func RenderMobileAuthComplete(w http.ResponseWriter, redirectURL string) { } func RenderMobileError(config *model.Config, w http.ResponseWriter, err *model.AppError, redirectURL string) { - var link = redirectURL + var link = template.HTMLEscapeString(redirectURL) var invalidSchemes = map[string]bool{ "data": true, "javascript": true, From afc2dcebe119cd2724123337db729bc9619ba685 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Tue, 22 Nov 2022 09:09:11 -0500 Subject: [PATCH 03/80] Update minimum supported MacOS and Edge versions (#21520) Automatic Merge --- i18n/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i18n/en.json b/i18n/en.json index b1c57495a7..9943569b7d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -9509,7 +9509,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_os_version.windows", From 76f7872a50b755c734bf29247838e18b4a819b03 Mon Sep 17 00:00:00 2001 From: Ben Cooke Date: Tue, 22 Nov 2022 11:31:04 -0500 Subject: [PATCH 04/80] [MM-46692] Channel group member count (#21270) * tools updates * Revert "tools updates" This reverts commit 6293297b55803c5a263e200ebd80192899666ae9. * adding channel member count to groups request * fixing models * adding a new test * removing unused var Co-authored-by: Benjamin Cooke Co-authored-by: Benjamin Cooke Co-authored-by: Benjamin Cooke Co-authored-by: Benjamin Cooke Co-authored-by: Mattermod --- api4/group.go | 39 +++++++++++-- api4/group_test.go | 22 +++++++ model/client4.go | 4 +- model/group.go | 29 ++++++---- store/sqlstore/group_store.go | 104 +++++++++++++++++++++++++--------- web/params.go | 2 + 6 files changed, 153 insertions(+), 47 deletions(-) diff --git a/api4/group.go b/api4/group.go index 343db5ee7e..78c606a448 100644 --- a/api4/group.go +++ b/api4/group.go @@ -950,12 +950,13 @@ func getGroupsAssociatedToChannelsByTeam(c *Context, w http.ResponseWriter, r *h } func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { + var teamID, NotAssociatedToChannelID, ChannelIDForMemberCount string + permissionErr := requireLicense(c) if permissionErr != nil { c.Err = permissionErr return } - var teamID, channelID string source := c.Params.GroupSource @@ -964,7 +965,11 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } if id := c.Params.NotAssociatedToChannel; model.IsValidId(id) { - channelID = id + NotAssociatedToChannelID = id + } + + if id := c.Params.IncludeChannelMemberCount; model.IsValidId(id) { + ChannelIDForMemberCount = id } // If they specify the group_source as custom when the feature is disabled, throw an error @@ -979,6 +984,8 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { source = model.GroupSourceLdap } + includeTimezones := r.URL.Query().Get("include_timezones") == "true" + opts := model.GroupSearchOpts{ Q: c.Params.Q, IncludeMemberCount: c.Params.IncludeMemberCount, @@ -986,6 +993,7 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { FilterParentTeamPermitted: c.Params.FilterParentTeamPermitted, Source: source, FilterHasMember: c.Params.FilterHasMember, + IncludeTimezones: includeTimezones, } if teamID != "" { @@ -998,8 +1006,8 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { opts.NotAssociatedToTeam = teamID } - if channelID != "" { - channel, appErr := c.App.GetChannel(c.AppContext, channelID) + if NotAssociatedToChannelID != "" { + channel, appErr := c.App.GetChannel(c.AppContext, NotAssociatedToChannelID) if appErr != nil { c.Err = appErr return @@ -1010,11 +1018,30 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } else { permission = model.PermissionManagePublicChannelMembers } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelID, permission) { + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), NotAssociatedToChannelID, permission) { c.SetPermissionError(permission) return } - opts.NotAssociatedToChannel = channelID + opts.NotAssociatedToChannel = NotAssociatedToChannelID + } + + if ChannelIDForMemberCount != "" { + channel, appErr := c.App.GetChannel(c.AppContext, ChannelIDForMemberCount) + if appErr != nil { + c.Err = appErr + return + } + var permission *model.Permission + if channel.Type == model.ChannelTypePrivate { + permission = model.PermissionManagePrivateChannelMembers + } else { + permission = model.PermissionManagePublicChannelMembers + } + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), ChannelIDForMemberCount, permission) { + c.SetPermissionError(permission) + return + } + opts.IncludeChannelMemberCount = ChannelIDForMemberCount } sinceString := r.URL.Query().Get("since") diff --git a/api4/group_test.go b/api4/group_test.go index 468e043d4e..a612291d8c 100644 --- a/api4/group_test.go +++ b/api4/group_test.go @@ -1291,6 +1291,28 @@ func TestGetGroups(t *testing.T) { assert.Len(t, groups, 1) assert.Equal(t, groups[0].Id, group2.Id) + // Test IncludeChannelMemberCount url param is working + opts.IncludeChannelMemberCount = th.BasicChannel.Id + opts.IncludeTimezones = true + opts.Q = "-fOo" + opts.IncludeMemberCount = true + + groups, _, _ = th.SystemAdminClient.GetGroups(opts) + assert.Equal(t, *groups[0].MemberCount, int(0)) + assert.Equal(t, *groups[0].ChannelMemberCount, int(0)) + + _, appErr = th.App.UpsertGroupMember(group2.Id, th.BasicUser.Id) + assert.Nil(t, appErr) + + groups, _, _ = th.SystemAdminClient.GetGroups(opts) + assert.NotNil(t, groups[0].MemberCount) + assert.Equal(t, *groups[0].ChannelMemberCount, int(1)) + + opts.IncludeChannelMemberCount = "" + opts.IncludeTimezones = false + opts.Q = "" + opts.IncludeMemberCount = false + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomGroups = false }) diff --git a/model/client4.go b/model/client4.go index 24ffc859ca..d195bec920 100644 --- a/model/client4.go +++ b/model/client4.go @@ -5515,7 +5515,7 @@ func (c *Client4) GetGroupsAssociatedToChannelsByTeam(teamId string, opts GroupS // GetGroups retrieves Mattermost Groups func (c *Client4) GetGroups(opts GroupSearchOpts) ([]*Group, *Response, error) { path := fmt.Sprintf( - "%s?include_member_count=%v¬_associated_to_team=%v¬_associated_to_channel=%v&filter_allow_reference=%v&q=%v&filter_parent_team_permitted=%v&group_source=%v", + "%s?include_member_count=%v¬_associated_to_team=%v¬_associated_to_channel=%v&filter_allow_reference=%v&q=%v&filter_parent_team_permitted=%v&group_source=%v&include_channel_member_count=%v&include_timezones=%v", c.groupsRoute(), opts.IncludeMemberCount, opts.NotAssociatedToTeam, @@ -5524,6 +5524,8 @@ func (c *Client4) GetGroups(opts GroupSearchOpts) ([]*Group, *Response, error) { opts.Q, opts.FilterParentTeamPermitted, opts.Source, + opts.IncludeChannelMemberCount, + opts.IncludeTimezones, ) if opts.Since > 0 { path = fmt.Sprintf("%s&since=%v", path, opts.Since) diff --git a/model/group.go b/model/group.go index 20d9533401..530eab88f7 100644 --- a/model/group.go +++ b/model/group.go @@ -31,18 +31,20 @@ var groupSourcesRequiringRemoteID = []GroupSource{ } type Group struct { - Id string `json:"id"` - Name *string `json:"name,omitempty"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - Source GroupSource `json:"source"` - RemoteId *string `json:"remote_id"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - HasSyncables bool `db:"-" json:"has_syncables"` - MemberCount *int `db:"-" json:"member_count,omitempty"` - AllowReference bool `json:"allow_reference"` + Id string `json:"id"` + Name *string `json:"name,omitempty"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Source GroupSource `json:"source"` + RemoteId *string `json:"remote_id"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + HasSyncables bool `db:"-" json:"has_syncables"` + MemberCount *int `db:"-" json:"member_count,omitempty"` + AllowReference bool `json:"allow_reference"` + ChannelMemberCount *int `db:"-" json:"channel_member_count,omitempty"` + ChannelMemberTimezonesCount *int `db:"-" json:"channel_member_timezones_count,omitempty"` } func (group *Group) Auditable() map[string]interface{} { @@ -113,6 +115,9 @@ type GroupSearchOpts struct { // FilterHasMember filters the groups to the intersect of the // set returned by the query and those that have the given user as a member. FilterHasMember string + + IncludeChannelMemberCount string + IncludeTimezones bool } type GetGroupOpts struct { diff --git a/store/sqlstore/group_store.go b/store/sqlstore/group_store.go index 40866d2a7a..64737840b8 100644 --- a/store/sqlstore/group_store.go +++ b/store/sqlstore/group_store.go @@ -1056,34 +1056,38 @@ func (s *SqlGroupStore) CountGroupsByChannel(channelId string, opts model.GroupS } type group struct { - Id string - Name *string - DisplayName string - Description string - Source model.GroupSource - RemoteId *string - CreateAt int64 - UpdateAt int64 - DeleteAt int64 - HasSyncables bool - MemberCount *int - AllowReference bool + Id string + Name *string + DisplayName string + Description string + Source model.GroupSource + RemoteId *string + CreateAt int64 + UpdateAt int64 + DeleteAt int64 + HasSyncables bool + MemberCount *int + AllowReference bool + ChannelMemberCount *int + ChannelMemberTimezonesCount *int } func (g group) ToModel() *model.Group { return &model.Group{ - Id: g.Id, - Name: g.Name, - DisplayName: g.DisplayName, - Description: g.Description, - Source: g.Source, - RemoteId: g.RemoteId, - CreateAt: g.CreateAt, - UpdateAt: g.UpdateAt, - DeleteAt: g.DeleteAt, - HasSyncables: g.HasSyncables, - AllowReference: g.AllowReference, - MemberCount: g.MemberCount, + Id: g.Id, + Name: g.Name, + DisplayName: g.DisplayName, + Description: g.Description, + Source: g.Source, + RemoteId: g.RemoteId, + CreateAt: g.CreateAt, + UpdateAt: g.UpdateAt, + DeleteAt: g.DeleteAt, + HasSyncables: g.HasSyncables, + AllowReference: g.AllowReference, + MemberCount: g.MemberCount, + ChannelMemberCount: g.ChannelMemberCount, + ChannelMemberTimezonesCount: g.ChannelMemberTimezonesCount, } } @@ -1416,7 +1420,20 @@ func (s *SqlGroupStore) GetGroupsAssociatedToChannelsByTeam(teamId string, opts func (s *SqlGroupStore) GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, error) { groupsVar := groups{} - groupsQuery := s.getQueryBuilder().Select("g.*") + selectQuery := []string{"g.*"} + + if opts.IncludeMemberCount { + selectQuery = append(selectQuery, "coalesce(Members.MemberCount, 0) AS MemberCount") + } + + if opts.IncludeChannelMemberCount != "" { + selectQuery = append(selectQuery, "coalesce(ChannelMembers.ChannelMemberCount, 0) AS ChannelMemberCount") + if opts.IncludeTimezones { + selectQuery = append(selectQuery, "coalesce(ChannelMembers.ChannelMemberTimezonesCount, 0) AS ChannelMemberTimezonesCount") + } + } + + groupsQuery := s.getQueryBuilder().Select(strings.Join(selectQuery, ", ")) if opts.IncludeMemberCount { countQuery := s.getQueryBuilder(). @@ -1433,12 +1450,43 @@ func (s *SqlGroupStore) GetGroups(page, perPage int, opts model.GroupSearchOpts, if err != nil { return nil, errors.Wrap(err, "get_groups_tosql") } - - groupsQuery = s.getQueryBuilder(). - Select("g.*, coalesce(Members.MemberCount, 0) AS MemberCount"). + groupsQuery = groupsQuery. LeftJoin("("+countString+") AS Members ON Members.GroupId = g.Id", params...) } + if opts.IncludeChannelMemberCount != "" { + selectStr := "GroupMembers.GroupId, COUNT(ChannelMembers.UserId) AS ChannelMemberCount" + joinStr := "" + + if opts.IncludeTimezones { + if s.DriverName() == model.DatabaseDriverMysql { + selectStr += `, + COUNT(DISTINCT + ( + CASE WHEN JSON_EXTRACT(Timezone, '$.useAutomaticTimezone') = 'true' AND LENGTH(JSON_UNQUOTE(JSON_EXTRACT(Timezone, '$.automaticTimezone'))) > 0 + THEN JSON_EXTRACT(Timezone, '$.automaticTimezone') + WHEN JSON_EXTRACT(Timezone, '$.useAutomaticTimezone') = 'false' AND LENGTH(JSON_UNQUOTE(JSON_EXTRACT(Timezone, '$.manualTimezone'))) > 0 + THEN JSON_EXTRACT(Timezone, '$.manualTimezone') + END + )) AS ChannelMemberTimezonesCount` + } else if s.DriverName() == model.DatabaseDriverPostgres { + selectStr += `, + COUNT(DISTINCT + ( + CASE WHEN Timezone->>'useAutomaticTimezone' = 'true' AND length(Timezone->>'automaticTimezone') > 0 + THEN Timezone->>'automaticTimezone' + WHEN Timezone->>'useAutomaticTimezone' = 'false' AND length(Timezone->>'manualTimezone') > 0 + THEN Timezone->>'manualTimezone' + END + )) AS ChannelMemberTimezonesCount` + } + joinStr = "LEFT JOIN Users ON Users.Id = GroupMembers.UserId" + } + + groupsQuery = groupsQuery. + LeftJoin("(SELECT "+selectStr+" FROM ChannelMembers LEFT JOIN GroupMembers ON GroupMembers.UserId = ChannelMembers.UserId AND GroupMembers.DeleteAt = 0 "+joinStr+" WHERE ChannelMembers.ChannelId = ? GROUP BY GroupId) AS ChannelMembers ON ChannelMembers.GroupId = g.Id", opts.IncludeChannelMemberCount) + } + if opts.FilterHasMember != "" { groupsQuery = groupsQuery. LeftJoin("GroupMembers ON GroupMembers.GroupId = g.Id"). diff --git a/web/params.go b/web/params.go index 20c944355b..64e8227119 100644 --- a/web/params.go +++ b/web/params.go @@ -90,6 +90,7 @@ type Params struct { ExcludePolicyConstrained bool GroupSource model.GroupSource FilterHasMember string + IncludeChannelMemberCount string // Cloud InvoiceId string @@ -208,6 +209,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.NotAssociatedToChannel = query.Get("not_associated_to_channel") params.FilterAllowReference, _ = strconv.ParseBool(query.Get("filter_allow_reference")) params.FilterParentTeamPermitted, _ = strconv.ParseBool(query.Get("filter_parent_team_permitted")) + params.IncludeChannelMemberCount = query.Get("include_channel_member_count") if val, err := strconv.ParseBool(query.Get("paginate")); err == nil { params.Paginate = &val From c78b78ed5eba76eafb95c2a1aa1d6f3f34555b30 Mon Sep 17 00:00:00 2001 From: Vishal Date: Tue, 22 Nov 2022 22:32:33 +0530 Subject: [PATCH 05/80] [MM-5046] Fix emails search for Postgres DB (#21590) * Support emails search in Postgres * Add @ exception for postgres * update comment Co-authored-by: Mattermod --- store/searchtest/post_layer.go | 48 ++++++++++++++++--------------- store/sqlstore/file_info_store.go | 3 +- store/sqlstore/post_store.go | 15 +--------- store/sqlstore/store.go | 22 ++++++++++++++ 4 files changed, 49 insertions(+), 39 deletions(-) diff --git a/store/searchtest/post_layer.go b/store/searchtest/post_layer.go index 1d03edf7f7..5bfa05a781 100644 --- a/store/searchtest/post_layer.go +++ b/store/searchtest/post_layer.go @@ -35,9 +35,16 @@ var searchPostStoreTests = []searchTest{ Tags: []string{EnginePostgres, EngineMySql, EngineElasticSearch}, }, { + // Postgres supports search with and without quotes Name: "Should be able to search for email addresses with or without quotes", Fn: testSearchEmailAddresses, - Tags: []string{EngineElasticSearch}, + Tags: []string{EnginePostgres, EngineElasticSearch}, + }, + { + // MySql supports search with quotes only + Name: "Should be able to search for email addresses with quotes", + Fn: testSearchEmailAddressesWithQuotes, + Tags: []string{EngineMySql}, }, { Name: "Should be able to search when markdown underscores are applied", @@ -242,11 +249,6 @@ var searchPostStoreTests = []searchTest{ Fn: testSlashShouldNotBeCharSeparator, Tags: []string{EngineMySql, EngineElasticSearch}, }, - { - Name: "Should be able to search emails without quoting them", - Fn: testSearchEmailsWithoutQuotes, - Tags: []string{EngineElasticSearch}, - }, { Name: "Should be able to search in comments", Fn: testSupportSearchInComments, @@ -366,9 +368,9 @@ func testSearchExactPhraseInQuotes(t *testing.T, th *SearchTestHelper) { } func testSearchEmailAddresses(t *testing.T, th *SearchTestHelper) { - p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "test email test@test.com", "", model.PostTypeDefault, 0, false) + p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "email test@test.com", "", model.PostTypeDefault, 0, false) require.NoError(t, err) - _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "test email test2@test.com", "", model.PostTypeDefault, 0, false) + _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "email test2@test.com", "", model.PostTypeDefault, 0, false) require.NoError(t, err) defer th.deleteUserPosts(th.User.Id) @@ -391,6 +393,21 @@ func testSearchEmailAddresses(t *testing.T, th *SearchTestHelper) { }) } +func testSearchEmailAddressesWithQuotes(t *testing.T, th *SearchTestHelper) { + p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "email test@test.com", "", model.PostTypeDefault, 0, false) + require.NoError(t, err) + _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "email test2@test.com", "", model.PostTypeDefault, 0, false) + require.NoError(t, err) + defer th.deleteUserPosts(th.User.Id) + + params := &model.SearchParams{Terms: "\"test@test.com\""} + results, err := th.Store.Post().SearchPostsForUser([]*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20) + require.NoError(t, err) + + require.Len(t, results.Posts, 1) + th.checkPostInSearchResults(t, p1.Id, results.Posts) +} + func testSearchMarkdownUnderscores(t *testing.T, th *SearchTestHelper) { p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "_start middle end_ _another_", "", model.PostTypeDefault, 0, false) require.NoError(t, err) @@ -1769,21 +1786,6 @@ func testSlashShouldNotBeCharSeparator(t *testing.T, th *SearchTestHelper) { th.checkPostInSearchResults(t, p1.Id, results.Posts) } -func testSearchEmailsWithoutQuotes(t *testing.T, th *SearchTestHelper) { - p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "message test@test.com", "", model.PostTypeDefault, 0, false) - require.NoError(t, err) - _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "message test2@test.com", "", model.PostTypeDefault, 0, false) - require.NoError(t, err) - defer th.deleteUserPosts(th.User.Id) - - params := &model.SearchParams{Terms: "test@test.com"} - results, err := th.Store.Post().SearchPostsForUser([]*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20) - require.NoError(t, err) - - require.Len(t, results.Posts, 1) - th.checkPostInSearchResults(t, p1.Id, results.Posts) -} - func testSupportSearchInComments(t *testing.T, th *SearchTestHelper) { p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "message test@test.com", "", model.PostTypeDefault, 0, false) require.NoError(t, err) diff --git a/store/sqlstore/file_info_store.go b/store/sqlstore/file_info_store.go index 47d847b9d1..49403e4d98 100644 --- a/store/sqlstore/file_info_store.go +++ b/store/sqlstore/file_info_store.go @@ -606,8 +606,7 @@ func (fs SqlFileInfoStore) Search(paramsList []*model.SearchParams, userId, team terms := params.Terms excludedTerms := params.ExcludedTerms - // these chars have special meaning and can be treated as spaces - for _, c := range specialSearchChar { + for _, c := range fs.specialSearchChars() { terms = strings.Replace(terms, c, " ", -1) excludedTerms = strings.Replace(excludedTerms, c, " ", -1) } diff --git a/store/sqlstore/post_store.go b/store/sqlstore/post_store.go index 127e566861..5afdddac63 100644 --- a/store/sqlstore/post_store.go +++ b/store/sqlstore/post_store.go @@ -1788,18 +1788,6 @@ func (s *SqlPostStore) getParentsPostsPostgreSQL(channelId string, offset int, l return posts, nil } -var specialSearchChar = []string{ - "<", - ">", - "+", - "-", - "(", - ")", - "~", - "@", - ":", -} - // GetNthRecentPostTime returns the CreateAt time of the nth most recent post. func (s *SqlPostStore) GetNthRecentPostTime(n int64) (int64, error) { if n <= 0 { @@ -1989,8 +1977,7 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search } } - // these chars have special meaning and can be treated as spaces - for _, c := range specialSearchChar { + for _, c := range s.specialSearchChars() { terms = strings.Replace(terms, c, " ", -1) excludedTerms = strings.Replace(excludedTerms, c, " ", -1) } diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 71f9598ed0..2730ece664 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -335,6 +335,28 @@ func (ss *SqlStore) DriverName() string { return *ss.settings.DriverName } +// specialSearchChars have special meaning and can be treated as spaces +func (ss *SqlStore) specialSearchChars() []string { + chars := []string{ + "<", + ">", + "+", + "-", + "(", + ")", + "~", + ":", + } + + // Postgres can handle "@" without any errors + // Also helps postgres in enabling search for EmailAddresses + if ss.DriverName() != model.DatabaseDriverPostgres { + chars = append(chars, "@") + } + + return chars +} + // computeBinaryParam returns whether the data source uses binary_parameters // when using Postgres func (ss *SqlStore) computeBinaryParam() (bool, error) { From a1e16f7b0292b9b083c89395249e71072c868835 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Tue, 22 Nov 2022 13:22:45 -0500 Subject: [PATCH 06/80] Call Cloud HandleLicenseChange when license is changed in Cloud (#21583) * Call Cloud HandleLicenseChange when license is changed in a cloud context * Remove UpdateSubscriptionFromHook as its no longer necessary * Update mocks * Remove another reference * Remove translation Co-authored-by: Mattermod --- api4/cloud.go | 18 ------------------ api4/license.go | 2 +- einterfaces/cloud.go | 2 +- einterfaces/mocks/CloudInterface.go | 28 ++++++++++++++-------------- i18n/en.json | 4 ---- 5 files changed, 16 insertions(+), 38 deletions(-) diff --git a/api4/cloud.go b/api4/cloud.go index 4537828afb..3e38324b06 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -13,7 +13,6 @@ import ( "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) @@ -630,23 +629,6 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, err.Error(), http.StatusInternalServerError) return } - case model.EventTypeSubscriptionChanged: - // event.ProductLimits is nil if there was no change - if event.ProductLimits != nil { - if pluginsEnvironment := c.App.GetPluginsEnvironment(); pluginsEnvironment != nil { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.OnCloudLimitsUpdated(event.ProductLimits) - return true - }, plugin.OnCloudLimitsUpdatedID) - } - c.App.AdjustInProductLimits(event.ProductLimits, event.Subscription) - } - - if err := c.App.Cloud().UpdateSubscriptionFromHook(event.ProductLimits, event.Subscription); err != nil { - c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.subscription.update_error", nil, err.Error(), http.StatusInternalServerError) - return - } - c.Logger.Info("Updated subscription from webhook event") case model.EventTypeTriggerDelinquencyEmail: var emailToTrigger model.DelinquencyEmail if event.DelinquencyEmail != nil { diff --git a/api4/license.go b/api4/license.go index 15c0b34fa8..bc89f7e1e2 100644 --- a/api4/license.go +++ b/api4/license.go @@ -139,7 +139,7 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { if c.App.Channels().License().IsCloud() { // If cloud, invalidate the caches when a new license is loaded - defer c.App.Srv().Cloud.InvalidateCaches() + defer c.App.Srv().Cloud.HandleLicenseChange() } auditRec.Success() diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 26e7f3c5ab..854b54fdf5 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -10,7 +10,6 @@ import ( type CloudInterface interface { GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) GetCloudLimits(userID string) (*model.ProductLimits, error) - UpdateSubscriptionFromHook(*model.ProductLimits, *model.Subscription) error CreateCustomerPayment(userID string) (*model.StripeSetupIntent, error) ConfirmCustomerPayment(userID string, confirmRequest *model.ConfirmPaymentMethodRequest) error @@ -31,4 +30,5 @@ type CloudInterface interface { // GetLicenseRenewalStatus checks on the portal whether it is possible to use token to renew a license GetLicenseRenewalStatus(userID, token string) error InvalidateCaches() error + HandleLicenseChange() error } diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index 1e5ba2b0a1..3157ecfc44 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -233,6 +233,20 @@ func (_m *CloudInterface) GetSubscription(userID string) (*model.Subscription, e return r0, r1 } +// HandleLicenseChange provides a mock function with given fields: +func (_m *CloudInterface) HandleLicenseChange() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + // InvalidateCaches provides a mock function with given fields: func (_m *CloudInterface) InvalidateCaches() error { ret := _m.Called() @@ -316,20 +330,6 @@ func (_m *CloudInterface) UpdateCloudCustomerAddress(userID string, address *mod return r0, r1 } -// UpdateSubscriptionFromHook provides a mock function with given fields: _a0, _a1 -func (_m *CloudInterface) UpdateSubscriptionFromHook(_a0 *model.ProductLimits, _a1 *model.Subscription) error { - ret := _m.Called(_a0, _a1) - - var r0 error - if rf, ok := ret.Get(0).(func(*model.ProductLimits, *model.Subscription) error); ok { - r0 = rf(_a0, _a1) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // ValidateBusinessEmail provides a mock function with given fields: userID, email func (_m *CloudInterface) ValidateBusinessEmail(userID string, email string) error { ret := _m.Called(userID, email) diff --git a/i18n/en.json b/i18n/en.json index 9943569b7d..b9ebaa8eb6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -487,10 +487,6 @@ "id": "api.cloud.request_error", "translation": "Error processing request to CWS." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Error updating subscription from webhook." - }, { "id": "api.cloud.teams_limit_reached.create", "translation": "Unable to create team because teams limit has been reached" From 0509e78744bfc8a5375834744a20199efe232d10 Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Tue, 22 Nov 2022 15:26:22 -0600 Subject: [PATCH 07/80] [MM-48523] Expose resumable uploads API to plugins (#21700) * Expose resumable uploads API to plugins * Update translations --- api4/upload.go | 7 +++ api4/upload_test.go | 11 ++++ app/plugin_api.go | 24 ++++++++ app/plugin_api_test.go | 95 +++++++++++++++++++++++++++++ app/upload.go | 5 -- app/upload_test.go | 10 --- i18n/en.json | 8 +-- plugin/api.go | 18 ++++++ plugin/api_timer_layer_generated.go | 21 +++++++ plugin/client_rpc.go | 52 ++++++++++++++++ plugin/client_rpc_generated.go | 60 ++++++++++++++++++ plugin/interface_generator/main.go | 1 + plugin/plugintest/api.go | 69 +++++++++++++++++++++ 13 files changed, 362 insertions(+), 19 deletions(-) diff --git a/api4/upload.go b/api4/upload.go index e5800df9b3..a89bc1149c 100644 --- a/api4/upload.go +++ b/api4/upload.go @@ -65,6 +65,13 @@ func createUpload(c *Context, w http.ResponseWriter, r *http.Request) { if c.AppContext.Session().UserId != "" { us.UserId = c.AppContext.Session().UserId } + + if us.FileSize > *c.App.Config().FileSettings.MaxFileSize { + c.Err = model.NewAppError("createUpload", "api.upload.create.upload_too_large.app_error", + map[string]any{"channelId": us.ChannelId}, "", http.StatusRequestEntityTooLarge) + return + } + rus, err := c.App.CreateUploadSession(c.AppContext, &us) if err != nil { c.Err = err diff --git a/api4/upload_test.go b/api4/upload_test.go index e373ecf5f3..9cf4e44b90 100644 --- a/api4/upload_test.go +++ b/api4/upload_test.go @@ -45,6 +45,17 @@ func TestCreateUpload(t *testing.T) { require.Equal(t, http.StatusForbidden, resp.StatusCode) }) + t.Run("FileSize over limit", func(t *testing.T) { + maxFileSize := *th.App.Config().FileSettings.MaxFileSize + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = us.FileSize - 1 }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize }) + us.ChannelId = th.BasicChannel.Id + u, resp, err := th.Client.CreateUpload(us) + require.Nil(t, u) + CheckErrorID(t, err, "api.upload.create.upload_too_large.app_error") + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) + }) + t.Run("not allowed in cloud", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicense("cloud")) defer th.App.Srv().RemoveLicense() diff --git a/app/plugin_api.go b/app/plugin_api.go index e302e6a860..accedf37ba 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -1237,3 +1237,27 @@ func (api *PluginAPI) GetCloudLimits() (*model.ProductLimits, error) { func (api *PluginAPI) RegisterCollectionAndTopic(collectionType, topicType string) error { return api.app.registerCollectionAndTopic(api.id, collectionType, topicType) } + +func (api *PluginAPI) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { + us, err := api.app.CreateUploadSession(api.ctx, us) + if err != nil { + return nil, err + } + return us, nil +} + +func (api *PluginAPI) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) { + fi, err := api.app.UploadData(api.ctx, us, rd) + if err != nil { + return nil, err + } + return fi, nil +} + +func (api *PluginAPI) GetUploadSession(uploadID string) (*model.UploadSession, error) { + fi, err := api.app.GetUploadSession(uploadID) + if err != nil { + return nil, err + } + return fi, nil +} diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index 232eb9f9a4..9f864109bf 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -2100,3 +2100,98 @@ func TestRegisterCollectionAndTopic(t *testing.T) { err = api.RegisterCollectionAndTopic("some other collection", "topicToBeRepeated") assert.Error(t, err) } + +func TestPluginUploadsAPI(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + pluginCode := fmt.Sprintf(` + package main + + import ( + "fmt" + "bytes" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/plugin" + ) + + type TestPlugin struct { + plugin.MattermostPlugin + } + + func (p *TestPlugin) OnActivate() error { + data := []byte("some content to upload") + us, err := p.API.CreateUploadSession(&model.UploadSession{ + Id: "%s", + UserId: "%s", + ChannelId: "%s", + Type: model.UploadTypeAttachment, + FileSize: int64(len(data)), + Filename: "upload.test", + }) + if err != nil { + return fmt.Errorf("failed to create upload session: %%w", err) + } + + us2, err := p.API.GetUploadSession(us.Id) + if err != nil { + return fmt.Errorf("failed to get upload session: %%w", err) + } + + if us.Id != us2.Id { + return fmt.Errorf("upload sessions should match") + } + + fi, err := p.API.UploadData(us, bytes.NewBuffer(data)) + if err != nil { + return fmt.Errorf("failed to upload data: %%w", err) + } + + if fi == nil || fi.Id == "" { + return fmt.Errorf("fileinfo should be set") + } + + fileData, appErr := p.API.GetFile(fi.Id) + if appErr != nil { + return fmt.Errorf("failed to get file data: %%w", err) + } + + if !bytes.Equal(data, fileData) { + return fmt.Errorf("file data should match") + } + + return nil + } + + func main() { + plugin.ClientMain(&TestPlugin{}) + } + `, model.NewId(), th.BasicUser.Id, th.BasicChannel.Id) + + pluginDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + webappPluginDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + defer os.RemoveAll(pluginDir) + defer os.RemoveAll(webappPluginDir) + + newPluginAPI := func(manifest *model.Manifest) plugin.API { + return th.App.NewPluginAPI(th.Context, manifest) + } + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) + require.NoError(t, err) + + th.App.ch.SetPluginsEnvironment(env) + + pluginID := "testplugin" + pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` + backend := filepath.Join(pluginDir, pluginID, "backend.exe") + utils.CompileGo(t, pluginCode, backend) + + os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifest), 0600) + manifest, activated, reterr := env.Activate(pluginID) + require.NoError(t, reterr) + require.NotNil(t, manifest) + require.True(t, activated) +} diff --git a/app/upload.go b/app/upload.go index 3909f5e096..df5b329117 100644 --- a/app/upload.go +++ b/app/upload.go @@ -127,11 +127,6 @@ func (a *App) runPluginsHook(c *request.Context, info *model.FileInfo, file io.R } func (a *App) CreateUploadSession(c request.CTX, us *model.UploadSession) (*model.UploadSession, *model.AppError) { - if us.FileSize > *a.Config().FileSettings.MaxFileSize { - return nil, model.NewAppError("CreateUploadSession", "app.upload.create.upload_too_large.app_error", - map[string]any{"channelId": us.ChannelId}, "", http.StatusRequestEntityTooLarge) - } - us.FileOffset = 0 now := time.Now() us.CreateAt = model.GetMillisForTime(now) diff --git a/app/upload_test.go b/app/upload_test.go index 128f157912..7fef6a8691 100644 --- a/app/upload_test.go +++ b/app/upload_test.go @@ -32,16 +32,6 @@ func TestCreateUploadSession(t *testing.T) { FileSize: 8 * 1024 * 1024, } - t.Run("FileSize over limit", func(t *testing.T) { - maxFileSize := *th.App.Config().FileSettings.MaxFileSize - th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = us.FileSize - 1 }) - defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize }) - u, err := th.App.CreateUploadSession(th.Context, us) - require.NotNil(t, err) - require.Equal(t, "app.upload.create.upload_too_large.app_error", err.Id) - require.Nil(t, u) - }) - t.Run("invalid Id", func(t *testing.T) { u, err := th.App.CreateUploadSession(th.Context, us) require.NotNil(t, err) diff --git a/i18n/en.json b/i18n/en.json index b9ebaa8eb6..067f77b4fe 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3907,6 +3907,10 @@ "id": "api.upgrade_to_enterprise_status.signature.app_error", "translation": "Mattermost was unable to upgrade to Enterprise Edition. The digital signature of the downloaded binary file could not be verified." }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Unable to upload file. File is too large." + }, { "id": "api.upload.get_upload.forbidden.app_error", "translation": "Failed to get upload." @@ -6539,10 +6543,6 @@ "id": "app.upload.create.save.app_error", "translation": "Failed to save upload." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Unable to upload file. File is too large." - }, { "id": "app.upload.get.app_error", "translation": "Failed to get upload." diff --git a/plugin/api.go b/plugin/api.go index d3b49da50c..f777539d04 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -1168,6 +1168,24 @@ type API interface { // // Minimum server version: 7.6 RegisterCollectionAndTopic(collectionType, topicType string) error + + // CreateUploadSession creates and returns a new (resumable) upload session. + // + // @tag Upload + // Minimum server version: 7.6 + CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) + + // UploadData uploads the data for a given upload session. + // + // @tag Upload + // Minimum server version: 7.6 + UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) + + // GetUploadSession returns the upload session for the provided id. + // + // @tag Upload + // Minimum server version: 7.6 + GetUploadSession(uploadID string) (*model.UploadSession, error) } var handshake = plugin.HandshakeConfig{ diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index b64a2229ce..91303da798 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -1246,3 +1246,24 @@ func (api *apiTimerLayer) RegisterCollectionAndTopic(collectionType, topicType s api.recordTime(startTime, "RegisterCollectionAndTopic", _returnsA == nil) return _returnsA } + +func (api *apiTimerLayer) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.CreateUploadSession(us) + api.recordTime(startTime, "CreateUploadSession", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.UploadData(us, rd) + api.recordTime(startTime, "UploadData", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) GetUploadSession(uploadID string) (*model.UploadSession, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetUploadSession(uploadID) + api.recordTime(startTime, "GetUploadSession", _returnsB == nil) + return _returnsA, _returnsB +} diff --git a/plugin/client_rpc.go b/plugin/client_rpc.go index feea87005a..86805e1a3e 100644 --- a/plugin/client_rpc.go +++ b/plugin/client_rpc.go @@ -866,3 +866,55 @@ func (s *apiRPCServer) InstallPlugin(args *Z_InstallPluginArgs, returns *Z_Insta returns.A, returns.B = hook.InstallPlugin(pluginReader, args.B) return nil } + +type Z_UploadDataArgs struct { + A *model.UploadSession + PluginStreamID uint32 +} + +type Z_UploadDataReturns struct { + A *model.FileInfo + B error +} + +func (g *apiRPCClient) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) { + pluginStreamID := g.muxBroker.NextId() + + go func() { + pluginConnection, err := g.muxBroker.Accept(pluginStreamID) + if err != nil { + log.Print("Failed to upload data. MuxBroker could not Accept connection", mlog.Err(err)) + return + } + defer pluginConnection.Close() + serveIOReader(rd, pluginConnection) + }() + + _args := &Z_UploadDataArgs{us, pluginStreamID} + _returns := &Z_UploadDataReturns{} + if err := g.client.Call("Plugin.UploadData", _args, _returns); err != nil { + log.Print("RPC call UploadData to plugin failed.", mlog.Err(err)) + } + + return _returns.A, _returns.B +} + +func (s *apiRPCServer) UploadData(args *Z_UploadDataArgs, returns *Z_UploadDataReturns) error { + hook, ok := s.impl.(interface { + UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) + }) + if !ok { + return encodableError(fmt.Errorf("API UploadData called but not implemented")) + } + + receivePluginConnection, err := s.muxBroker.Dial(args.PluginStreamID) + if err != nil { + fmt.Fprintf(os.Stderr, "[ERROR] Can't connect to remote plugin stream, error: %v", err.Error()) + return err + } + pluginReader := connectIOReader(receivePluginConnection) + defer pluginReader.Close() + + returns.A, returns.B = hook.UploadData(args.A, pluginReader) + return nil +} diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index b442898080..c47e9958fe 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -5968,3 +5968,63 @@ func (s *apiRPCServer) RegisterCollectionAndTopic(args *Z_RegisterCollectionAndT } return nil } + +type Z_CreateUploadSessionArgs struct { + A *model.UploadSession +} + +type Z_CreateUploadSessionReturns struct { + A *model.UploadSession + B error +} + +func (g *apiRPCClient) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { + _args := &Z_CreateUploadSessionArgs{us} + _returns := &Z_CreateUploadSessionReturns{} + if err := g.client.Call("Plugin.CreateUploadSession", _args, _returns); err != nil { + log.Printf("RPC call to CreateUploadSession API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) CreateUploadSession(args *Z_CreateUploadSessionArgs, returns *Z_CreateUploadSessionReturns) error { + if hook, ok := s.impl.(interface { + CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) + }); ok { + returns.A, returns.B = hook.CreateUploadSession(args.A) + returns.B = encodableError(returns.B) + } else { + return encodableError(fmt.Errorf("API CreateUploadSession called but not implemented.")) + } + return nil +} + +type Z_GetUploadSessionArgs struct { + A string +} + +type Z_GetUploadSessionReturns struct { + A *model.UploadSession + B error +} + +func (g *apiRPCClient) GetUploadSession(uploadID string) (*model.UploadSession, error) { + _args := &Z_GetUploadSessionArgs{uploadID} + _returns := &Z_GetUploadSessionReturns{} + if err := g.client.Call("Plugin.GetUploadSession", _args, _returns); err != nil { + log.Printf("RPC call to GetUploadSession API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetUploadSession(args *Z_GetUploadSessionArgs, returns *Z_GetUploadSessionReturns) error { + if hook, ok := s.impl.(interface { + GetUploadSession(uploadID string) (*model.UploadSession, error) + }); ok { + returns.A, returns.B = hook.GetUploadSession(args.A) + returns.B = encodableError(returns.B) + } else { + return encodableError(fmt.Errorf("API GetUploadSession called but not implemented.")) + } + return nil +} diff --git a/plugin/interface_generator/main.go b/plugin/interface_generator/main.go index 0a14a092b9..0dd367f9cd 100644 --- a/plugin/interface_generator/main.go +++ b/plugin/interface_generator/main.go @@ -35,6 +35,7 @@ var excludedPluginHooks = []string{ "OnActivate", "PluginHTTP", "ServeHTTP", + "UploadData", } var excludedProductHooks = []string{ diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index a7982c650c..a7948cfd4b 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -391,6 +391,29 @@ func (_m *API) CreateTeamMembersGracefully(teamID string, userIds []string, requ return r0, r1 } +// CreateUploadSession provides a mock function with given fields: us +func (_m *API) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { + ret := _m.Called(us) + + var r0 *model.UploadSession + if rf, ok := ret.Get(0).(func(*model.UploadSession) *model.UploadSession); ok { + r0 = rf(us) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.UploadSession) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.UploadSession) error); ok { + r1 = rf(us) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateUser provides a mock function with given fields: user func (_m *API) CreateUser(user *model.User) (*model.User, *model.AppError) { ret := _m.Called(user) @@ -2181,6 +2204,29 @@ func (_m *API) GetUnsanitizedConfig() *model.Config { return r0 } +// GetUploadSession provides a mock function with given fields: uploadID +func (_m *API) GetUploadSession(uploadID string) (*model.UploadSession, error) { + ret := _m.Called(uploadID) + + var r0 *model.UploadSession + if rf, ok := ret.Get(0).(func(string) *model.UploadSession); ok { + r0 = rf(uploadID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.UploadSession) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(uploadID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetUser provides a mock function with given fields: userID func (_m *API) GetUser(userID string) (*model.User, *model.AppError) { ret := _m.Called(userID) @@ -3717,6 +3763,29 @@ func (_m *API) UpdateUserStatus(userID string, status string) (*model.Status, *m return r0, r1 } +// UploadData provides a mock function with given fields: us, rd +func (_m *API) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo, error) { + ret := _m.Called(us, rd) + + var r0 *model.FileInfo + if rf, ok := ret.Get(0).(func(*model.UploadSession, io.Reader) *model.FileInfo); ok { + r0 = rf(us, rd) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.FileInfo) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.UploadSession, io.Reader) error); ok { + r1 = rf(us, rd) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // UploadFile provides a mock function with given fields: data, channelId, filename func (_m *API) UploadFile(data []byte, channelId string, filename string) (*model.FileInfo, *model.AppError) { ret := _m.Called(data, channelId, filename) From 3edb28ede7a161c4f53beef7a7c8a754097c06a9 Mon Sep 17 00:00:00 2001 From: Matthew Williams Date: Wed, 23 Nov 2022 10:01:58 +0100 Subject: [PATCH 08/80] Translated using Weblate (English (Australia)) Currently translated at 100.0% (2390 of 2390 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/en_AU/ Translated using Weblate (English (Australia)) Currently translated at 100.0% (2389 of 2389 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/en_AU/ --- i18n/en_AU.json | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/i18n/en_AU.json b/i18n/en_AU.json index 64ea23c44d..60316fd426 100644 --- a/i18n/en_AU.json +++ b/i18n/en_AU.json @@ -65,7 +65,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -9308,7 +9308,7 @@ }, { "id": "api.templates.delinquency_90.subtitle2", - "translation": "In addition, your data may have been archived due to Cloud Starter limitations." + "translation": "In addition, your data may have been archived due to Cloud Free limitations." }, { "id": "api.templates.delinquency_90.subtitle1", @@ -9332,11 +9332,11 @@ }, { "id": "api.templates.delinquency_75.subtitle3", - "translation": "Update your payment information now, or downgrade to Cloud Starter." + "translation": "Update your payment information now, or downgrade to Cloud Free." }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "Your workspace will be downgraded to Cloud Starter. Your {{.Plan}} features will be locked and some of your workspace data may be archived until your full outstanding balance is settled." + "translation": "Your workspace will be downgraded to Cloud Free. Your {{.Plan}} features will be locked and some of your workspace data may be archived until your full outstanding balance is settled." }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9372,7 +9372,7 @@ }, { "id": "api.templates.delinquency_60.subtitle3", - "translation": "Update your payment information now or downgrade to Cloud Starter below." + "translation": "Update your payment information now or downgrade to Cloud Free below." }, { "id": "api.templates.delinquency_60.subtitle2", @@ -9464,7 +9464,7 @@ }, { "id": "api.templates.delinquency_14.subtitle1", - "translation": "The credit card on record wasn't able to be charged. This means your workspace is at risk of being downgraded to Cloud Starter." + "translation": "The credit card on record wasn't able to be charged. This means your workspace is at risk of being downgraded to Cloud Free." }, { "id": "api.templates.delinquency_14.subject", @@ -9569,5 +9569,17 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Collection type already exists." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "not an LDAP user" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Unable to upload file. File is too large." + }, + { + "id": "api.admin.syncables_error", + "translation": "Failed to add user to group-teams and group-channels" } ] From 96683a3913c6f0763e20a69575cee44083cbdaa0 Mon Sep 17 00:00:00 2001 From: Tom De Moor Date: Wed, 23 Nov 2022 10:01:59 +0100 Subject: [PATCH 09/80] Translated using Weblate (Dutch) Currently translated at 100.0% (2391 of 2391 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/nl/ Deleted translation using Weblate (Croatian) Translated using Weblate (Dutch) Currently translated at 100.0% (2389 of 2389 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/nl/ --- i18n/nl.json | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/i18n/nl.json b/i18n/nl.json index dedae87b78..c44c0e1f03 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -4333,11 +4333,11 @@ }, { "id": "model.post.is_valid.filenames.app_error", - "translation": "Ongeldige bestandsnaamen" + "translation": "Ongeldige bestandsnaamen." }, { "id": "model.post.is_valid.hashtags.app_error", - "translation": "Ongeldige hashtags" + "translation": "Ongeldige hashtags." }, { "id": "model.post.is_valid.id.app_error", @@ -9328,7 +9328,7 @@ }, { "id": "api.templates.delinquency_60.subtitle3", - "translation": "Werk nu jouw betalingsgegevens bij of downgrade naar Cloud Starter hieronder." + "translation": "Werk nu jouw betalingsgegevens bij of downgrade naar Cloud Free hieronder." }, { "id": "api.templates.delinquency_60.subtitle2", @@ -9424,7 +9424,7 @@ }, { "id": "api.templates.delinquency_14.subtitle1", - "translation": "We waren niet in staat om de kredietkaart die we in ons bestand hebben in rekening te brengen. Dit betekent dat jouw werkruimte het risico loopt te worden gedegradeerd naar Cloud Starter." + "translation": "We waren niet in staat om de kredietkaart die we in ons bestand hebben in rekening te brengen. Dit betekent dat jouw werkruimte het risico loopt te worden gedegradeerd naar Cloud Free." }, { "id": "api.templates.delinquency_90.subject", @@ -9444,11 +9444,11 @@ }, { "id": "api.templates.delinquency_75.subtitle3", - "translation": "Werk nu jouw betalingsgegevens bij, of downgrade naar Cloud Starter." + "translation": "Werk nu jouw betalingsgegevens bij, of downgrade naar Cloud Free." }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "Jouw werkruimte zal worden gedowngrade naar Cloud Starter. Jouw {{.Plan}} functies zullen worden vergrendeld en sommige van jouw werkruimtegegevens kunnen worden gearchiveerd totdat je jouw volledige uitstaande saldo hebt voldaan." + "translation": "Jouw werkruimte zal worden gedowngrade naar Cloud Free. Jouw {{.Plan}} functies zullen worden vergrendeld en sommige van jouw werkruimtegegevens kunnen worden gearchiveerd totdat je jouw volledige uitstaande saldo hebt voldaan." }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9472,7 +9472,7 @@ }, { "id": "api.templates.delinquency_90.subtitle2", - "translation": "Bovendien kunnen jouw gegevens gearchiveerd worden als gevolg van de beperkingen bij Cloud Starter." + "translation": "Bovendien kunnen jouw gegevens gearchiveerd worden als gevolg van de beperkingen bij Cloud Free." }, { "id": "api.templates.delinquency_90.subtitle1", @@ -9569,5 +9569,13 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Collectietype bestaat al." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "geen ldap-gebruiker" + }, + { + "id": "api.admin.syncables_error", + "translation": "kon gebruiker niet toevoegen aan groep-teams en groep-kanalen" } ] From b71b1bc2969ab3a986ff54db996814b894206d73 Mon Sep 17 00:00:00 2001 From: kaakaa Date: Wed, 23 Nov 2022 10:01:59 +0100 Subject: [PATCH 10/80] Translated using Weblate (Japanese) Currently translated at 100.0% (2389 of 2389 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/ja/ --- i18n/ja.json | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/i18n/ja.json b/i18n/ja.json index ff427d87e3..c130a01b5f 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -9281,7 +9281,7 @@ }, { "id": "app.cloud.get_cloud_products.app_error", - "translation": "クラウド製品を取得できませんでした" + "translation": "クラウドプロダクトを取得できませんでした" }, { "id": "api.templates.delinquency_90.title", @@ -9293,7 +9293,7 @@ }, { "id": "api.templates.delinquency_90.subtitle2", - "translation": "加えて、Cloud Starterの制限により、あなたのデータがアーカイブされている可能性があります。" + "translation": "加えて、Cloud Freeの制限により、あなたのデータがアーカイブされている可能性があります。" }, { "id": "api.templates.delinquency_90.subtitle1", @@ -9313,11 +9313,11 @@ }, { "id": "api.templates.delinquency_75.subtitle3", - "translation": "今すぐ支払い情報を更新するか、Cloud Starterにダウングレードしてください。" + "translation": "今すぐ支払い情報を更新するか、Cloud Freeにダウングレードしてください。" }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "ワークスペースは Cloud Starter にダウングレードされます。{{.Plan}}の機能はロックされ、ワークスペースのデータの一部は未払い金の全額が支払われるまでアーカイブされる場合があります。" + "translation": "ワークスペースは Cloud Free にダウングレードされます。{{.Plan}}の機能はロックされ、ワークスペースのデータの一部は未払い金の全額が支払われるまでアーカイブされる場合があります。" }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9349,7 +9349,7 @@ }, { "id": "api.templates.delinquency_60.subtitle3", - "translation": "今すぐ支払い情報を更新するか、以下よりCloud Starterにダウングレードしてください。" + "translation": "今すぐ支払い情報を更新するか、以下よりCloud Freeにダウングレードしてください。" }, { "id": "api.templates.delinquency_60.subtitle2", @@ -9429,7 +9429,7 @@ }, { "id": "api.templates.delinquency_14.subtitle1", - "translation": "登録されているクレジットカードに課金することができませんでした。これは、お客様のワークスペースがCloud Starterにダウングレードされる危険性があることを意味します。" + "translation": "登録されているクレジットカードに課金することができませんでした。これは、お客様のワークスペースがCloud Freeにダウングレードされる危険性があることを意味します。" }, { "id": "api.templates.delinquency_14.subject", @@ -9554,5 +9554,13 @@ { "id": "api.team.invite_guests_to_channels.disabled.error", "translation": "ゲストアカウントは無効化されています" + }, + { + "id": "app.collection.add_topic.exists.app_error", + "translation": "Topic type はすでに存在しています。" + }, + { + "id": "app.collection.add_collection.exists.app_error", + "translation": "Collection typeはすでに存在しています。" } ] From 30ebf5bee033a1136632b814ce26555112e9c894 Mon Sep 17 00:00:00 2001 From: Weblate Date: Wed, 23 Nov 2022 10:02:00 +0100 Subject: [PATCH 11/80] Added translation using Weblate (Croatian) --- i18n/hr.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 i18n/hr.json diff --git a/i18n/hr.json b/i18n/hr.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/i18n/hr.json @@ -0,0 +1 @@ +{} From 2132f8fdc99878730a81754eaa357b9b5f95e450 Mon Sep 17 00:00:00 2001 From: aiden Date: Wed, 23 Nov 2022 10:02:00 +0100 Subject: [PATCH 12/80] Translated using Weblate (Korean) Currently translated at 82.9% (1982 of 2389 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/ko/ --- i18n/ko.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/i18n/ko.json b/i18n/ko.json index 7d4b36ccb3..67117c3ce9 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -7962,5 +7962,9 @@ { "id": "api.command_marketplace.name", "translation": "마켓플레이스" + }, + { + "id": "sharedchannel.cannot_deliver_post", + "translation": "{{.Remote}} 원격 사이트가 오프라인이기 때문에 하나 또는 그 이상의 포스트가 전송되지 않았습니다. 포스트는 해당 사이트가 온라인일 때 전송될 것입니다." } ] From 6e6ae439d6c62e20eaab5304f44ef98dc72be978 Mon Sep 17 00:00:00 2001 From: MArtin Johnson Date: Wed, 23 Nov 2022 10:02:00 +0100 Subject: [PATCH 13/80] Translated using Weblate (Swedish) Currently translated at 100.0% (2389 of 2389 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/sv/ --- i18n/sv.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/i18n/sv.json b/i18n/sv.json index 04d40500d7..e7548af414 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -9364,7 +9364,7 @@ }, { "id": "api.templates.delinquency_14.subtitle1", - "translation": "Vi kunde inte debitera det kreditkort som vi har registrerat. Detta innebär att din arbetsyta riskerar att nedgraderas till Cloud Starter." + "translation": "Vi kunde inte debitera det kreditkort som vi har registrerat. Detta innebär att din arbetsyta riskerar att nedgraderas till Cloud Free." }, { "id": "api.templates.delinquency_14.subject", @@ -9428,7 +9428,7 @@ }, { "id": "api.templates.delinquency_90.subtitle2", - "translation": "Dessutom kan dina data ha arkiverats på grund av begränsningar i Cloud Starter." + "translation": "Dessutom kan dina data ha arkiverats på grund av begränsningar i Cloud Free." }, { "id": "api.templates.delinquency_90.subtitle1", @@ -9452,11 +9452,11 @@ }, { "id": "api.templates.delinquency_75.subtitle3", - "translation": "Uppdatera din betalningsinformation nu, eller nedgradera till Cloud Starter." + "translation": "Uppdatera din betalningsinformation nu, eller nedgradera till Cloud Free." }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "Din arbetsplats kommer att nedgraderas till Cloud Starter. Dina {{.Plan}}-funktioner kommer att spärras och delar av dina arbetsytedata kan komma att arkiveras tills hela ditt utestående belopp är betalt." + "translation": "Din arbetsplats kommer att nedgraderas till Cloud Free. Dina {{.Plan}}-funktioner kommer att spärras och delar av dina arbetsytedata kan komma att arkiveras tills hela ditt utestående belopp är betalt." }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9496,7 +9496,7 @@ }, { "id": "api.templates.delinquency_60.subtitle3", - "translation": "Uppdatera din betalningsinformation nu eller nedgradera till Cloud Starter nedan." + "translation": "Uppdatera din betalningsinformation nu eller nedgradera till Cloud Free nedan." }, { "id": "api.templates.delinquency_60.subtitle2", From 66c8f9ba28c61e6734b86eeb94aea8b9a5e4d3c8 Mon Sep 17 00:00:00 2001 From: jprusch Date: Wed, 23 Nov 2022 10:02:01 +0100 Subject: [PATCH 14/80] Translated using Weblate (German) Currently translated at 100.0% (2391 of 2391 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/de/ Translated using Weblate (German) Currently translated at 100.0% (2391 of 2391 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/de/ --- i18n/de.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index 4c409f0669..4f5ce3fa0a 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -4789,7 +4789,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -9308,7 +9308,7 @@ }, { "id": "api.templates.delinquency_75.subtitle2", - "translation": "Dein Arbeitsbereich wird auf Cloud Free herabgestuft. Deins {{.Plan}}-Funktionen werden gesperrt und einige deiner Arbeitsbereichsdaten können archiviert werden, bis dein ausstehender Betrag vollständig beglichen ist." + "translation": "Dein Arbeitsbereich wird auf Cloud Free herabgestuft. Deine {{.Plan}}-Funktionen werden gesperrt und einige deiner Arbeitsbereichsdaten können archiviert werden, bis dein ausstehender Betrag vollständig beglichen ist." }, { "id": "api.templates.delinquency_75.subtitle1", @@ -9569,5 +9569,13 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Collection Typ existiert schon." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "Kein LDAP-Benutzer" + }, + { + "id": "api.admin.syncables_error", + "translation": "Fehlschlag beim Hinzufügen des Benutzers zu Gruppen-Teams und -Kanälen" } ] From 61c5615c4d06b75ca9b09afa063b3074774390ee Mon Sep 17 00:00:00 2001 From: master7 Date: Wed, 23 Nov 2022 10:02:01 +0100 Subject: [PATCH 15/80] Translated using Weblate (Polish) Currently translated at 100.0% (2390 of 2390 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/pl/ Translated using Weblate (Polish) Currently translated at 100.0% (2391 of 2391 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/pl/ --- i18n/pl.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/i18n/pl.json b/i18n/pl.json index 64c2419ae0..9497465fc8 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -4749,7 +4749,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -9570,5 +9570,17 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Typ kolekcji już istnieje." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "nie jest użytkownikiem ldap" + }, + { + "id": "api.admin.syncables_error", + "translation": "nie udało się dodać użytkownika do group-teams i group-channels" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Nie można przesłać pliku. Plik jest zbyt duży." } ] From 865ef7e4c896a06c03ca3c5aac22a4a4306d72ab Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Wed, 23 Nov 2022 10:02:02 +0100 Subject: [PATCH 16/80] Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/ Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/ --- i18n/bg.json | 4 ---- i18n/de.json | 8 -------- i18n/en_AU.json | 8 -------- i18n/es.json | 8 -------- i18n/fa.json | 4 ---- i18n/fr.json | 8 -------- i18n/hu.json | 8 -------- i18n/it.json | 8 -------- i18n/ja.json | 8 -------- i18n/ko.json | 8 -------- i18n/nl.json | 8 -------- i18n/pl.json | 8 -------- i18n/pt-BR.json | 8 -------- i18n/ro.json | 4 ---- i18n/ru.json | 8 -------- i18n/sv.json | 8 -------- i18n/tr.json | 8 -------- i18n/uk.json | 4 ---- i18n/zh-CN.json | 8 -------- i18n/zh-TW.json | 8 -------- 20 files changed, 144 deletions(-) diff --git a/i18n/bg.json b/i18n/bg.json index 4031bf4bf6..1e2ac3b534 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -3667,10 +3667,6 @@ "id": "app.upload.get.app_error", "translation": "Не можа да се вземе качването." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Не може да се качи файл. Файлът е прекалено голям." - }, { "id": "app.upload.create.save.app_error", "translation": "Не можа да се запише качването." diff --git a/i18n/de.json b/i18n/de.json index 4f5ce3fa0a..e2f1e5b78b 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -6603,10 +6603,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} Dimensionen ({{.Width}} mal {{.Height}} Pixel) überschreiten die Limits." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Konnte Datei nicht hochladen. Datei ist zu groß." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Kann nicht in gelöschten Kanal senden." @@ -9194,10 +9190,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "Hochladen über mmctl zu einer Cloud Instanz wird nicht unterstützt. Bitte prüfe die Dokumentation: https://docs.mattermost.com/manage/cloud-data-export.html." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Fehler bei der Aktualisierung des Abonnements über Webhook." - }, { "id": "app.recent_searches.app_error", "translation": "Fehler beim Holen der letzten Suchen" diff --git a/i18n/en_AU.json b/i18n/en_AU.json index 60316fd426..e094789eab 100644 --- a/i18n/en_AU.json +++ b/i18n/en_AU.json @@ -4103,10 +4103,6 @@ "id": "app.upload.get.app_error", "translation": "Failed to get upload." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Unable to upload file. File is too large." - }, { "id": "app.upload.create.save.app_error", "translation": "Failed to save upload." @@ -9186,10 +9182,6 @@ "id": "api.templates.server_inactivity_footer_disclaimer", "translation": "You received this one-time email because your Mattermost server was inactive for more than {{.Hours}} hours. This email was automatically generated by your Mattermost server." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Error updating subscription from webhook." - }, { "id": "app.recent_searches.app_error", "translation": "An error occurred while fetching recent searches" diff --git a/i18n/es.json b/i18n/es.json index 65593d7226..a4c04c48a3 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -6751,10 +6751,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "Las dimensiones de {{.Filename}} son ({{.Width}} por {{.Height}} pixels) exceden el limite." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "No se pudo cargar el archivo. El archivo es muy grande." - }, { "id": "app.team.user_belongs_to_teams.app_error", "translation": "No se puede determinar si el usuario pertenece a la lista de equipos." @@ -8995,10 +8991,6 @@ "id": "api.custom_groups.no_remote_id", "translation": " " }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Error al actualizar la suscripción desde el webhook." - }, { "id": "app.system.get_onboarding_request.app_error", "translation": "No se pudo obtener el estado de finalización de inducción." diff --git a/i18n/fa.json b/i18n/fa.json index 88186a1d34..cdee577b6c 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -5359,10 +5359,6 @@ "id": "app.upload.get.app_error", "translation": "بارگیری انجام نشد." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "بارگذاری پرونده امکان پذیر نیست. پرونده خیلی بزرگ است." - }, { "id": "app.upload.create.save.app_error", "translation": "بارگذاری ذخیره نشد." diff --git a/i18n/fr.json b/i18n/fr.json index c94978c9da..39292444da 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -6591,10 +6591,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "La taille de {{.Filename}} ({{.Width}} sur {{.Height}} pixels) dépasse la limitée autorisée." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Impossible d'envoyer le fichier. Le fichier est trop volumineux." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Impossible d'envoyer un message dans un canal supprimé." @@ -8679,10 +8675,6 @@ "id": "api.cloud.teams_limit_reached.create", "translation": "Impossible de créer l'équipe, car la limite des équipes a été atteinte" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Erreur de mise à jour de l'abonnement à partir du point d'ancrage Web." - }, { "id": "app.user.store_is_empty.app_error", "translation": "Impossible de vérifier si le magasin de l'utilisateur est vide." diff --git a/i18n/hu.json b/i18n/hu.json index 930d7fd5d3..ad6d2dfd0c 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -4115,10 +4115,6 @@ "id": "app.upload.get.app_error", "translation": "Nem sikerült lekérni a feltöltést." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Nem sikerült feltölteni a fájlt. A fájl túl nagy." - }, { "id": "app.upload.create.save.app_error", "translation": "Nem sikerült elmenteni a feltöltést." @@ -9183,10 +9179,6 @@ "id": "api.templates.server_inactivity_footer_disclaimer", "translation": "Ezt az egyszeri e-mailt azért kapta, mert a Mattermost szervere több mint {{.Hours}} órán keresztül inaktív volt. Ezt az e-mailt a Mattermost szervere automatikusan generálta." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "HIba történt a webhorogról az előfizetés frissítése közben." - }, { "id": "api.file.cloud_upload.app_error", "translation": "Az mmctl segítségével történő feltöltés egy felhő alapú példányra nem támogatott. Kérjük, tekintse meg a dokumentációt itt: https://docs.mattermost.com/manage/cloud-data-export.html." diff --git a/i18n/it.json b/i18n/it.json index f9c6ae0911..041a46e134 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -6803,10 +6803,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}}: le dimensioni ({{.Width}} per {{.Height}} pixel) superano i limiti." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Impossibile caricare il file. Il file è troppo grande." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Impossibile inviare ad un canale eliminato." @@ -8711,10 +8707,6 @@ "id": "api.command_share.uninvite_remote.help", "translation": " " }, - { - "id": "api.cloud.subscription.update_error", - "translation": " " - }, { "id": "model.member.is_valid.channel.app_error", "translation": " " diff --git a/i18n/ja.json b/i18n/ja.json index c130a01b5f..cc82a4b220 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -6635,10 +6635,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}}の大きさ({{.Width}} x {{.Height}} ピクセル)が制限を超えています。" }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "画像ファイルをアップロードできません。ファイルが大き過ぎます。" - }, { "id": "app.team.user_belongs_to_teams.app_error", "translation": "ユーザーがチームリストに所属するかどうか確認できませんでした。" @@ -9191,10 +9187,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "クラウドインスタンスへのmmctlによるアップロードはサポートされていません。こちらのドキュメントを確認してください:https://docs.mattermost.com/manage/cloud-data-export.html。" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "ウェブフックからサブスクリプションを更新する際にエラーが発生しました。" - }, { "id": "app.install_integration.reached_max_limit.error", "translation": "有効な統合機能数の上限 {{.NumIntegrations}} に達しました。無制限に統合機能をインストールするには、いずれかの有料プランにアップグレードしてください。" diff --git a/i18n/ko.json b/i18n/ko.json index 67117c3ce9..82c9a20ffd 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -6607,10 +6607,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}}의 해상도({{.Width}} x {{.Height}} 픽셀)가 제한 사항을 초과했습니다." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "파일을 업로드할 수 없습니다. 파일 크기가 너무 큽니다." - }, { "id": "app.team.user_belongs_to_teams.app_error", "translation": "사용자가 팀 목록에 속하는지 확인할 수 없습니다." @@ -7895,10 +7891,6 @@ "id": "api.custom_groups.license_error", "translation": "사용자 정의 그룹을 위한 라이선스가 없습니다" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "웹훅에서 구독을 업데이트하는 동안 오류가 발생했습니다." - }, { "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", "translation": "이미 관리자에게 통지됨" diff --git a/i18n/nl.json b/i18n/nl.json index c44c0e1f03..7fb0b16e52 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -7203,10 +7203,6 @@ "id": "app.upload.get_for_user.app_error", "translation": "Fout bij het ophalen van de uploads van de gebruiker." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Fout bij het uploaden van bestanden. Bestand is te groot." - }, { "id": "app.upload.create.incorrect_channel_id.app_error", "translation": "Kan niet uploaden naar het opgegeven kanaal." @@ -9190,10 +9186,6 @@ "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Bevestiging Mattermost upgrade" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Fout met bijwerken inschrijving vanuit webhook." - }, { "id": "app.recent_searches.app_error", "translation": "Fout bij ophalen van de recente zoekopdrachten" diff --git a/i18n/pl.json b/i18n/pl.json index 9497465fc8..18ea9ad6b0 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -6599,10 +6599,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} wymiary ({{.width}} według {{.Height}} pikseli) przekraczają limity." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Nie udało się wgrać obrazu. Plik jest zbyt duży." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Nie można utworzyć wiadomości na usuniętym kanale." @@ -9191,10 +9187,6 @@ "id": "api.templates.server_inactivity_footer_disclaimer", "translation": "Otrzymałeś tę jednorazową wiadomość e-mail, ponieważ Twój serwer Mattermost był nieaktywny przez ponad {{.Hours}} godzin. Ta wiadomość e-mail została automatycznie wygenerowana przez serwer Mattermost." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Błąd aktualizacji subskrypcji z webhooka." - }, { "id": "api.file.cloud_upload.app_error", "translation": "Przesyłanie danych do instancji Chmury za pomocą mmctl nie jest obsługiwane. Proszę sprawdzić dokumentację tutaj: https://docs.mattermost.com/manage/cloud-data-export.html." diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index 2873fe7a1d..134b851682 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -6911,10 +6911,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} dimensão ({{.Width}} por {{.Height}} pixels) excede os limites." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Não foi possível enviar o arquivo. Arquivo é muito grande." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Não é possível publicar em um canal excluído." @@ -8627,10 +8623,6 @@ "id": "api.cloud.teams_limit_reached.create", "translation": "Não foi possível criar equipe porque o limite de equipes foi atingido" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Erro ao atualizar a assinatura do webhook." - }, { "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", "translation": "Administrador já notificado" diff --git a/i18n/ro.json b/i18n/ro.json index 6c9fe29085..f2686f070c 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -6699,10 +6699,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} dimensiuni ({{.Width}} de {{.Height}} pixeli) depășesc limitele." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Imposibil de încărcat fișierul. Fișierul este prea mare." - }, { "id": "app.team.user_belongs_to_teams.app_error", "translation": "Nu se poate stabili dacă utilizatorul aparține unei liste de echipe." diff --git a/i18n/ru.json b/i18n/ru.json index a04c03a356..ff22bf7094 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -6975,10 +6975,6 @@ "id": "app.upload.get.app_error", "translation": "Не удалось получить загрузки." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Невозможно загрузить файл. Он слишком большой." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Невозможно создать пост в удаленном канале." @@ -9243,10 +9239,6 @@ "id": "api.cloud.teams_limit_reached.create", "translation": "Невозможно создать команду, потому что достигнут лимит команд" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Ошибка обновления подписки из webhook." - }, { "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", "translation": "Администратор уже уведомлен" diff --git a/i18n/sv.json b/i18n/sv.json index e7548af414..6d407bae45 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -2123,10 +2123,6 @@ "id": "app.upload.get.app_error", "translation": "Kunde inte ta fram den uppladdade flen." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Kunde inte ladda upp. Filen är för stor." - }, { "id": "app.upload.create.save.app_error", "translation": "Misslyckades att spara det uppladdade." @@ -9190,10 +9186,6 @@ "id": "api.elasticsearch.test_elasticsearch_settings_nil.app_error", "translation": "Elasticsearch-inställningarna har odefinierade värden." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Fel vid uppdatering av prenumeration från webhook." - }, { "id": "app.recent_searches.app_error", "translation": "Fel när senaste sökningar skulle hämtas" diff --git a/i18n/tr.json b/i18n/tr.json index 680520e2fd..31505742bb 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -6783,10 +6783,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} boyutları ({{.Width}} x {{.Height}} piksel) sınırını aşıyor." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Dosya yüklenemedi. Dosya çok büyük." - }, { "id": "app.team.get_by_scheme.app_error", "translation": "Belirtilen şemanın uygulanabileceği kanallar alınamadı." @@ -9190,10 +9186,6 @@ "id": "api.elasticsearch.test_elasticsearch_settings_nil.app_error", "translation": "Elasticsearch yapılandırmasında ayarlanmamış değerler var." }, - { - "id": "api.cloud.subscription.update_error", - "translation": "Abonelik web bağlantısından güncellenirken sorun çıktı." - }, { "id": "app.recent_searches.app_error", "translation": "Son aramalar alınırken sorun çıktı" diff --git a/i18n/uk.json b/i18n/uk.json index c05c7895b3..cbba647d9d 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -6583,10 +6583,6 @@ "id": "app.upload.upload_data.large_image.app_error", "translation": "{{.Filename}} dimensions ({{.Width}} by {{.Height}} pixels) exceed the limits." }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "Неможливо завантажити файл. Він занадто великий." - }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Неможливо розмістити публікацію на видаленому каналі." diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 9d3b1ef5fe..456be38416 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -6635,10 +6635,6 @@ "id": "app.upload.get.app_error", "translation": "获取上传失败。" }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "无法上传文件。文件过大。" - }, { "id": "app.upload.create.incorrect_channel_id.app_error", "translation": "无法上传到指定的频道。" @@ -9203,10 +9199,6 @@ "id": "api.cloud.teams_limit_reached.create", "translation": "无法创建团队,因为已达到团队限制" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "从 webhook 更新订阅时出错。" - }, { "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", "translation": "已通知管理员" diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index 11b8fcb9ef..e1a171691e 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -6771,10 +6771,6 @@ "id": "app.upload.get.app_error", "translation": "無法取得上傳檔。" }, - { - "id": "app.upload.create.upload_too_large.app_error", - "translation": "無法上傳檔案。檔案過大。" - }, { "id": "app.upload.create.incorrect_channel_id.app_error", "translation": "無法上傳至指定頻道。" @@ -7271,10 +7267,6 @@ "id": "api.channel.patch_channel_moderations.cache_invalidation.error", "translation": "錯誤 無效快取" }, - { - "id": "api.cloud.subscription.update_error", - "translation": "從 Webhook 更新訂閱時發生錯誤。" - }, { "id": "api.cloud.request_error", "translation": "CWS 處理請求錯誤。" From 2c997c46d29ce28006acd1464c88b6094334502e Mon Sep 17 00:00:00 2001 From: Angel Mendez Cano Date: Wed, 23 Nov 2022 10:02:03 +0100 Subject: [PATCH 17/80] Translated using Weblate (Spanish) Currently translated at 100.0% (2390 of 2390 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/es/ --- i18n/es.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/i18n/es.json b/i18n/es.json index a4c04c48a3..de84b2e463 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -4757,7 +4757,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -9562,5 +9562,17 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "El tipo de colección ya existe." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "no es un usuario ldap" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "No es posible subir el archivo. El archivo es muy grande." + }, + { + "id": "api.admin.syncables_error", + "translation": "Error al agregar usuario a grupo-equipos y grupo-canales" } ] From d2cb3598b15a480a890e7a658541b3584258601f Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 23 Nov 2022 10:02:03 +0100 Subject: [PATCH 18/80] Translated using Weblate (Russian) Currently translated at 100.0% (2390 of 2390 strings) Translation: mattermost-languages-shipped/mattermost-server Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-server_master/ru/ --- i18n/ru.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/i18n/ru.json b/i18n/ru.json index ff22bf7094..ce6fdce143 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -5573,7 +5573,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.no_longer_support_version", @@ -9562,5 +9562,17 @@ { "id": "api.templates.delinquency_60.downgrade_to_free", "translation": "Понижение статуса до Cloud Free" + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "не является пользователем ldap" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Невозможно загрузить файл. Он слишком большой." + }, + { + "id": "api.admin.syncables_error", + "translation": "не удалось добавить пользователя в group-teams и group-channels" } ] From 7f419ea091c221a90cb09a0dfa12c69138160c81 Mon Sep 17 00:00:00 2001 From: Javier Aguirre Date: Wed, 23 Nov 2022 10:42:23 +0100 Subject: [PATCH 19/80] [MM-48542] Removing integration limits (#21282) * Removing integration limits * Remove freemium limit test * Remove test assertion regarding cloud limits * Remove GetIntegrationsUsage * Removing integrations usage notifications * This shouldn't be removed * Removing client call and websocket event * Remove old translations Co-authored-by: Mattermod --- api4/config.go | 12 +-- api4/config_test.go | 108 ------------------------- api4/usage.go | 29 ------- api4/usage_test.go | 25 ------ app/app_iface.go | 4 - app/integrations.go | 113 --------------------------- app/integrations_test.go | 74 ------------------ app/opentracing/opentracing_layer.go | 44 ----------- app/plugin.go | 23 ------ app/plugin_install.go | 16 ---- app/plugin_test.go | 11 --- app/usage.go | 31 -------- i18n/de.json | 4 - i18n/en.json | 4 - i18n/en_AU.json | 4 - i18n/es.json | 4 - i18n/fr.json | 4 - i18n/hu.json | 4 - i18n/it.json | 4 - i18n/ja.json | 4 - i18n/nl.json | 4 - i18n/pl.json | 4 - i18n/sv.json | 4 - i18n/tr.json | 4 - i18n/zh-CN.json | 4 - model/client4.go | 13 --- model/usage.go | 4 - model/websocket_message.go | 1 - 28 files changed, 1 insertion(+), 559 deletions(-) delete mode 100644 app/integrations.go delete mode 100644 app/integrations_test.go diff --git a/api4/config.go b/api4/config.go index d2f2019c36..5f620e14d5 100644 --- a/api4/config.go +++ b/api4/config.go @@ -157,12 +157,7 @@ func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) { } if cfg.PluginSettings.PluginStates[model.PluginIdFocalboard].Enable && cfg.FeatureFlags.BoardsProduct { - c.Err = model.NewAppError("EnablePlugin", "app.plugin.product_mode.app_error", map[string]any{"Name": model.PluginIdFocalboard}, "", http.StatusBadRequest) - return - } - - if appErr := c.App.CheckFreemiumLimitsForConfigSave(appCfg, cfg); appErr != nil { - c.Err = appErr + c.Err = model.NewAppError("EnablePlugin", "app.plugin.product_mode.app_error", map[string]any{"Name": model.PluginIdFocalboard}, "", http.StatusInternalServerError) return } @@ -304,11 +299,6 @@ func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) { } } - if appErr := c.App.CheckFreemiumLimitsForConfigSave(appCfg, cfg); appErr != nil { - c.Err = appErr - return - } - // There are some settings that cannot be changed in a cloud env if c.App.Channels().License().IsCloud() { if cfg.ComplianceSettings.Directory != nil && *appCfg.ComplianceSettings.Directory != *cfg.ComplianceSettings.Directory { diff --git a/api4/config_test.go b/api4/config_test.go index 2af4fe7bac..3db4082e49 100644 --- a/api4/config_test.go +++ b/api4/config_test.go @@ -17,9 +17,7 @@ import ( "github.com/mattermost/mattermost-server/v6/app" "github.com/mattermost/mattermost-server/v6/config" - "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock" ) func TestGetConfig(t *testing.T) { @@ -249,59 +247,6 @@ func TestUpdateConfig(t *testing.T) { assert.Equal(t, newURL, *cfg2.PluginSettings.MarketplaceURL) }) - t.Run("Should not be able to save config if the new config exceeds Freemium limits", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - defer th.App.Srv().RemoveLicense() - - cloud := &mocks.CloudInterface{} - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = cloud - - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Integrations: &model.IntegrationsLimits{ - Enabled: model.NewInt(0), - }, - }, nil).Once() - - // Exceed freemium limit. Should throw error. - cfg1 := th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - _, _, err1 := th.SystemAdminClient.UpdateConfig(cfg1) - require.Error(t, err1) - - // No attempt to enable a plugin. Should not throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: false} - _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) - require.NoError(t, err1) - - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Integrations: &model.IntegrationsLimits{ - Enabled: model.NewInt(1), - }, - }, nil).Twice() - - // Exceed freemium limit while enabling more than one plugin. Should throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - cfg1.PluginSettings.PluginStates["new-plugin2"] = &model.PluginState{Enable: true} - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.Error(t, err1) - - // Match freemium limit. Should not throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) - require.NoError(t, err1) - - // Save same config with same plugin enabled. Should not throw error. - _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) - require.NoError(t, err1) - }) - t.Run("Should not be able to modify ComplianceSettings.Directory in cloud", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicense("cloud")) defer th.App.Srv().RemoveLicense() @@ -847,59 +792,6 @@ func TestPatchConfig(t *testing.T) { assert.Equal(t, newURL, *cfg.PluginSettings.MarketplaceURL) }) - t.Run("Should not be able to save config if the new config exceeds Freemium limits", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - defer th.App.Srv().RemoveLicense() - - cloud := &mocks.CloudInterface{} - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = cloud - - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Integrations: &model.IntegrationsLimits{ - Enabled: model.NewInt(0), - }, - }, nil).Once() - - // Exceed freemium limit. Should throw error. - cfg1 := th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - _, _, err1 := th.SystemAdminClient.PatchConfig(cfg1) - require.Error(t, err1) - - // No attempt to enable a plugin. Should not throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: false} - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.NoError(t, err1) - - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Integrations: &model.IntegrationsLimits{ - Enabled: model.NewInt(1), - }, - }, nil).Twice() - - // Exceed freemium limit while enabling more than one plugin. Should throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - cfg1.PluginSettings.PluginStates["new-plugin2"] = &model.PluginState{Enable: true} - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.Error(t, err1) - - // Match freemium limit. Should not throw error. - cfg1 = th.App.Config().Clone() - cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.NoError(t, err1) - - // Save same config with same plugin enabled. Should not throw error. - _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) - require.NoError(t, err1) - }) - t.Run("System Admin should not be able to clear Site URL", func(t *testing.T) { cfg, _, err := th.SystemAdminClient.GetConfig() require.NoError(t, err) diff --git a/api4/usage.go b/api4/usage.go index 3822972ba4..542c89ffa8 100644 --- a/api4/usage.go +++ b/api4/usage.go @@ -18,8 +18,6 @@ func (api *API) InitUsage() { api.BaseRoutes.Usage.Handle("/storage", api.APISessionRequired(getStorageUsage)).Methods("GET") // GET /api/v4/usage/teams api.BaseRoutes.Usage.Handle("/teams", api.APISessionRequired(getTeamsUsage)).Methods("GET") - // GET /api/v4/usage/integrations - api.BaseRoutes.Usage.Handle("/integrations", api.APISessionRequired(getIntegrationsUsage)).Methods("GET") } func getPostsUsage(c *Context, w http.ResponseWriter, r *http.Request) { @@ -74,30 +72,3 @@ func getTeamsUsage(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(json) } - -func getIntegrationsUsage(c *Context, w http.ResponseWriter, r *http.Request) { - if !*c.App.Config().PluginSettings.Enable { - json, err := json.Marshal(&model.IntegrationsUsage{}) - if err != nil { - c.Err = model.NewAppError("Api4.getIntegrationsUsage", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - w.Write(json) - return - } - - usage, appErr := c.App.GetIntegrationsUsage() - if appErr != nil { - c.Err = appErr - return - } - - json, err := json.Marshal(usage) - if err != nil { - c.Err = model.NewAppError("Api4.getIntegrationsUsage", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - w.Write(json) -} diff --git a/api4/usage_test.go b/api4/usage_test.go index 41d96ae75c..9171614cc8 100644 --- a/api4/usage_test.go +++ b/api4/usage_test.go @@ -91,28 +91,3 @@ func TestGetTeamsUsage(t *testing.T) { assert.Equal(t, int64(3), usage.Active) }) } - -func TestGetIntegrationsUsage(t *testing.T) { - t.Run("unauthenticated users can not access", func(t *testing.T) { - th := Setup(t) - defer th.TearDown() - - th.Client.Logout() - - usage, r, err := th.Client.GetIntegrationsUsage() - assert.Error(t, err) - assert.Nil(t, usage) - assert.Equal(t, http.StatusUnauthorized, r.StatusCode) - }) - - t.Run("good request returns response", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - usage, r, err := th.Client.GetIntegrationsUsage() - assert.NoError(t, err) - assert.Equal(t, http.StatusOK, r.StatusCode) - assert.NotNil(t, usage) - assert.Equal(t, 0, usage.Enabled) - }) -} diff --git a/app/app_iface.go b/app/app_iface.go index ab22b9cbf9..a96681d93c 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -69,8 +69,6 @@ type AppIface interface { // If includeRemovedMembers is true, then channel members who left or were removed from the channel will // be included; otherwise, they will be excluded. ChannelMembersToAdd(since int64, channelID *string, includeRemovedMembers bool) ([]*model.UserChannelIDPair, *model.AppError) - // CheckFreemiumLimitsForConfigSave returns an error if the configuration being saved violates a cloud plan's limits - CheckFreemiumLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError // CheckProviderAttributes returns the empty string if the patch can be applied without // overriding attributes set by the user's login provider; otherwise, the name of the offending // field is returned. @@ -184,8 +182,6 @@ type AppIface interface { GetFilteredUsersStats(options *model.UserCountOptions) (*model.UsersStats, *model.AppError) // GetGroupsByTeam returns the paged list and the total count of group associated to the given team. GetGroupsByTeam(teamID string, opts model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.AppError) - // GetIntegrationsUsage returns usage information on enabled integrations - GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) // GetKnownUsers returns the list of user ids of users with any direct // relationship with a user. That means any user sharing any channel, including // direct and group channels. diff --git a/app/integrations.go b/app/integrations.go deleted file mode 100644 index 0a810cbf0b..0000000000 --- a/app/integrations.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package app - -import ( - "net/http" - "sort" - "strings" - - "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/shared/mlog" -) - -func (a *App) checkIntegrationLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError { - pluginIds := []string{} - for pluginId, newState := range newConfig.PluginSettings.PluginStates { - oldState, ok := oldConfig.PluginSettings.PluginStates[pluginId] - if newState.Enable && !(ok && oldState.Enable) { - pluginIds = append(pluginIds, pluginId) - } - } - - if len(pluginIds) > 0 { - return a.checkIfIntegrationsMeetFreemiumLimits(pluginIds) - } - - return nil -} - -func (ch *Channels) getInstalledIntegrations() ([]*model.InstalledIntegration, *model.AppError) { - out := []*model.InstalledIntegration{} - - pluginsEnvironment := ch.GetPluginsEnvironment() - if pluginsEnvironment == nil { - return out, nil - } - - plugins, err := pluginsEnvironment.Available() - if err != nil { - return nil, model.NewAppError("getInstalledIntegrations", "app.plugin.sync.read_local_folder.app_error", nil, "", 0).Wrap(err) - } - - pluginStates := ch.cfgSvc.Config().PluginSettings.PluginStates - for _, p := range plugins { - if _, ok := model.InstalledIntegrationsIgnoredPlugins[p.Manifest.Id]; !ok { - enabled := false - if state, ok := pluginStates[p.Manifest.Id]; ok { - enabled = state.Enable - } - - integration := &model.InstalledIntegration{ - Type: "plugin", - ID: p.Manifest.Id, - Name: p.Manifest.Name, - Version: p.Manifest.Version, - Enabled: enabled, - } - - out = append(out, integration) - } - } - - // Sort result alphabetically, by display name. - sort.SliceStable(out, func(i, j int) bool { - return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) - }) - - return out, nil -} - -func (a *App) checkIfIntegrationsMeetFreemiumLimits(originalPluginIds []string) *model.AppError { - if !a.License().IsCloud() { - return nil - } - - pluginIds := map[string]bool{} - for _, pluginId := range originalPluginIds { - if _, ok := model.InstalledIntegrationsIgnoredPlugins[pluginId]; !ok { - pluginIds[pluginId] = true - } - } - - limits, err := a.Cloud().GetCloudLimits("") - if err != nil { - a.Log().Error("Error fetching cloud limits for enabled integrations", mlog.Err(err)) - return nil - } - - if limits == nil || limits.Integrations == nil || limits.Integrations.Enabled == nil { - return nil - } - - installed, appErr := a.ch.getInstalledIntegrations() - if appErr != nil { - a.Log().Error("Failed to get installed integrations to check cloud limit", mlog.Err(appErr)) - return nil - } - - enableCount := len(pluginIds) - for _, integration := range installed { - if _, ok := pluginIds[integration.ID]; !ok && integration.Enabled { - enableCount++ - } - } - - limit := *limits.Integrations.Enabled - if enableCount > limit { - return model.NewAppError("checkIfIntegrationMeetsFreemiumLimits", "app.install_integration.reached_max_limit.error", map[string]any{"NumIntegrations": limit}, "", http.StatusBadRequest) - } - - return nil -} diff --git a/app/integrations_test.go b/app/integrations_test.go deleted file mode 100644 index 3a3b6b6c6a..0000000000 --- a/app/integrations_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package app - -import ( - "testing" - - "github.com/mattermost/mattermost-server/v6/model" - "github.com/stretchr/testify/require" -) - -func TestGetIntegrationsUsage(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - samplePluginCode := ` - package main - - import ( - "github.com/mattermost/mattermost-server/v6/plugin" - ) - - type MyPlugin struct { - plugin.MattermostPlugin - } - - func main() { - plugin.ClientMain(&MyPlugin{}) - } - ` - - setupMultiPluginAPITest(t, - []string{samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode}, []string{ - `{"id": "otherplugin", "name": "Other Plugin", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "mattermost-autolink", "name": "Autolink", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "playbooks", "name": "Playbooks", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "focalboard", "name": "Mattermost Boards", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "com.mattermost.calls", "name": "Calls", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "com.mattermost.nps", "name": "User Satisfaction Surveys", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, - `{"id": "com.mattermost.apps", "server": {"executable": "backend.exe"}}`, - }, []string{"otherplugin", "mattermost-autolink", "playbooks", "focalboard", "com.mattermost.calls", "com.mattermost.nps", "com.mattermost.apps"}, - true, th.App, th.Context) - - integrations, appErr := th.App.ch.getInstalledIntegrations() - require.Nil(t, appErr) - - expected := []*model.InstalledIntegration{ - { - Type: "plugin", - ID: "mattermost-autolink", - Name: "Autolink", - Version: "1.2.0", - Enabled: true, - }, - { - Type: "plugin", - ID: "otherplugin", - Name: "Other Plugin", - Version: "1.2.0", - Enabled: true, - }, - } - require.Equal(t, expected, integrations) - - usage, appErr := th.App.GetIntegrationsUsage() - require.Nil(t, appErr) - - // 2 enabled integrations - expectedUsage := &model.IntegrationsUsage{ - Enabled: 2, - } - require.Equal(t, expectedUsage, usage) -} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index b40c1921aa..51a5daba92 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1200,28 +1200,6 @@ func (a *OpenTracingAppLayer) CheckForClientSideCert(r *http.Request) (string, s return resultVar0, resultVar1, resultVar2 } -func (a *OpenTracingAppLayer) CheckFreemiumLimitsForConfigSave(oldConfig *model.Config, newConfig *model.Config) *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckFreemiumLimitsForConfigSave") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0 := a.app.CheckFreemiumLimitsForConfigSave(oldConfig, newConfig) - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) CheckIntegrity() <-chan model.IntegrityCheckResult { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckIntegrity") @@ -6630,28 +6608,6 @@ func (a *OpenTracingAppLayer) GetIncomingWebhooksPageByUser(userID string, page return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetIntegrationsUsage") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0, resultVar1 := a.app.GetIntegrationsUsage() - - if resultVar1 != nil { - span.LogFields(spanlog.Error(resultVar1)) - ext.Error.Set(span, true) - } - - return resultVar0, resultVar1 -} - func (a *OpenTracingAppLayer) GetJob(id string) (*model.Job, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetJob") diff --git a/app/plugin.go b/app/plugin.go index b9042330bf..b48503de5d 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -200,10 +200,6 @@ func (ch *Channels) syncPluginsActiveState() { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } - - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } } func (a *App) NewPluginAPI(c *request.Context, manifest *model.Manifest) plugin.API { @@ -422,11 +418,6 @@ func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { // activation if inactive anywhere in the cluster. // Notifies cluster peers through config change. func (a *App) EnablePlugin(id string) *model.AppError { - appErr := a.checkIfIntegrationsMeetFreemiumLimits([]string{id}) - if appErr != nil { - return appErr - } - return a.ch.enablePlugin(id) } @@ -537,20 +528,6 @@ func (ch *Channels) disablePlugin(id string) *model.AppError { return nil } -func (ch *Channels) notifyIntegrationsUsageChanged() *model.AppError { - usage, appErr := ch.getIntegrationsUsage() - if appErr != nil { - return appErr - } - - message := model.NewWebSocketEvent(model.WebsocketEventIntegrationsUsageChanged, "", "", "", nil, "") - message.Add("usage", usage) - message.GetBroadcast().ContainsSensitiveData = true - ch.Publish(message) - - return nil -} - func (a *App) GetPlugins() (*model.PluginsResponse, *model.AppError) { pluginsEnvironment := a.GetPluginsEnvironment() if pluginsEnvironment == nil { diff --git a/app/plugin_install.go b/app/plugin_install.go index fa076df3c4..431c3abebe 100644 --- a/app/plugin_install.go +++ b/app/plugin_install.go @@ -102,10 +102,6 @@ func (ch *Channels) installPluginFromData(data model.PluginEventData) { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Error("Failed to notify plugin status changed", mlog.Err(err)) } - - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } } func (ch *Channels) removePluginFromData(data model.PluginEventData) { @@ -118,10 +114,6 @@ func (ch *Channels) removePluginFromData(data model.PluginEventData) { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } - - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } } // InstallPluginWithSignature verifies and installs plugin. @@ -177,10 +169,6 @@ func (ch *Channels) installPlugin(pluginFile, signature io.ReadSeeker, installat mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } - return manifest, nil } @@ -455,10 +443,6 @@ func (ch *Channels) RemovePlugin(id string) *model.AppError { mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } - if err := ch.notifyIntegrationsUsageChanged(); err != nil { - mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) - } - return nil } diff --git a/app/plugin_test.go b/app/plugin_test.go index fcc719c891..57802c67ac 100644 --- a/app/plugin_test.go +++ b/app/plugin_test.go @@ -1057,17 +1057,6 @@ func TestEnablePluginWithCloudLimits(t *testing.T) { appErr = th.App.EnablePlugin("testplugin") checkNoError(t, appErr) - appErr = th.App.EnablePlugin("testplugin2") - checkError(t, appErr) - require.Equal(t, "app.install_integration.reached_max_limit.error", appErr.Id) - - th.App.Srv().RemoveLicense() - appErr = th.App.EnablePlugin("testplugin2") - checkNoError(t, appErr) - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - appErr = th.App.EnablePlugin("testplugin2") - checkError(t, appErr) - // Let enable succeed if a CWS error occurs cloud = &mocks.CloudInterface{} th.App.Srv().Cloud = cloud diff --git a/app/usage.go b/app/usage.go index 3abf9bdf64..43c71e004b 100644 --- a/app/usage.go +++ b/app/usage.go @@ -10,37 +10,6 @@ import ( "github.com/mattermost/mattermost-server/v6/utils" ) -// CheckFreemiumLimitsForConfigSave returns an error if the configuration being saved violates a cloud plan's limits -func (a *App) CheckFreemiumLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError { - appErr := a.checkIntegrationLimitsForConfigSave(oldConfig, newConfig) - if appErr != nil { - return appErr - } - - return nil -} - -// GetIntegrationsUsage returns usage information on enabled integrations -func (a *App) GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { - return a.ch.getIntegrationsUsage() -} - -func (ch *Channels) getIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { - installed, appErr := ch.getInstalledIntegrations() - if appErr != nil { - return nil, appErr - } - - var count = 0 - for _, i := range installed { - if i.Enabled { - count++ - } - } - - return &model.IntegrationsUsage{Enabled: count}, nil -} - // GetPostsUsage returns the total posts count rounded down to the most // significant digit func (a *App) GetPostsUsage() (int64, *model.AppError) { diff --git a/i18n/de.json b/i18n/de.json index 4c409f0669..01f8c07fc7 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -9202,10 +9202,6 @@ "id": "app.recent_searches.app_error", "translation": "Fehler beim Holen der letzten Suchen" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Du hast das Maximum von {{.NumIntegrations}} aktivierten Integrationen erreicht. Um Integrationen ohne Beschränkungen zu installieren, upgrade auf ein bezahltes Abonnements." - }, { "id": "app.teams.analytics_teams_count.app_error", "translation": "Kann Team-Zähler nicht abfragen" diff --git a/i18n/en.json b/i18n/en.json index 067f77b4fe..a9612782c4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -5619,10 +5619,6 @@ "id": "app.insights.feature_disabled", "translation": "Insights feature is disabled." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "You've reached the max limit of {{.NumIntegrations}} enabled integrations. To install unlimited integrations, upgrade to one of our paid plans." - }, { "id": "app.job.download_export_results_not_enabled", "translation": "DownloadExportResults in config.json is false. Please set this to true to download the results of this job." diff --git a/i18n/en_AU.json b/i18n/en_AU.json index 64ea23c44d..dc7bbc6e2a 100644 --- a/i18n/en_AU.json +++ b/i18n/en_AU.json @@ -9198,10 +9198,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "Uploading via mmctl to a Cloud instance is not supported. Please check the documentation here: https://docs.mattermost.com/manage/cloud-data-export.html." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "You've reached the limit of {{.NumIntegrations}} enabled integrations. To install unlimited integrations, upgrade to one of the paid plans." - }, { "id": "app.usage.get_storage_usage.app_error", "translation": "Failed to get storage usage." diff --git a/i18n/es.json b/i18n/es.json index 65593d7226..f2c4f8e106 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -9151,10 +9151,6 @@ "id": "api.templates.invite_team_and_channels_body.title", "translation": " " }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Has alcanzado el límite máximo de {{.NumIntegrations}} integraciones activas. Para instalar integraciones ilimitadas, actualiza a uno de unos planes de pago." - }, { "id": "model.channel.is_valid.1_or_more.app_error", "translation": "El Nombre debe tener 1 o más caracteres alfanuméricos en minúsculas." diff --git a/i18n/fr.json b/i18n/fr.json index c94978c9da..73d49de59b 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -8747,10 +8747,6 @@ "id": "app.job.get_all_jobs_by_type_and_status.app_error", "translation": "Impossible d'obtenir tous les travaux par type et statuts." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Vous avez atteint la limite maximale de {{.NumIntegrations}} d'intégrations activées. Pour installer un nombre illimité d'intégrations, effectuez une mise à niveau vers l'un de nos plans payants." - }, { "id": "app.insights.feature_disabled", "translation": "La fonctionnalité des aperçus est désactivée." diff --git a/i18n/hu.json b/i18n/hu.json index 930d7fd5d3..1a316e0355 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -9203,10 +9203,6 @@ "id": "app.teams.analytics_teams_count.app_error", "translation": "Nem kérdezhető le a csapatok száma" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Elérte az engedélyezett integrációk maximális számát {{.NumIntegrations}}. Korlátlan számú integráció telepítéséhez frissítsen valamelyik fizetős csomagunkra." - }, { "id": "app.post.analytics_teams_count.app_error", "translation": "Nem kérdezhető le a csapat használtság" diff --git a/i18n/it.json b/i18n/it.json index f9c6ae0911..c4e839c010 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -8383,10 +8383,6 @@ "id": "app.notification.body.group.title", "translation": " " }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": " " - }, { "id": "app.user.missing_account.const", "translation": " " diff --git a/i18n/ja.json b/i18n/ja.json index ff427d87e3..648a82e5b0 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -9195,10 +9195,6 @@ "id": "api.cloud.subscription.update_error", "translation": "ウェブフックからサブスクリプションを更新する際にエラーが発生しました。" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "有効な統合機能数の上限 {{.NumIntegrations}} に達しました。無制限に統合機能をインストールするには、いずれかの有料プランにアップグレードしてください。" - }, { "id": "model.config.is_valid.image_decoder_concurrency.app_error", "translation": "デコーダーの並列数 {{.Value}} は不正です。正の数または-1であるべきです。" diff --git a/i18n/nl.json b/i18n/nl.json index dedae87b78..994d088bcd 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -9218,10 +9218,6 @@ "id": "app.teams.analytics_teams_count.app_error", "translation": "Niet gelukt om het aan aantal teams op te halen" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Je bereikte de maximumlimiet van {{.NumIntegrations}} ingeschakelde integraties. Om onbeperkte integraties te installeren, upgrade naar een van onze betaalde plannen." - }, { "id": "api.cloud.teams_limit_reached.restore", "translation": "Kan het team niet herstellen omdat de teamlimiet bereikt is" diff --git a/i18n/pl.json b/i18n/pl.json index 64c2419ae0..922951e724 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -9203,10 +9203,6 @@ "id": "app.recent_searches.app_error", "translation": "Błąd pobierania ostatnich wyszukiwań" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Osiągnąłeś maksymalny limit {{.NumIntegrations}} włączonych integracji. Aby zainstalować nieograniczoną liczbę integracji, uaktualnij do jednego z naszych płatnych planów." - }, { "id": "app.teams.analytics_teams_count.app_error", "translation": "Nie można uzyskać liczby zespołów" diff --git a/i18n/sv.json b/i18n/sv.json index 04d40500d7..a1c59374bc 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -9202,10 +9202,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "Uppladdning via mmctl till en molninstans stöds inte. Se dokumentationen här: https://docs.mattermost.com/manage/cloud-data-export.html." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Du har nått maxgränsen {{.NumIntegrations}} aktiva integrationer. Om du vill installera obegränsat antal integrationer kan du uppgradera till en av våra betal-abonnemang." - }, { "id": "app.usage.get_storage_usage.app_error", "translation": "Det gick inte att få fram lagringsvolym." diff --git a/i18n/tr.json b/i18n/tr.json index 680520e2fd..f9e472bfed 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -9202,10 +9202,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "Bir Bulut kopyasına mmctl ile yükleme desteklenmiyor. Lütfen şu makaleye bakın: https://docs.mattermost.com/manage/cloud-data-export.html." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Kullanabileceğiniz en fazla {{.NumIntegrations}} bütünleştirme sınırına ulaştınız. Sınırsız bütünleştirme için ücretli tarifelerimizden birine geçin." - }, { "id": "app.teams.analytics_teams_count.app_error", "translation": "Takım sayısı alınamadı" diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 9d3b1ef5fe..724dd25d78 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -9231,10 +9231,6 @@ "id": "app.post_reminder_dm", "translation": "您好,这是您关于此消息的提醒 @{{.Username}}: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "您已达到启用 {{.NumIntegrations}} 集成的最大限制。 要安装无限集成,请升级到我们的付费计划之一。" - }, { "id": "app.usage.get_storage_usage.app_error", "translation": "无法获取存储使用情况。" diff --git a/model/client4.go b/model/client4.go index d195bec920..47a1ce366b 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8434,19 +8434,6 @@ func (c *Client4) GetTeamsUsage() (*TeamsUsage, *Response, error) { return usage, BuildResponse(r), err } -// GetIntegrationsUsage returns usage information on integrations, including the count of enabled integrations -func (c *Client4) GetIntegrationsUsage() (*IntegrationsUsage, *Response, error) { - r, err := c.DoAPIGet(c.usageRoute()+"/integrations", "") - if err != nil { - return nil, BuildResponse(r), err - } - defer closeBody(r) - - var usage *IntegrationsUsage - err = json.NewDecoder(r.Body).Decode(&usage) - return usage, BuildResponse(r), err -} - func (c *Client4) GetNewTeamMembersSince(teamID string, timeRange string, page int, perPage int) (*NewTeamMembersList, *Response, error) { query := fmt.Sprintf("?time_range=%v&page=%v&per_page=%v", timeRange, page, perPage) r, err := c.DoAPIGet(c.teamRoute(teamID)+"/top/team_members"+query, "") diff --git a/model/usage.go b/model/usage.go index 6ee0c4383f..413ee2b8e6 100644 --- a/model/usage.go +++ b/model/usage.go @@ -16,10 +16,6 @@ type TeamsUsage struct { CloudArchived int64 `json:"cloud_archived"` } -type IntegrationsUsage struct { - Enabled int `json:"enabled"` -} - var InstalledIntegrationsIgnoredPlugins = map[string]struct{}{ PluginIdPlaybooks: {}, PluginIdFocalboard: {}, diff --git a/model/websocket_message.go b/model/websocket_message.go index 41c802dbfc..9cd3892453 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -76,7 +76,6 @@ const ( WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadReadChanged = "thread_read_changed" WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received" - WebsocketEventIntegrationsUsageChanged = "integrations_usage_changed" ) type WebSocketMessage interface { From 87db42f42ca273629e4e750ee2da4abf28336ced Mon Sep 17 00:00:00 2001 From: Tom De Moor Date: Wed, 23 Nov 2022 17:42:52 +0100 Subject: [PATCH 20/80] Deleted translation using Weblate (Croatian) --- i18n/hr.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 i18n/hr.json diff --git a/i18n/hr.json b/i18n/hr.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/i18n/hr.json +++ /dev/null @@ -1 +0,0 @@ -{} From ff1ea0599e0b52a2036c0a91c6b9d28079e53e71 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 23 Nov 2022 14:06:29 -0500 Subject: [PATCH 21/80] [MM-48560] LastAccessiblePostTime not removed on upgrade to Professional (#21708) * Delete system value for LastAccessibleFileTime and LastAccessiblePostTime if the limits are 0 and a value is set * Update tests --- app/file.go | 22 +++++++++++++++ app/file_test.go | 71 ++++++++++++++++++++++++++++++++++-------------- app/post.go | 18 ++++++++++++ app/post_test.go | 6 ++-- 4 files changed, 95 insertions(+), 22 deletions(-) diff --git a/app/file.go b/app/file.go index 341c5f655e..2f0050dff6 100644 --- a/app/file.go +++ b/app/file.go @@ -1380,6 +1380,28 @@ func (a *App) ComputeLastAccessibleFileTime() error { return appErr } + if limit == 0 { + // All files are accessible - we must check if a previous value was set so we can clear it + systemValue, err := a.Srv().Store().System().GetByName(model.SystemLastAccessibleFileTime) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + // All files are already accessible + return nil + default: + return model.NewAppError("ComputeLastAccessibleFileTime", "app.system.get_by_name.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + if systemValue != nil { + // Previous value was set, so we must clear it + if _, err := a.Srv().Store().System().PermanentDeleteByName(model.SystemLastAccessibleFileTime); err != nil { + return model.NewAppError("ComputeLastAccessibleFileTime", "app.system.permanent_delete_by_name.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + return nil + } + createdAt, err := a.Srv().GetStore().FileInfo().GetUptoNSizeFileTime(limit) if err != nil { var nfErr *store.ErrNotFound diff --git a/app/file_test.go b/app/file_test.go index 09c03e640e..b781af6483 100644 --- a/app/file_test.go +++ b/app/file_test.go @@ -591,30 +591,61 @@ func TestGetLastAccessibleFileTime(t *testing.T) { } func TestComputeLastAccessibleFileTime(t *testing.T) { - th := SetupWithStoreMock(t) - defer th.TearDown() + t.Run("Updates the time, if cloud limit is applicable", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - cloud := &eMocks.CloudInterface{} - th.App.Srv().Cloud = cloud + cloud := &eMocks.CloudInterface{} + th.App.Srv().Cloud = cloud - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ - Files: &model.FilesLimits{ - TotalStorage: model.NewInt64(1), - }, - }, nil) + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Files: &model.FilesLimits{ + TotalStorage: model.NewInt64(1), + }, + }, nil) - mockStore := th.App.Srv().Store().(*storemocks.Store) - mockFileStore := storemocks.FileInfoStore{} - mockFileStore.On("GetUptoNSizeFileTime", mock.Anything).Return(int64(1), nil) - mockSystemStore := storemocks.SystemStore{} - mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil) - mockStore.On("FileInfo").Return(&mockFileStore) - mockStore.On("System").Return(&mockSystemStore) + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockFileStore := storemocks.FileInfoStore{} + mockFileStore.On("GetUptoNSizeFileTime", mock.Anything).Return(int64(1), nil) + mockSystemStore := storemocks.SystemStore{} + mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil) + mockStore.On("FileInfo").Return(&mockFileStore) + mockStore.On("System").Return(&mockSystemStore) - err := th.App.ComputeLastAccessibleFileTime() - require.NoError(t, err) + err := th.App.ComputeLastAccessibleFileTime() + require.NoError(t, err) - mockSystemStore.AssertCalled(t, "SaveOrUpdate", mock.Anything) + mockSystemStore.AssertCalled(t, "SaveOrUpdate", mock.Anything) + }) + + t.Run("Removes the time, if cloud limit is not applicable", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := &eMocks.CloudInterface{} + th.App.Srv().Cloud = cloud + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, nil) + + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockFileStore := storemocks.FileInfoStore{} + mockFileStore.On("GetUptoNSizeFileTime", mock.Anything).Return(int64(1), nil) + mockSystemStore := storemocks.SystemStore{} + mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessibleFileTime, Value: "10"}, nil) + mockSystemStore.On("PermanentDeleteByName", mock.Anything).Return(nil, nil) + mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil) + mockStore.On("FileInfo").Return(&mockFileStore) + mockStore.On("System").Return(&mockSystemStore) + + err := th.App.ComputeLastAccessibleFileTime() + require.NoError(t, err) + + mockSystemStore.AssertNotCalled(t, "SaveOrUpdate", mock.Anything) + mockSystemStore.AssertCalled(t, "PermanentDeleteByName", mock.Anything) + + }) } diff --git a/app/post.go b/app/post.go index 78d14b16ef..301c5e9c2f 100644 --- a/app/post.go +++ b/app/post.go @@ -1441,6 +1441,24 @@ func (a *App) ComputeLastAccessiblePostTime() error { } if limit == 0 { + // All posts are accessible - we must check if a previous value was set so we can clear it + systemValue, err := a.Srv().Store().System().GetByName(model.SystemLastAccessiblePostTime) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + // There was no previous value, nothing to do + return nil + default: + return model.NewAppError("ComputeLastAccessiblePostTime", "app.system.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + if systemValue != nil { + // Previous value was set, so we must clear it + if _, err = a.Srv().Store().System().PermanentDeleteByName(model.SystemLastAccessiblePostTime); err != nil { + return model.NewAppError("ComputeLastAccessiblePostTime", "app.system.permanent_delete_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } // Cloud limit is not applicable return nil } diff --git a/app/post_test.go b/app/post_test.go index 2ed0092a6c..74744e2363 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -2887,7 +2887,7 @@ func TestComputeLastAccessiblePostTime(t *testing.T) { mockSystemStore.AssertCalled(t, "SaveOrUpdate", mock.Anything) }) - t.Run("Do NOT update the time, if cloud limit is NOT applicable", func(t *testing.T) { + t.Run("Remove the time if cloud limit is NOT applicable", func(t *testing.T) { th := SetupWithStoreMock(t) defer th.TearDown() @@ -2901,13 +2901,15 @@ func TestComputeLastAccessiblePostTime(t *testing.T) { mockStore := th.App.Srv().Store().(*storemocks.Store) mockSystemStore := storemocks.SystemStore{} - mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil) + mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "10"}, nil) + mockSystemStore.On("PermanentDeleteByName", mock.Anything).Return(nil, nil) mockStore.On("System").Return(&mockSystemStore) err := th.App.ComputeLastAccessiblePostTime() assert.NoError(t, err) mockSystemStore.AssertNotCalled(t, "SaveOrUpdate", mock.Anything) + mockSystemStore.AssertCalled(t, "PermanentDeleteByName", mock.Anything) }) } From c44d37629a6d8eae3c600426f4c83d25453e7b19 Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Wed, 23 Nov 2022 21:08:21 +0200 Subject: [PATCH 22/80] MM-46410: adds urgency on mention counts (#20999) * MM-46410: adds urgency on mention counts We have introduced priority for posts in https://github.com/mattermost/mattermost-webapp/pull/10951. We do need to color the mention badges in the webapp with a prominent color when a mention is posted in an urgent message. A thread has urgent mentions if the root post is marked as urgent, and the replies contain mentions to the user viewing the thread. This PR adds a column, urgentmentioncount, in channelmembers. Furthermore when asking for team/thread mention counts, we also return urgent mention counts for the user. Adds a new table to hold posts priorities Refactors priority out of the props and into the new table We are nilifying Metadata when post.ForPlugin(), which didn't save Priority for a post when Boards was enabled. This commit copies metadata again to the post, so metadata are reinstated. Co-authored-by: Mattermod Co-authored-by: Vishal Choudhary --- api4/post.go | 6 +- api4/resolver_channel_member_test.go | 25 +- api4/schema.graphqls | 31 +-- api4/user_test.go | 81 ++++++- app/app_iface.go | 6 +- app/channel.go | 13 +- app/notification.go | 5 +- app/opentracing/opentracing_layer.go | 52 +++- app/post.go | 84 +++++-- app/post_metadata.go | 28 ++- app/post_metadata_test.go | 44 ++-- app/post_priority.go | 34 +++ app/post_test.go | 89 +++++-- app/team.go | 18 +- app/user.go | 20 +- db/migrations/migrations.list | 4 + .../000097_create_posts_priority.down.sql | 16 ++ .../mysql/000097_create_posts_priority.up.sql | 23 ++ .../000097_create_posts_priority.down.sql | 3 + .../000097_create_posts_priority.up.sql | 9 + i18n/en.json | 12 + model/channel_member.go | 96 ++++---- model/post.go | 31 +++ model/post_metadata.go | 15 ++ model/team_member.go | 15 +- model/thread.go | 13 +- store/opentracinglayer/opentracinglayer.go | 99 +++++++- store/retrylayer/retrylayer.go | 111 ++++++++- store/retrylayer/retrylayer_test.go | 1 + store/sqlstore/channel_store.go | 224 +++++++++++++----- store/sqlstore/post_priority_store.go | 63 +++++ store/sqlstore/post_priority_store_test.go | 14 ++ store/sqlstore/post_store.go | 22 ++ store/sqlstore/store.go | 6 + store/sqlstore/thread_store.go | 132 ++++++++--- store/store.go | 16 +- store/storetest/channel_store.go | 69 +++++- store/storetest/mocks/ChannelStore.go | 45 +++- store/storetest/mocks/PostPriorityStore.go | 61 +++++ store/storetest/mocks/Store.go | 16 ++ store/storetest/mocks/ThreadStore.go | 49 ++-- store/storetest/post_priority_store.go | 72 ++++++ store/storetest/post_store.go | 25 ++ store/storetest/store.go | 3 + store/storetest/thread_store.go | 119 ++++++++-- store/storetest/user_store.go | 8 +- store/timerlayer/timerlayer.go | 91 ++++++- 47 files changed, 1676 insertions(+), 343 deletions(-) create mode 100644 app/post_priority.go create mode 100644 db/migrations/mysql/000097_create_posts_priority.down.sql create mode 100644 db/migrations/mysql/000097_create_posts_priority.up.sql create mode 100644 db/migrations/postgres/000097_create_posts_priority.down.sql create mode 100644 db/migrations/postgres/000097_create_posts_priority.up.sql create mode 100644 store/sqlstore/post_priority_store.go create mode 100644 store/sqlstore/post_priority_store_test.go create mode 100644 store/storetest/mocks/PostPriorityStore.go create mode 100644 store/storetest/post_priority_store.go diff --git a/api4/post.go b/api4/post.go index 05ca87b6c8..b4ad80d653 100644 --- a/api4/post.go +++ b/api4/post.go @@ -141,7 +141,7 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) rp = model.AddPostActionCookies(rp, c.App.PostActionCookieSecret()) - rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, true, false) + rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, true, false, true) rp, err := c.App.SanitizePostMetadataForUser(c.AppContext, rp, c.AppContext.Session().UserId) if err != nil { c.Err = err @@ -420,7 +420,7 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) { return } - post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, false, false) + post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, false, false, true) post, err = c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId) if err != nil { c.Err = err @@ -479,7 +479,7 @@ func getPostsByIds(c *Context, w http.ResponseWriter, r *http.Request) { } } - post = c.App.PreparePostForClient(c.AppContext, post, false, false) + post = c.App.PreparePostForClient(c.AppContext, post, false, false, true) post.StripActionIntegrations() posts = append(posts, post) } diff --git a/api4/resolver_channel_member_test.go b/api4/resolver_channel_member_test.go index d4278145e7..96d5da7806 100644 --- a/api4/resolver_channel_member_test.go +++ b/api4/resolver_channel_member_test.go @@ -61,17 +61,18 @@ func TestGraphQLChannelMembers(t *testing.T) { SchemeManaged bool `json:"schemeManaged"` BuiltIn bool `json:"builtIn"` } `json:"roles"` - LastViewedAt float64 `json:"lastViewedAt"` - LastUpdateAt float64 `json:"lastUpdateAt"` - MsgCount float64 `json:"msgCount"` - MentionCount float64 `json:"mentionCount"` - MentionCountRoot float64 `json:"mentionCountRoot"` - MsgCountRoot float64 `json:"msgCountRoot"` - NotifyProps model.StringMap `json:"notifyProps"` - SchemeGuest bool `json:"schemeGuest"` - SchemeUser bool `json:"schemeUser"` - SchemeAdmin bool `json:"schemeAdmin"` - Cursor string `json:"cursor"` + LastViewedAt float64 `json:"lastViewedAt"` + LastUpdateAt float64 `json:"lastUpdateAt"` + MsgCount float64 `json:"msgCount"` + MentionCount float64 `json:"mentionCount"` + MentionCountRoot float64 `json:"mentionCountRoot"` + UrgentMentionCount float64 `json:"urgentMentionCount"` + MsgCountRoot float64 `json:"msgCountRoot"` + NotifyProps model.StringMap `json:"notifyProps"` + SchemeGuest bool `json:"schemeGuest"` + SchemeUser bool `json:"schemeUser"` + SchemeAdmin bool `json:"schemeAdmin"` + Cursor string `json:"cursor"` } `json:"channelMembers"` } @@ -101,6 +102,7 @@ func TestGraphQLChannelMembers(t *testing.T) { msgCount mentionCount mentionCountRoot + urgentMentionCount msgCountRoot schemeGuest schemeUser @@ -181,6 +183,7 @@ func TestGraphQLChannelMembers(t *testing.T) { msgCount mentionCount mentionCountRoot + urgentMentionCount } } `, diff --git a/api4/schema.graphqls b/api4/schema.graphqls index 5af4d4d87c..e277832f04 100644 --- a/api4/schema.graphqls +++ b/api4/schema.graphqls @@ -69,21 +69,22 @@ type Channel { } type ChannelMember { - channel : Channel - user : User - roles : [Role]! - lastViewedAt : Float! - msgCount : Float! - mentionCount : Float! - mentionCountRoot : Float! - msgCountRoot : Float! - notifyProps : StringMap! - lastUpdateAt : Float! - schemeGuest : Boolean! - schemeUser : Boolean! - schemeAdmin : Boolean! - explicitRoles : String! - cursor: String + channel : Channel + user : User + roles : [Role]! + lastViewedAt : Float! + msgCount : Float! + mentionCount : Float! + urgentMentionCount: Float! + mentionCountRoot : Float! + msgCountRoot : Float! + notifyProps : StringMap! + lastUpdateAt : Float! + schemeGuest : Boolean! + schemeUser : Boolean! + schemeAdmin : Boolean! + explicitRoles : String! + cursor : String } # Deliberately omitting password, authData, mfaSecret. diff --git a/api4/user_test.go b/api4/user_test.go index 8185ce6c03..d269593fce 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -5720,10 +5720,12 @@ func TestUpdatePassword(t *testing.T) { } func TestGetThreadsForUser(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") os.Setenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS", "true") defer os.Unsetenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS") + th := Setup(t).InitBasic() + defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ThreadAutoFollow = true @@ -5820,7 +5822,49 @@ func TestGetThreadsForUser(t *testing.T) { require.NoError(t, err) require.Len(t, uss.Threads, 1) require.Greater(t, uss.Threads[0].Post.DeleteAt, int64(0)) + }) + t.Run("isUrgent, 1 thread", func(t *testing.T) { + testCases := []struct { + featureEnabled bool + expected bool + }{ + {featureEnabled: true, expected: true}, + {featureEnabled: false, expected: false}, + } + + for _, tc := range testCases { + func() { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = tc.featureEnabled + cfg.FeatureFlags.PostPriority = true + }) + + client := th.Client + + rpost, resp, err := client.CreatePost(&model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "testMsg", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + _, resp, err = client.CreatePost(&model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + + defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) + + uss, _, err := th.Client.GetUserThreads(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) + require.NoError(t, err) + require.Len(t, uss.Threads, 1) + require.Equal(t, uss.Threads[0].IsUrgent, tc.expected) + }() + } }) t.Run("paged, 30 threads", func(t *testing.T) { @@ -6515,13 +6559,19 @@ func TestThreadCounts(t *testing.T) { } func TestSingleThreadGet(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") os.Setenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS", "true") defer os.Unsetenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS") + + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ThreadAutoFollow = true *cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true }) client := th.Client @@ -6534,7 +6584,15 @@ func TestSingleThreadGet(t *testing.T) { postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) // create another thread to check that we are not returning it by mistake - rpost2, _ := postAndCheck(t, client, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testMsg2"}) + rpost2, _ := postAndCheck(t, client, &model.Post{ + ChannelId: th.BasicChannel2.Id, + Message: "testMsg2", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }) postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testReply", RootId: rpost2.Id}) // regular user should have two threads with 3 replies total @@ -6546,9 +6604,22 @@ func TestSingleThreadGet(t *testing.T) { require.Equal(t, threads.Threads[0].PostId, tr.PostId) require.Empty(t, tr.Participants[0].Username) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = false + }) + tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) require.NoError(t, err) require.NotEmpty(t, tr.Participants[0].Username) + require.Equal(t, false, tr.IsUrgent) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + }) + + tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) + require.NoError(t, err) + require.Equal(t, true, tr.IsUrgent) } func TestMaintainUnreadMentionsInThread(t *testing.T) { diff --git a/app/app_iface.go b/app/app_iface.go index a96681d93c..093d97a035 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -716,6 +716,8 @@ type AppIface interface { GetPreferenceByCategoryForUser(userID string, category string) (model.Preferences, *model.AppError) GetPreferencesForUser(userID string) (model.Preferences, *model.AppError) GetPrevPostIdFromPostList(postList *model.PostList, collapsedThreads bool) string + GetPriorityForPost(postId string) (*model.PostPriority, *model.AppError) + GetPriorityForPostList(list *model.PostList) (map[string]*model.PostPriority, *model.AppError) GetPrivateChannelsForTeam(c request.CTX, teamID string, offset int, limit int) (model.ChannelList, *model.AppError) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) GetPublicChannelsByIdsForTeam(c request.CTX, teamID string, channelIDs []string) (model.ChannelList, *model.AppError) @@ -927,8 +929,8 @@ type AppIface interface { PostUpdateChannelPurposeMessage(c request.CTX, userID string, channel *model.Channel, oldChannelPurpose string, newChannelPurpose string) *model.AppError PostWithProxyAddedToImageURLs(post *model.Post) *model.Post PostWithProxyRemovedFromImageURLs(post *model.Post) *model.Post - PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost bool) *model.Post - PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost bool) *model.Post + PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post + PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post PreparePostListForClient(c request.CTX, originalList *model.PostList) *model.PostList ProcessSlackText(text string) string Publish(message *model.WebSocketEvent) diff --git a/app/channel.go b/app/channel.go index f5c864ca38..0fe00de4e6 100644 --- a/app/channel.go +++ b/app/channel.go @@ -2609,12 +2609,12 @@ func (a *App) MarkChannelAsUnreadFromPost(c request.CTX, postID string, userID s return nil, err } - unreadMentions, unreadMentionsRoot, err := a.countMentionsFromPost(c, user, post) + unreadMentions, unreadMentionsRoot, urgentMentions, err := a.countMentionsFromPost(c, user, post) if err != nil { return nil, err } - channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, true) + channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, urgentMentions, true) if nErr != nil { return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } @@ -2641,7 +2641,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st threadId = post.Id } - unreadMentions, unreadMentionsRoot, appErr := a.countMentionsFromPost(c, user, post) + unreadMentions, unreadMentionsRoot, urgentMentions, appErr := a.countMentionsFromPost(c, user, post) if appErr != nil { return nil, appErr } @@ -2650,7 +2650,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st // In CRT Supported Client: badge on channel only sums mentions in root posts including and below the post that was marked. // In CRT Unsupported Client: badge on channel sums mentions in all posts (root & replies) including and below the post that was marked unread. if post.RootId == "" { - channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, true) + channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, urgentMentions, true) if nErr != nil { return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } @@ -2706,7 +2706,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st if mErr != nil { return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(mErr) } - thread, mErr := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true) + thread, mErr := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true, a.isPostPriorityEnabled()) if mErr != nil { return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(mErr) } @@ -2724,7 +2724,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st } } - channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, 0, false) + channelUnread, nErr := a.Srv().Store().Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, 0, 0, false) if nErr != nil { return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } @@ -2741,6 +2741,7 @@ func (a *App) sendWebSocketPostUnreadEvent(c request.CTX, channelUnread *model.C } message.Add("mention_count", channelUnread.MentionCount) message.Add("mention_count_root", channelUnread.MentionCountRoot) + message.Add("urgent_mention_count", channelUnread.UrgentMentionCount) message.Add("last_viewed_at", channelUnread.LastViewedAt) message.Add("post_id", postID) a.Publish(message) diff --git a/app/notification.go b/app/notification.go index 99c1a9a9bb..cd275134d4 100644 --- a/app/notification.go +++ b/app/notification.go @@ -305,7 +305,8 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea mentionedUsersList = append(mentionedUsersList, id) } - nErr := a.Srv().Store().Channel().IncrementMentionCount(post.ChannelId, mentionedUsersList, post.RootId == "") + nErr := a.Srv().Store().Channel().IncrementMentionCount(post.ChannelId, mentionedUsersList, post.RootId == "", post.IsUrgent()) + if nErr != nil { mlog.Warn( "Failed to update mention count", @@ -596,7 +597,7 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea } threadMembership = tm } - userThread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true) + userThread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true, a.isPostPriorityEnabled()) if err != nil { return nil, errors.Wrapf(err, "cannot get thread %q for user %q", post.RootId, uid) } diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 51a5daba92..d58f160c4b 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -8118,6 +8118,50 @@ func (a *OpenTracingAppLayer) GetPrevPostIdFromPostList(postList *model.PostList return resultVar0 } +func (a *OpenTracingAppLayer) GetPriorityForPost(postId string) (*model.PostPriority, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPriorityForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetPriorityForPost(postId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetPriorityForPostList(list *model.PostList) (map[string]*model.PostPriority, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPriorityForPostList") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetPriorityForPostList(list) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetPrivateChannelsForTeam(c request.CTX, teamID string, offset int, limit int) (model.ChannelList, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPrivateChannelsForTeam") @@ -13023,7 +13067,7 @@ func (a *OpenTracingAppLayer) PostWithProxyRemovedFromImageURLs(post *model.Post return resultVar0 } -func (a *OpenTracingAppLayer) PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost bool, isEditPost bool) *model.Post { +func (a *OpenTracingAppLayer) PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost bool, isEditPost bool, includePriority bool) *model.Post { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PreparePostForClient") @@ -13035,12 +13079,12 @@ func (a *OpenTracingAppLayer) PreparePostForClient(c request.CTX, originalPost * }() defer span.Finish() - resultVar0 := a.app.PreparePostForClient(c, originalPost, isNewPost, isEditPost) + resultVar0 := a.app.PreparePostForClient(c, originalPost, isNewPost, isEditPost, includePriority) return resultVar0 } -func (a *OpenTracingAppLayer) PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost bool, isEditPost bool) *model.Post { +func (a *OpenTracingAppLayer) PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost bool, isEditPost bool, includePriority bool) *model.Post { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PreparePostForClientWithEmbedsAndImages") @@ -13052,7 +13096,7 @@ func (a *OpenTracingAppLayer) PreparePostForClientWithEmbedsAndImages(c request. }() defer span.Finish() - resultVar0 := a.app.PreparePostForClientWithEmbedsAndImages(c, originalPost, isNewPost, isEditPost) + resultVar0 := a.app.PreparePostForClientWithEmbedsAndImages(c, originalPost, isNewPost, isEditPost, includePriority) return resultVar0 } diff --git a/app/post.go b/app/post.go index 301c5e9c2f..b9f1bf0ed3 100644 --- a/app/post.go +++ b/app/post.go @@ -259,7 +259,15 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel } } + if !a.isPostPriorityEnabled() && post.GetPriority() != nil { + post.Metadata.Priority = nil + } + if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { + var metadata *model.PostMetadata + if post.Metadata != nil { + metadata = post.Metadata.Copy() + } var rejectionError *model.AppError pluginContext := pluginContext(c) pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { @@ -273,8 +281,12 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel return false } if replacementPost != nil { - // the original post's metadata (if there ever was any) is lost, and will be rebuilt. post = replacementPost + if post.Metadata != nil && metadata != nil { + post.Metadata.Priority = metadata.Priority + } else { + post.Metadata = metadata + } } return true @@ -343,7 +355,9 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel // Normally, we would let the API layer call PreparePostForClient, but we do it here since it also needs // to be done when we send the post over the websocket in handlePostEvents - rpost = a.PreparePostForClient(c, rpost, true, false) + // PS: we don't want to include PostPriority from the db to avoid the replica lag, + // so we just return the one that was passed with post + rpost = a.PreparePostForClient(c, rpost, true, false, false) // Make sure poster is following the thread if *a.Config().ServiceSettings.ThreadAutoFollow && rpost.RootId != "" { @@ -515,7 +529,7 @@ func (a *App) SendEphemeralPost(c request.CTX, userID string, post *model.Post) post.GenerateActionIds() message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", post.ChannelId, userID, nil, "") - post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false) + post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false, true) post = model.AddPostActionCookies(post, a.PostActionCookieSecret()) postJSON, jsonErr := post.ToJSON() @@ -538,7 +552,7 @@ func (a *App) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post post.GenerateActionIds() message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, userID, nil, "") - post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false) + post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false, true) post = model.AddPostActionCookies(post, a.PostActionCookieSecret()) postJSON, jsonErr := post.ToJSON() if jsonErr != nil { @@ -682,7 +696,7 @@ func (a *App) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) }) } - rpost = a.PreparePostForClientWithEmbedsAndImages(c, rpost, false, true) + rpost = a.PreparePostForClientWithEmbedsAndImages(c, rpost, false, true, true) // Ensure IsFollowing is nil since this updated post will be broadcast to all users // and we don't want to have to populate it for every single user and broadcast to each @@ -1705,7 +1719,7 @@ func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.P posts, nErr := a.Srv().Store().Post().GetPostsByThread(post.Id, timestamp) if nErr != nil { - return 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return 0, model.NewAppError("countThreadMentions", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } count := 0 @@ -1732,7 +1746,7 @@ func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.P groups, nErr := a.getGroupsAllowedForReferenceInChannel(channel, team) if nErr != nil { - return 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return 0, model.NewAppError("countThreadMentions", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } for _, p := range posts { @@ -1749,25 +1763,33 @@ func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.P // countMentionsFromPost returns the number of posts in the post's channel that mention the user after and including the // given post. -func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model.Post) (int, int, *model.AppError) { +func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model.Post) (int, int, int, *model.AppError) { channel, err := a.GetChannel(c, post.ChannelId) if err != nil { - return 0, 0, err + return 0, 0, 0, err } if channel.Type == model.ChannelTypeDirect { // In a DM channel, every post made by the other user is a mention count, countRoot, nErr := a.Srv().Store().Channel().CountPostsAfter(post.ChannelId, post.CreateAt-1, channel.GetOtherUserIdForDM(user.Id)) if nErr != nil { - return 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } - return count, countRoot, nil + var urgentCount int + if a.isPostPriorityEnabled() { + urgentCount, nErr = a.Srv().Store().Channel().CountUrgentPostsAfter(post.ChannelId, post.CreateAt-1, channel.GetOtherUserIdForDM(user.Id)) + if nErr != nil { + return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.count_urgent_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + + return count, countRoot, urgentCount, nil } channelMember, err := a.GetChannelMember(c, channel.Id, user.Id) if err != nil { - return 0, 0, err + return 0, 0, 0, err } keywords := addMentionKeywordsForUser( @@ -1785,15 +1807,25 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model thread, err := a.GetPostThread(post.Id, model.GetPostsOptions{}, user.Id) if err != nil { - return 0, 0, err + return 0, 0, 0, err } count := 0 countRoot := 0 + urgentCount := 0 if isPostMention(user, post, keywords, thread.Posts, mentionedByThread, checkForCommentMentions) { count += 1 if post.RootId == "" { countRoot += 1 + if a.isPostPriorityEnabled() { + priority, err := a.GetPriorityForPost(post.Id) + if err != nil { + return 0, 0, 0, err + } + if priority != nil && *priority.Priority == model.PostPriorityUrgent { + urgentCount += 1 + } + } } } @@ -1807,18 +1839,32 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model PerPage: perPage, }) if err != nil { - return 0, 0, err + return 0, 0, 0, err } + mentionPostIds := make([]string, 0) for _, postID := range postList.Order { if isPostMention(user, postList.Posts[postID], keywords, postList.Posts, mentionedByThread, checkForCommentMentions) { count += 1 if postList.Posts[postID].RootId == "" { + mentionPostIds = append(mentionPostIds, postID) countRoot += 1 } } } + if a.isPostPriorityEnabled() { + priorityList, nErr := a.Srv().Store().PostPriority().GetForPosts(mentionPostIds) + if err != nil { + return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.get_priority_for_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + for _, priority := range priorityList { + if *priority.Priority == model.PostPriorityUrgent { + urgentCount += 1 + } + } + } + if len(postList.Order) < perPage { break } @@ -1826,7 +1872,7 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model page += 1 } - return count, countRoot, nil + return count, countRoot, urgentCount, nil } func isCommentMention(user *model.User, post *model.Post, otherPosts map[string]*model.Post, mentionedByThread map[string]bool) bool { @@ -2025,7 +2071,7 @@ func (a *App) SetPostReminder(postID, userID string, targetTime int64) *model.Ap } message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", ephemeralPost.ChannelId, userID, nil, "") - ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(request.EmptyContext(a.Log()), ephemeralPost, true, false) + ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(request.EmptyContext(a.Log()), ephemeralPost, true, false, true) ephemeralPost = model.AddPostActionCookies(ephemeralPost, a.PostActionCookieSecret()) postJSON, jsonErr := ephemeralPost.ToJSON() @@ -2107,7 +2153,7 @@ func (a *App) CheckPostReminders() { func includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThreadList, userID string) (*model.TopThreadList, error) { for _, topThread := range topThreadList.Items { - topThread.Post = a.PreparePostForClientWithEmbedsAndImages(c, topThread.Post, false, false) + topThread.Post = a.PreparePostForClientWithEmbedsAndImages(c, topThread.Post, false, false, true) sanitizedPost, err := a.SanitizePostMetadataForUser(c, topThread.Post, userID) if err != nil { return nil, err @@ -2116,3 +2162,7 @@ func includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThrea } return topThreadList, nil } + +func (a *App) isPostPriorityEnabled() bool { + return a.Config().FeatureFlags.PostPriority && *a.Config().ServiceSettings.PostPriority +} diff --git a/app/post_metadata.go b/app/post_metadata.go index 9d57bf83aa..368d0803f0 100644 --- a/app/post_metadata.go +++ b/app/post_metadata.go @@ -56,11 +56,20 @@ func (a *App) PreparePostListForClient(c request.CTX, originalList *model.PostLi } for id, originalPost := range originalList.Posts { - post := a.PreparePostForClientWithEmbedsAndImages(c, originalPost, false, false) + post := a.PreparePostForClientWithEmbedsAndImages(c, originalPost, false, false, false) list.Posts[id] = post } + if a.isPostPriorityEnabled() { + priority, _ := a.GetPriorityForPostList(list) + for _, id := range list.Order { + if _, ok := priority[id]; ok { + list.Posts[id].Metadata.Priority = priority[id] + } + } + } + return list } @@ -90,7 +99,7 @@ func (a *App) OverrideIconURLIfEmoji(c request.CTX, post *model.Post) { } } -func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost bool) *model.Post { +func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post { post := originalPost.Clone() // Proxy image links before constructing metadata so that requests go through the proxy @@ -123,11 +132,20 @@ func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNe post.Metadata.Files = fileInfos } + if includePriority && a.isPostPriorityEnabled() && post.RootId == "" { + // Post's Priority if any + if priority, err := a.GetPriorityForPost(post.Id); err != nil { + mlog.Warn("Failed to get post priority for a post", mlog.String("post_id", post.Id), mlog.Err(err)) + } else { + post.Metadata.Priority = priority + } + } + return post } -func (a *App) PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost bool) *model.Post { - post := a.PreparePostForClient(c, originalPost, isNewPost, isEditPost) +func (a *App) PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post { + post := a.PreparePostForClient(c, originalPost, isNewPost, isEditPost, includePriority) post = a.getEmbedsAndImages(c, post, isNewPost) return post } @@ -562,7 +580,7 @@ func (a *App) getLinkMetadata(c request.CTX, requestURL string, timestamp int64, permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPost, referencedTeam, referencedChannel)} } else { // referencedPost does not contain a permalink: we get its metadata - referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(c, referencedPost, false, false) + referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(c, referencedPost, false, false, false) permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPostWithMetadata, referencedTeam, referencedChannel)} } } else { diff --git a/app/post_metadata_test.go b/app/post_metadata_test.go index 29e9ad8c4d..50e2608ebc 100644 --- a/app/post_metadata_test.go +++ b/app/post_metadata_test.go @@ -125,7 +125,7 @@ func TestPreparePostForClient(t *testing.T) { Message: message, } - clientPost := th.App.PreparePostForClient(th.Context, post, false, true) + clientPost := th.App.PreparePostForClient(th.Context, post, false, true, false) t.Run("doesn't mutate provided post", func(t *testing.T) { assert.NotEqual(t, clientPost, post, "should've returned a new post") @@ -151,7 +151,7 @@ func TestPreparePostForClient(t *testing.T) { post := th.CreatePost(th.BasicChannel) - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) assert.False(t, clientPost == post, "should've returned a new post") assert.Equal(t, clientPost, post, "shouldn't have changed any metadata") @@ -167,7 +167,7 @@ func TestPreparePostForClient(t *testing.T) { reaction3 := th.AddReactionToPost(post, th.BasicUser2, "ice_cream") post.HasReactions = true - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) assert.Len(t, clientPost.Metadata.Reactions, 3, "should've populated Reactions") assert.Equal(t, reaction1, clientPost.Metadata.Reactions[0], "first reaction is incorrect") @@ -194,7 +194,7 @@ func TestPreparePostForClient(t *testing.T) { var clientPost *model.Post assert.Eventually(t, func() bool { - clientPost = th.App.PreparePostForClient(th.Context, post, false, false) + clientPost = th.App.PreparePostForClient(th.Context, post, false, false, false) return assert.ObjectsAreEqual([]*model.FileInfo{fileInfo}, clientPost.Metadata.Files) }, time.Second, 10*time.Millisecond) @@ -230,7 +230,7 @@ func TestPreparePostForClient(t *testing.T) { th.AddReactionToPost(post, th.BasicUser2, "angry") post.HasReactions = true - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) t.Run("populates emojis", func(t *testing.T) { assert.ElementsMatch(t, []*model.Emoji{}, clientPost.Metadata.Emojis, "should've populated empty Emojis") @@ -275,7 +275,7 @@ func TestPreparePostForClient(t *testing.T) { th.AddReactionToPost(post, th.BasicUser2, "angry") post.HasReactions = true - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) t.Run("populates emojis", func(t *testing.T) { assert.ElementsMatch(t, []*model.Emoji{emoji1, emoji2, emoji3, emoji4}, clientPost.Metadata.Emojis, "should've populated post.Emojis") @@ -307,7 +307,7 @@ func TestPreparePostForClient(t *testing.T) { post.AddProp(model.PostPropsOverrideIconURL, url) post.AddProp(model.PostPropsOverrideIconEmoji, emoji) - return th.App.PreparePostForClient(th.Context, post, false, false) + return th.App.PreparePostForClient(th.Context, post, false, false, false) } emoji := "basketball" @@ -361,7 +361,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) t.Run("populates image dimensions", func(t *testing.T) { imageDimensions := clientPost.Metadata.Images @@ -394,7 +394,7 @@ func TestPreparePostForClient(t *testing.T) { post.AddProp(model.PostPropsOverrideIconEmoji, true) require.NotPanics(t, func() { - _ = th.App.PreparePostForClient(th.Context, post, false, false) + _ = th.App.PreparePostForClient(th.Context, post, false, false, false) }) }) @@ -424,7 +424,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) post.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false) // Reminder that only the first link gets an embed and dimensions @@ -459,7 +459,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] ogData := firstEmbed.Data.(*opengraph.OpenGraph) @@ -502,7 +502,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) post.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false) t.Run("populates embeds", func(t *testing.T) { assert.ElementsMatch(t, []*model.PostEmbed{ @@ -547,7 +547,7 @@ func TestPreparePostForClient(t *testing.T) { // DeleteAt isn't set on the post returned by App.DeletePost post.DeleteAt = model.GetMillis() - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) assert.NotEqual(t, nil, clientPost.Metadata, "should've populated Metadata“") assert.Equal(t, "", clientPost.Message, "should've cleaned post content") @@ -582,7 +582,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) previewPost.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview.PostID) @@ -641,7 +641,7 @@ func TestPreparePostForClient(t *testing.T) { require.Nil(t, err) previewPost.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) @@ -679,7 +679,7 @@ func TestPreparePostForClient(t *testing.T) { require.Nil(t, err) previewPost.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) referencedPostFirstEmbed := preview.Post.Metadata.Embeds[0] @@ -726,7 +726,7 @@ func TestPreparePostForClient(t *testing.T) { require.Nil(t, err) previewPost.Metadata.Embeds = nil - clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) referencedPostMetadata := preview.Post.Metadata @@ -761,7 +761,7 @@ func TestPreparePostForClient(t *testing.T) { }, th.BasicChannel, false, true) require.Nil(t, err) - clientPost := th.App.PreparePostForClient(th.Context, previewPost, false, false) + clientPost := th.App.PreparePostForClient(th.Context, previewPost, false, false, false) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview.PostID) @@ -770,13 +770,13 @@ func TestPreparePostForClient(t *testing.T) { *cfg.ServiceSettings.EnablePermalinkPreviews = false }) - th.App.PreparePostForClient(th.Context, previewPost, false, false) + th.App.PreparePostForClient(th.Context, previewPost, false, false, false) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePermalinkPreviews = true }) - clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, false, false) + clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, false, false, false) firstEmbed2 := clientPost2.Metadata.Embeds[0] preview2 := firstEmbed2.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview2.PostID) @@ -828,7 +828,7 @@ func testProxyLinkedImage(t *testing.T, th *TestHelper, shouldProxy bool) { Message: fmt.Sprintf(postTemplate, imageURL), } - clientPost := th.App.PreparePostForClient(th.Context, post, false, false) + clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false) if shouldProxy { assert.Equal(t, fmt.Sprintf(postTemplate, imageURL), post.Message, "should not have mutated original post") @@ -876,7 +876,7 @@ func testProxyOpenGraphImage(t *testing.T, th *TestHelper, shouldProxy bool) { require.Nil(t, err) post.Metadata.Embeds = nil - embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false).Metadata.Embeds + embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false).Metadata.Embeds require.Len(t, embeds, 1, "should have one embed") embed := embeds[0] diff --git a/app/post_priority.go b/app/post_priority.go new file mode 100644 index 0000000000..d4efd534b8 --- /dev/null +++ b/app/post_priority.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "database/sql" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func (a *App) GetPriorityForPost(postId string) (*model.PostPriority, *model.AppError) { + priority, err := a.Srv().Store().PostPriority().GetForPost(postId) + + if err != nil && err != sql.ErrNoRows { + return nil, model.NewAppError("GetPriorityForPost", "app.post_prority.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return priority, nil +} + +func (a *App) GetPriorityForPostList(list *model.PostList) (map[string]*model.PostPriority, *model.AppError) { + priority, err := a.Srv().Store().PostPriority().GetForPosts(list.Order) + if err != nil { + return nil, model.NewAppError("GetPriorityForPost", "app.post_prority.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + priorityMap := make(map[string]*model.PostPriority) + for _, p := range priority { + priorityMap[p.PostId] = p + } + + return priorityMap, nil +} diff --git a/app/post_test.go b/app/post_test.go index 74744e2363..0b9c37a3f8 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -1598,7 +1598,7 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, false, true) require.Nil(t, err) - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 0, count) @@ -1637,7 +1637,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post1 and post3 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) @@ -1676,7 +1676,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post2 and post3 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) @@ -1713,7 +1713,7 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, false, true) require.Nil(t, err) - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 0, count) @@ -1755,7 +1755,7 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, false, true) require.Nil(t, err) - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 0, count) @@ -1809,7 +1809,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post2 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -1863,7 +1863,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post2 and post5 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) @@ -1912,7 +1912,7 @@ func TestCountMentionsFromPost(t *testing.T) { // should be mentioned by post2 and post3 - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) @@ -1942,12 +1942,12 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, false, true) require.Nil(t, err) - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 2, count) - count, _, err = th.App.countMentionsFromPost(th.Context, user1, post1) + count, _, _, err = th.App.countMentionsFromPost(th.Context, user1, post1) assert.Nil(t, err) assert.Equal(t, 0, count) @@ -1984,7 +1984,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post1 and post3 should mention the user, but we only count post3 - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post2) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post2) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -2015,7 +2015,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post2 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -2062,7 +2062,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post4 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post3) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post3) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -2102,7 +2102,7 @@ func TestCountMentionsFromPost(t *testing.T) { // post3 should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, 1, count) @@ -2138,11 +2138,70 @@ func TestCountMentionsFromPost(t *testing.T) { // Every post should mention the user - count, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) + count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1) assert.Nil(t, err) assert.Equal(t, numPosts, count) }) + + t.Run("should count urgent mentions", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true + }) + + user1 := th.BasicUser + user2 := th.BasicUser2 + + channel := th.CreateChannel(th.Context, th.BasicTeam) + th.AddUserToChannel(user2, channel) + + user2.NotifyProps[model.MentionKeysNotifyProp] = "apple" + + post1, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: user1.Id, + ChannelId: channel.Id, + Message: fmt.Sprintf("@%s", user2.Username), + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }, channel, false, true) + require.Nil(t, err) + + _, err = th.App.CreatePost(th.Context, &model.Post{ + UserId: user1.Id, + ChannelId: channel.Id, + Message: fmt.Sprintf("@%s", user2.Username), + }, channel, false, true) + require.Nil(t, err) + + _, err = th.App.CreatePost(th.Context, &model.Post{ + UserId: user1.Id, + ChannelId: channel.Id, + Message: "apple", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }, channel, false, true) + require.Nil(t, err) + + // all posts mention the user but only post1, post3 are urgent + + _, _, count, err := th.App.countMentionsFromPost(th.Context, user2, post1) + + assert.Nil(t, err) + assert.Equal(t, 2, count) + }) } func TestFillInPostProps(t *testing.T) { diff --git a/app/team.go b/app/team.go index 30020e25ef..1de3c92350 100644 --- a/app/team.go +++ b/app/team.go @@ -1741,13 +1741,14 @@ func (a *App) GetTeamsUnreadForUser(excludeTeamId string, userID string, include } else { teamIDs = append(teamIDs, id) membersMap[id] = unreads(data[i], &model.TeamUnread{ - MsgCount: 0, - MentionCount: 0, - MentionCountRoot: 0, - MsgCountRoot: 0, - ThreadCount: 0, - ThreadMentionCount: 0, - TeamId: id, + MsgCount: 0, + MentionCount: 0, + MentionCountRoot: 0, + MsgCountRoot: 0, + ThreadCount: 0, + ThreadMentionCount: 0, + ThreadUrgentMentionCount: 0, + TeamId: id, }) } } @@ -1755,7 +1756,7 @@ func (a *App) GetTeamsUnreadForUser(excludeTeamId string, userID string, include includeCollapsedThreads = includeCollapsedThreads && *a.Config().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled if includeCollapsedThreads { - teamUnreads, err := a.Srv().Store().Thread().GetTeamsUnreadForUser(userID, teamIDs) + teamUnreads, err := a.Srv().Store().Thread().GetTeamsUnreadForUser(userID, teamIDs, a.isPostPriorityEnabled()) if err != nil { return nil, model.NewAppError("GetTeamsUnreadForUser", "app.team.get_unread.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -1763,6 +1764,7 @@ func (a *App) GetTeamsUnreadForUser(excludeTeamId string, userID string, include if _, ok := teamUnreads[teamID]; ok { member.ThreadCount = teamUnreads[teamID].ThreadCount member.ThreadMentionCount = teamUnreads[teamID].ThreadMentionCount + member.ThreadUrgentMentionCount = teamUnreads[teamID].ThreadUrgentMentionCount } } } diff --git a/app/user.go b/app/user.go index 0c3f998c9c..206a030605 100644 --- a/app/user.go +++ b/app/user.go @@ -2391,6 +2391,10 @@ func (a *App) ConvertBotToUser(c request.CTX, bot *model.Bot, userPatch *model.U func (a *App) GetThreadsForUser(userID, teamID string, options model.GetUserThreadsOpts) (*model.Threads, *model.AppError) { var result model.Threads var eg errgroup.Group + postPriorityIsEnabled := a.isPostPriorityEnabled() + if postPriorityIsEnabled { + options.IncludeIsUrgent = true + } if !options.ThreadsOnly { eg.Go(func() error { @@ -2427,6 +2431,18 @@ func (a *App) GetThreadsForUser(userID, teamID string, options model.GetUserThre return nil }) + + if postPriorityIsEnabled { + eg.Go(func() error { + totalUnreadUrgentMentions, err := a.Srv().Store().Thread().GetTotalUnreadUrgentMentions(userID, teamID, options) + if err != nil { + return errors.Wrapf(err, "failed to count urgent mentioned threads for user id=%s", userID) + } + result.TotalUnreadUrgentMentions = totalUnreadUrgentMentions + + return nil + }) + } } if !options.TotalsOnly { @@ -2469,7 +2485,7 @@ func (a *App) GetThreadMembershipForUser(userId, threadId string) (*model.Thread } func (a *App) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, *model.AppError) { - thread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, extended) + thread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, extended, a.isPostPriorityEnabled()) if err != nil { return nil, model.NewAppError("GetThreadForUser", "app.user.get_threads_for_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2551,7 +2567,7 @@ func (a *App) UpdateThreadFollowForUserFromChannelAdd(c request.CTX, userID, tea } message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, teamID, "", userID, nil, "") - userThread, err := a.Srv().Store().Thread().GetThreadForUser(tm, true) + userThread, err := a.Srv().Store().Thread().GetThreadForUser(tm, true, a.isPostPriorityEnabled()) if err != nil { var errNotFound *store.ErrNotFound diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index 641cd312af..1acb91f075 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -192,6 +192,8 @@ db/migrations/mysql/000095_remove_posts_parentid.down.sql db/migrations/mysql/000095_remove_posts_parentid.up.sql db/migrations/mysql/000096_threads_threadteamid.down.sql db/migrations/mysql/000096_threads_threadteamid.up.sql +db/migrations/mysql/000097_create_posts_priority.down.sql +db/migrations/mysql/000097_create_posts_priority.up.sql db/migrations/postgres/000001_create_teams.down.sql db/migrations/postgres/000001_create_teams.up.sql db/migrations/postgres/000002_create_team_members.down.sql @@ -384,3 +386,5 @@ db/migrations/postgres/000095_remove_posts_parentid.down.sql db/migrations/postgres/000095_remove_posts_parentid.up.sql db/migrations/postgres/000096_threads_threadteamid.down.sql db/migrations/postgres/000096_threads_threadteamid.up.sql +db/migrations/postgres/000097_create_posts_priority.down.sql +db/migrations/postgres/000097_create_posts_priority.up.sql diff --git a/db/migrations/mysql/000097_create_posts_priority.down.sql b/db/migrations/mysql/000097_create_posts_priority.down.sql new file mode 100644 index 0000000000..a0294c918a --- /dev/null +++ b/db/migrations/mysql/000097_create_posts_priority.down.sql @@ -0,0 +1,16 @@ +DROP TABLE IF EXISTS PostsPriority; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'UrgentMentionCount' + ) > 0, + 'ALTER TABLE ChannelMembers DROP COLUMN UrgentMentionCount;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; diff --git a/db/migrations/mysql/000097_create_posts_priority.up.sql b/db/migrations/mysql/000097_create_posts_priority.up.sql new file mode 100644 index 0000000000..b8b44d0544 --- /dev/null +++ b/db/migrations/mysql/000097_create_posts_priority.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS PostsPriority ( + PostId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + Priority varchar(32) NOT NULL, + RequestedAck tinyint(1), + PersistentNotifications tinyint(1), + PRIMARY KEY (PostId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'UrgentMentionCount' + ), + 'ALTER TABLE ChannelMembers ADD COLUMN UrgentMentionCount bigint(20);', + 'SELECT 1;' +)); + +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; diff --git a/db/migrations/postgres/000097_create_posts_priority.down.sql b/db/migrations/postgres/000097_create_posts_priority.down.sql new file mode 100644 index 0000000000..55974765fa --- /dev/null +++ b/db/migrations/postgres/000097_create_posts_priority.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS postspriority; + +ALTER TABLE channelmembers DROP COLUMN IF EXISTS urgentmentioncount; diff --git a/db/migrations/postgres/000097_create_posts_priority.up.sql b/db/migrations/postgres/000097_create_posts_priority.up.sql new file mode 100644 index 0000000000..cc22b1e96d --- /dev/null +++ b/db/migrations/postgres/000097_create_posts_priority.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS postspriority ( + postid VARCHAR(26) PRIMARY KEY, + channelid VARCHAR(26) NOT NULL, + priority VARCHAR(32) NOT NULL, + requestedack boolean, + persistentnotifications boolean +); + +ALTER TABLE channelmembers ADD COLUMN IF NOT EXISTS urgentmentioncount bigint; diff --git a/i18n/en.json b/i18n/en.json index a9612782c4..589c90973a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4551,6 +4551,10 @@ "id": "app.channel.count_posts_since.app_error", "translation": "Unable to count messages since given date." }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "Unable to count urgent posts since given date." + }, { "id": "app.channel.create_channel.internal_error", "translation": "Unable to save channel." @@ -4679,6 +4683,10 @@ "id": "app.channel.get_pinnedpost_count.app_error", "translation": "Unable to get the channel pinned post count." }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "Unable to get the priority for posts" + }, { "id": "app.channel.get_private_channels.get.app_error", "translation": "Unable to get private channels." @@ -6111,6 +6119,10 @@ "id": "app.post.update.app_error", "translation": "Unable to update the Post." }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "Unable to get postpriority for post" + }, { "id": "app.post_reminder_dm", "translation": "Hi there, here's your reminder about this message from @{{.Username}}: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" diff --git a/model/channel_member.go b/model/channel_member.go index d79695ada5..dbe0014eac 100644 --- a/model/channel_member.go +++ b/model/channel_member.go @@ -22,60 +22,64 @@ const ( ) type ChannelUnread struct { - TeamId string `json:"team_id"` - ChannelId string `json:"channel_id"` - MsgCount int64 `json:"msg_count"` - MentionCount int64 `json:"mention_count"` - MentionCountRoot int64 `json:"mention_count_root"` - MsgCountRoot int64 `json:"msg_count_root"` - NotifyProps StringMap `json:"-"` + TeamId string `json:"team_id"` + ChannelId string `json:"channel_id"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + UrgentMentionCount int64 `json:"urgent_mention_count"` + MsgCountRoot int64 `json:"msg_count_root"` + NotifyProps StringMap `json:"-"` } type ChannelUnreadAt struct { - TeamId string `json:"team_id"` - UserId string `json:"user_id"` - ChannelId string `json:"channel_id"` - MsgCount int64 `json:"msg_count"` - MentionCount int64 `json:"mention_count"` - MentionCountRoot int64 `json:"mention_count_root"` - MsgCountRoot int64 `json:"msg_count_root"` - LastViewedAt int64 `json:"last_viewed_at"` - NotifyProps StringMap `json:"-"` + TeamId string `json:"team_id"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + UrgentMentionCount int64 `json:"urgent_mention_count"` + MsgCountRoot int64 `json:"msg_count_root"` + LastViewedAt int64 `json:"last_viewed_at"` + NotifyProps StringMap `json:"-"` } type ChannelMember struct { - ChannelId string `json:"channel_id"` - UserId string `json:"user_id"` - Roles string `json:"roles"` - LastViewedAt int64 `json:"last_viewed_at"` - MsgCount int64 `json:"msg_count"` - MentionCount int64 `json:"mention_count"` - MentionCountRoot int64 `json:"mention_count_root"` - MsgCountRoot int64 `json:"msg_count_root"` - NotifyProps StringMap `json:"notify_props"` - LastUpdateAt int64 `json:"last_update_at"` - SchemeGuest bool `json:"scheme_guest"` - SchemeUser bool `json:"scheme_user"` - SchemeAdmin bool `json:"scheme_admin"` - ExplicitRoles string `json:"explicit_roles"` + ChannelId string `json:"channel_id"` + UserId string `json:"user_id"` + Roles string `json:"roles"` + LastViewedAt int64 `json:"last_viewed_at"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + UrgentMentionCount int64 `json:"urgent_mention_count"` + MsgCountRoot int64 `json:"msg_count_root"` + NotifyProps StringMap `json:"notify_props"` + LastUpdateAt int64 `json:"last_update_at"` + SchemeGuest bool `json:"scheme_guest"` + SchemeUser bool `json:"scheme_user"` + SchemeAdmin bool `json:"scheme_admin"` + ExplicitRoles string `json:"explicit_roles"` } func (o *ChannelMember) Auditable() map[string]interface{} { return map[string]interface{}{ - "channel_id": o.ChannelId, - "user_id": o.UserId, - "roles": o.Roles, - "last_viewed_at": o.LastViewedAt, - "msg_count": o.MsgCount, - "mention_count": o.MentionCount, - "mention_count_root": o.MentionCountRoot, - "msg_count_root": o.MsgCountRoot, - "notify_props": o.NotifyProps, - "last_update_at": o.LastUpdateAt, - "scheme_guest": o.SchemeGuest, - "scheme_user": o.SchemeUser, - "scheme_admin": o.SchemeAdmin, - "explicit_roles": o.ExplicitRoles, + "channel_id": o.ChannelId, + "user_id": o.UserId, + "roles": o.Roles, + "last_viewed_at": o.LastViewedAt, + "msg_count": o.MsgCount, + "mention_count": o.MentionCount, + "mention_count_root": o.MentionCountRoot, + "urgent_mention_count": o.UrgentMentionCount, + "msg_count_root": o.MsgCountRoot, + "notify_props": o.NotifyProps, + "last_update_at": o.LastUpdateAt, + "scheme_guest": o.SchemeGuest, + "scheme_user": o.SchemeUser, + "scheme_admin": o.SchemeAdmin, + "explicit_roles": o.ExplicitRoles, } } @@ -100,6 +104,10 @@ func (o *ChannelMember) MentionCountRoot_() float64 { return float64(o.MentionCountRoot) } +func (o *ChannelMember) UrgentMentionCount_() float64 { + return float64(o.UrgentMentionCount) +} + func (o *ChannelMember) MsgCountRoot_() float64 { return float64(o.MsgCountRoot) } diff --git a/model/post.go b/model/post.go index 3f9c5a630c..992188ac8e 100644 --- a/model/post.go +++ b/model/post.go @@ -71,6 +71,10 @@ const ( PostPropsGroupHighlightDisabled = "disable_group_highlight" PostPropsPreviewedPost = "previewed_post" + + PostPriorityUrgent = "urgent" + PostPropsRequestedAck = "requested_ack" + PostPropsPersistentNotifications = "persistent_notifications" ) const ( @@ -158,6 +162,15 @@ type PostReminder struct { UserId string `json:",omitempty"` } +type PostPriority struct { + Priority *string `json:"priority"` + RequestedAck *bool `json:"requested_ack"` + PersistentNotifications *bool `json:"persistent_notifications"` + // These fields are only used internally for interacting with DB. + PostId string `json:",omitempty"` + ChannelId string `json:",omitempty"` +} + type SearchParameter struct { Terms *string `json:"terms"` IsOrSearch *bool `json:"is_or_search"` @@ -306,6 +319,7 @@ type GetPostsOptions struct { FromCreateAt int64 // CreateAt after which to send the items Direction string // Only accepts up|down. Indicates the order in which to send the items. IncludeDeleted bool + IncludePostPriority bool } type PostCountOptions struct { @@ -770,3 +784,20 @@ func (o *Post) GetPreviewedPostProp() string { } return "" } + +func (o *Post) GetPriority() *PostPriority { + if o.Metadata != nil && o.Metadata.Priority != nil { + return o.Metadata.Priority + } + + return nil +} + +func (o *Post) IsUrgent() bool { + postPriority := o.GetPriority() + if postPriority == nil { + return false + } + + return *postPriority.Priority == PostPriorityUrgent +} diff --git a/model/post_metadata.go b/model/post_metadata.go index 6ccc1ebffc..3730d06f55 100644 --- a/model/post_metadata.go +++ b/model/post_metadata.go @@ -22,6 +22,9 @@ type PostMetadata struct { // Reactions holds reactions made to the post. Reactions []*Reaction `json:"reactions,omitempty"` + + // Reactions holds reactions made to the post. + Priority *PostPriority `json:"priority,omitempty"` } type PostImage struct { @@ -54,11 +57,23 @@ func (p *PostMetadata) Copy() *PostMetadata { reactionsCopy := make([]*Reaction, len(p.Reactions)) copy(reactionsCopy, p.Reactions) + var postPriorityCopy *PostPriority + if p.Priority != nil { + postPriorityCopy = &PostPriority{ + Priority: p.Priority.Priority, + RequestedAck: p.Priority.RequestedAck, + PersistentNotifications: p.Priority.PersistentNotifications, + PostId: p.Priority.PostId, + ChannelId: p.Priority.ChannelId, + } + } + return &PostMetadata{ Embeds: embedsCopy, Emojis: emojisCopy, Files: filesCopy, Images: imagesCopy, Reactions: reactionsCopy, + Priority: postPriorityCopy, } } diff --git a/model/team_member.go b/model/team_member.go index f38c29ed56..fbcecadd73 100644 --- a/model/team_member.go +++ b/model/team_member.go @@ -45,13 +45,14 @@ func (o *TeamMember) Auditable() map[string]interface{} { //msgp:ignore TeamUnread type TeamUnread struct { - TeamId string `json:"team_id"` - MsgCount int64 `json:"msg_count"` - MentionCount int64 `json:"mention_count"` - MentionCountRoot int64 `json:"mention_count_root"` - MsgCountRoot int64 `json:"msg_count_root"` - ThreadCount int64 `json:"thread_count"` - ThreadMentionCount int64 `json:"thread_mention_count"` + TeamId string `json:"team_id"` + MsgCount int64 `json:"msg_count"` + MentionCount int64 `json:"mention_count"` + MentionCountRoot int64 `json:"mention_count_root"` + MsgCountRoot int64 `json:"msg_count_root"` + ThreadCount int64 `json:"thread_count"` + ThreadMentionCount int64 `json:"thread_mention_count"` + ThreadUrgentMentionCount int64 `json:"thread_urgent_mention_count"` } //msgp:ignore TeamMemberForExport diff --git a/model/thread.go b/model/thread.go index 6513f41b40..ce8ebcca3c 100644 --- a/model/thread.go +++ b/model/thread.go @@ -41,14 +41,16 @@ type ThreadResponse struct { Post *Post `json:"post"` UnreadReplies int64 `json:"unread_replies"` UnreadMentions int64 `json:"unread_mentions"` + IsUrgent bool `json:"is_urgent"` DeleteAt int64 `json:"delete_at"` } type Threads struct { - Total int64 `json:"total"` - TotalUnreadThreads int64 `json:"total_unread_threads"` - TotalUnreadMentions int64 `json:"total_unread_mentions"` - Threads []*ThreadResponse `json:"threads"` + Total int64 `json:"total"` + TotalUnreadThreads int64 `json:"total_unread_threads"` + TotalUnreadMentions int64 `json:"total_unread_mentions"` + TotalUnreadUrgentMentions int64 `json:"total_unread_urgent_mentions"` + Threads []*ThreadResponse `json:"threads"` } type GetUserThreadsOpts struct { @@ -81,6 +83,9 @@ type GetUserThreadsOpts struct { // TeamOnly will only fetch threads and unreads for the specified team and excludes DMs/GMs TeamOnly bool + + // IncludeIsUrgent will return IsUrgent field as well to assert is the thread is urgent or not + IncludeIsUrgent bool } func (o *Thread) Etag() string { diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 513749fd4b..3ab5c4715d 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -37,6 +37,7 @@ type OpenTracingLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore ReactionStore store.ReactionStore @@ -131,6 +132,10 @@ func (s *OpenTracingLayer) Post() store.PostStore { return s.PostStore } +func (s *OpenTracingLayer) PostPriority() store.PostPriorityStore { + return s.PostPriorityStore +} + func (s *OpenTracingLayer) Preference() store.PreferenceStore { return s.PreferenceStore } @@ -301,6 +306,11 @@ type OpenTracingLayerPostStore struct { Root *OpenTracingLayer } +type OpenTracingLayerPostPriorityStore struct { + store.PostPriorityStore + Root *OpenTracingLayer +} + type OpenTracingLayerPreferenceStore struct { store.PreferenceStore Root *OpenTracingLayer @@ -702,6 +712,24 @@ func (s *OpenTracingLayerChannelStore) CountPostsAfter(channelID string, timesta return result, resultVar1, err } +func (s *OpenTracingLayerChannelStore) CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.CountUrgentPostsAfter") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelStore.CountUrgentPostsAfter(channelID, timestamp, userID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerChannelStore) CreateDirectChannel(userID *model.User, otherUserID *model.User, channelOptions ...model.ChannelOption) (*model.Channel, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.CreateDirectChannel") @@ -1867,7 +1895,7 @@ func (s *OpenTracingLayerChannelStore) GroupSyncedChannelCount() (int64, error) return result, err } -func (s *OpenTracingLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error { +func (s *OpenTracingLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool, isUrgent bool) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.IncrementMentionCount") s.Root.Store.SetContext(newCtx) @@ -1876,7 +1904,7 @@ func (s *OpenTracingLayerChannelStore) IncrementMentionCount(channelID string, u }() defer span.Finish() - err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot) + err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot, isUrgent) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -2439,7 +2467,7 @@ func (s *OpenTracingLayerChannelStore) UpdateLastViewedAt(channelIds []string, u return result, err } -func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { +func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.UpdateLastViewedAtPost") s.Root.Store.SetContext(newCtx) @@ -2448,7 +2476,7 @@ func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model. }() defer span.Finish() - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -6516,6 +6544,42 @@ func (s *OpenTracingLayerPostStore) Update(newPost *model.Post, oldPost *model.P return result, err } +func (s *OpenTracingLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPriorityStore.GetForPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostPriorityStore.GetForPost(postId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostPriorityStore) GetForPosts(ids []string) ([]*model.PostPriority, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPriorityStore.GetForPosts") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostPriorityStore.GetForPosts(ids) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerPreferenceStore) CleanupFlagsBatch(limit int64) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PreferenceStore.CleanupFlagsBatch") @@ -9872,7 +9936,7 @@ func (s *OpenTracingLayerThreadStore) GetMembershipsForUser(userId string, teamI return result, err } -func (s *OpenTracingLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { +func (s *OpenTracingLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.GetTeamsUnreadForUser") s.Root.Store.SetContext(newCtx) @@ -9881,7 +9945,7 @@ func (s *OpenTracingLayerThreadStore) GetTeamsUnreadForUser(userID string, teamI }() defer span.Finish() - result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs) + result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs, includeUrgentMentionCount) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -9908,7 +9972,7 @@ func (s *OpenTracingLayerThreadStore) GetThreadFollowers(threadID string, fetchO return result, err } -func (s *OpenTracingLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { +func (s *OpenTracingLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool, postPriorityIsEnabled bool) (*model.ThreadResponse, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.GetThreadForUser") s.Root.Store.SetContext(newCtx) @@ -9917,7 +9981,7 @@ func (s *OpenTracingLayerThreadStore) GetThreadForUser(threadMembership *model.T }() defer span.Finish() - result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended) + result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended, postPriorityIsEnabled) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -10052,6 +10116,24 @@ func (s *OpenTracingLayerThreadStore) GetTotalUnreadThreads(userId string, teamI return result, err } +func (s *OpenTracingLayerThreadStore) GetTotalUnreadUrgentMentions(userId string, teamID string, opts model.GetUserThreadsOpts) (int64, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.GetTotalUnreadUrgentMentions") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ThreadStore.GetTotalUnreadUrgentMentions(userId, teamID, opts) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerThreadStore) MaintainMembership(userID string, postID string, opts store.ThreadMembershipOpts) (*model.ThreadMembership, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.MaintainMembership") @@ -12509,6 +12591,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.OAuthStore = &OpenTracingLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &OpenTracingLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &OpenTracingLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostPriorityStore = &OpenTracingLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &OpenTracingLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &OpenTracingLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} newStore.ReactionStore = &OpenTracingLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index fdf6b2c9e9..8dc782fce7 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -40,6 +40,7 @@ type RetryLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore ReactionStore store.ReactionStore @@ -134,6 +135,10 @@ func (s *RetryLayer) Post() store.PostStore { return s.PostStore } +func (s *RetryLayer) PostPriority() store.PostPriorityStore { + return s.PostPriorityStore +} + func (s *RetryLayer) Preference() store.PreferenceStore { return s.PreferenceStore } @@ -304,6 +309,11 @@ type RetryLayerPostStore struct { Root *RetryLayer } +type RetryLayerPostPriorityStore struct { + store.PostPriorityStore + Root *RetryLayer +} + type RetryLayerPreferenceStore struct { store.PreferenceStore Root *RetryLayer @@ -762,6 +772,27 @@ func (s *RetryLayerChannelStore) CountPostsAfter(channelID string, timestamp int } +func (s *RetryLayerChannelStore) CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) { + + tries := 0 + for { + result, err := s.ChannelStore.CountUrgentPostsAfter(channelID, timestamp, userID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelStore) CreateDirectChannel(userID *model.User, otherUserID *model.User, channelOptions ...model.ChannelOption) (*model.Channel, error) { tries := 0 @@ -2112,11 +2143,11 @@ func (s *RetryLayerChannelStore) GroupSyncedChannelCount() (int64, error) { } -func (s *RetryLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error { +func (s *RetryLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool, isUrgent bool) error { tries := 0 for { - err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot) + err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot, isUrgent) if err == nil { return nil } @@ -2706,11 +2737,11 @@ func (s *RetryLayerChannelStore) UpdateLastViewedAt(channelIds []string, userID } -func (s *RetryLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { +func (s *RetryLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { tries := 0 for { - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) if err == nil { return result, nil } @@ -7389,6 +7420,48 @@ func (s *RetryLayerPostStore) Update(newPost *model.Post, oldPost *model.Post) ( } +func (s *RetryLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + + tries := 0 + for { + result, err := s.PostPriorityStore.GetForPost(postId) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPriorityStore) GetForPosts(ids []string) ([]*model.PostPriority, error) { + + tries := 0 + for { + result, err := s.PostPriorityStore.GetForPosts(ids) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerPreferenceStore) CleanupFlagsBatch(limit int64) (int64, error) { tries := 0 @@ -11286,11 +11359,11 @@ func (s *RetryLayerThreadStore) GetMembershipsForUser(userId string, teamID stri } -func (s *RetryLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { +func (s *RetryLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { tries := 0 for { - result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs) + result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs, includeUrgentMentionCount) if err == nil { return result, nil } @@ -11328,11 +11401,11 @@ func (s *RetryLayerThreadStore) GetThreadFollowers(threadID string, fetchOnlyAct } -func (s *RetryLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { +func (s *RetryLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool, postPriorityIsEnabled bool) (*model.ThreadResponse, error) { tries := 0 for { - result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended) + result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended, postPriorityIsEnabled) if err == nil { return result, nil } @@ -11496,6 +11569,27 @@ func (s *RetryLayerThreadStore) GetTotalUnreadThreads(userId string, teamID stri } +func (s *RetryLayerThreadStore) GetTotalUnreadUrgentMentions(userId string, teamID string, opts model.GetUserThreadsOpts) (int64, error) { + + tries := 0 + for { + result, err := s.ThreadStore.GetTotalUnreadUrgentMentions(userId, teamID, opts) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerThreadStore) MaintainMembership(userID string, postID string, opts store.ThreadMembershipOpts) (*model.ThreadMembership, error) { tries := 0 @@ -14261,6 +14355,7 @@ func New(childStore store.Store) *RetryLayer { newStore.OAuthStore = &RetryLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &RetryLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &RetryLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostPriorityStore = &RetryLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &RetryLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &RetryLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} newStore.ReactionStore = &RetryLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} diff --git a/store/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index f71ce9a55d..b45bc7c561 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -54,6 +54,7 @@ func genStore() *mocks.Store { mock.On("UserTermsOfService").Return(&mocks.UserTermsOfServiceStore{}) mock.On("Webhook").Return(&mocks.WebhookStore{}) mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{}) + mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) return mock } diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index 69b6835766..ab1d6c0ebb 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -41,36 +41,38 @@ type SqlChannelStore struct { } type channelMember struct { - ChannelId string - UserId string - Roles string - LastViewedAt int64 - MsgCount int64 - MentionCount int64 - NotifyProps model.StringMap - LastUpdateAt int64 - SchemeUser sql.NullBool - SchemeAdmin sql.NullBool - SchemeGuest sql.NullBool - MentionCountRoot int64 - MsgCountRoot int64 + ChannelId string + UserId string + Roles string + LastViewedAt int64 + MsgCount int64 + MentionCount int64 + UrgentMentionCount int64 + NotifyProps model.StringMap + LastUpdateAt int64 + SchemeUser sql.NullBool + SchemeAdmin sql.NullBool + SchemeGuest sql.NullBool + MentionCountRoot int64 + MsgCountRoot int64 } func NewMapFromChannelMemberModel(cm *model.ChannelMember) map[string]any { return map[string]any{ - "ChannelId": cm.ChannelId, - "UserId": cm.UserId, - "Roles": cm.ExplicitRoles, - "LastViewedAt": cm.LastViewedAt, - "MsgCount": cm.MsgCount, - "MentionCount": cm.MentionCount, - "MentionCountRoot": cm.MentionCountRoot, - "MsgCountRoot": cm.MsgCountRoot, - "NotifyProps": cm.NotifyProps, - "LastUpdateAt": cm.LastUpdateAt, - "SchemeGuest": sql.NullBool{Valid: true, Bool: cm.SchemeGuest}, - "SchemeUser": sql.NullBool{Valid: true, Bool: cm.SchemeUser}, - "SchemeAdmin": sql.NullBool{Valid: true, Bool: cm.SchemeAdmin}, + "ChannelId": cm.ChannelId, + "UserId": cm.UserId, + "Roles": cm.ExplicitRoles, + "LastViewedAt": cm.LastViewedAt, + "MsgCount": cm.MsgCount, + "MentionCount": cm.MentionCount, + "MentionCountRoot": cm.MentionCountRoot, + "UrgentMentionCount": cm.UrgentMentionCount, + "MsgCountRoot": cm.MsgCountRoot, + "NotifyProps": cm.NotifyProps, + "LastUpdateAt": cm.LastUpdateAt, + "SchemeGuest": sql.NullBool{Valid: true, Bool: cm.SchemeGuest}, + "SchemeUser": sql.NullBool{Valid: true, Bool: cm.SchemeUser}, + "SchemeAdmin": sql.NullBool{Valid: true, Bool: cm.SchemeAdmin}, } } @@ -82,6 +84,7 @@ type channelMemberWithSchemeRoles struct { MsgCount int64 MentionCount int64 MentionCountRoot int64 + UrgentMentionCount int64 NotifyProps model.StringMap LastUpdateAt int64 SchemeGuest sql.NullBool @@ -106,7 +109,7 @@ type channelMemberWithTeamWithSchemeRoles struct { type channelMemberWithTeamWithSchemeRolesList []channelMemberWithTeamWithSchemeRoles func channelMemberSliceColumns() []string { - return []string{"ChannelId", "UserId", "Roles", "LastViewedAt", "MsgCount", "MsgCountRoot", "MentionCount", "MentionCountRoot", "NotifyProps", "LastUpdateAt", "SchemeUser", "SchemeAdmin", "SchemeGuest"} + return []string{"ChannelId", "UserId", "Roles", "LastViewedAt", "MsgCount", "MsgCountRoot", "MentionCount", "MentionCountRoot", "UrgentMentionCount", "NotifyProps", "LastUpdateAt", "SchemeUser", "SchemeAdmin", "SchemeGuest"} } func channelMemberToSlice(member *model.ChannelMember) []any { @@ -119,6 +122,7 @@ func channelMemberToSlice(member *model.ChannelMember) []any { resultSlice = append(resultSlice, member.MsgCountRoot) resultSlice = append(resultSlice, member.MentionCount) resultSlice = append(resultSlice, member.MentionCountRoot) + resultSlice = append(resultSlice, member.UrgentMentionCount) resultSlice = append(resultSlice, model.MapToJSON(member.NotifyProps)) resultSlice = append(resultSlice, member.LastUpdateAt) resultSlice = append(resultSlice, member.SchemeUser) @@ -244,20 +248,21 @@ func (db channelMemberWithSchemeRoles) ToModel() *model.ChannelMember { strings.Fields(db.Roles), ) return &model.ChannelMember{ - ChannelId: db.ChannelId, - UserId: db.UserId, - Roles: strings.Join(rolesResult.roles, " "), - LastViewedAt: db.LastViewedAt, - MsgCount: db.MsgCount, - MsgCountRoot: db.MsgCountRoot, - MentionCount: db.MentionCount, - MentionCountRoot: db.MentionCountRoot, - NotifyProps: db.NotifyProps, - LastUpdateAt: db.LastUpdateAt, - SchemeAdmin: rolesResult.schemeAdmin, - SchemeUser: rolesResult.schemeUser, - SchemeGuest: rolesResult.schemeGuest, - ExplicitRoles: strings.Join(rolesResult.explicitRoles, " "), + ChannelId: db.ChannelId, + UserId: db.UserId, + Roles: strings.Join(rolesResult.roles, " "), + LastViewedAt: db.LastViewedAt, + MsgCount: db.MsgCount, + MsgCountRoot: db.MsgCountRoot, + MentionCount: db.MentionCount, + MentionCountRoot: db.MentionCountRoot, + UrgentMentionCount: db.UrgentMentionCount, + NotifyProps: db.NotifyProps, + LastUpdateAt: db.LastUpdateAt, + SchemeAdmin: rolesResult.schemeAdmin, + SchemeUser: rolesResult.schemeUser, + SchemeGuest: rolesResult.schemeGuest, + ExplicitRoles: strings.Join(rolesResult.explicitRoles, " "), } } @@ -307,20 +312,21 @@ func (db channelMemberWithTeamWithSchemeRoles) ToModel() *model.ChannelMemberWit ) return &model.ChannelMemberWithTeamData{ ChannelMember: model.ChannelMember{ - ChannelId: db.ChannelId, - UserId: db.UserId, - Roles: strings.Join(rolesResult.roles, " "), - LastViewedAt: db.LastViewedAt, - MsgCount: db.MsgCount, - MsgCountRoot: db.MsgCountRoot, - MentionCount: db.MentionCount, - MentionCountRoot: db.MentionCountRoot, - NotifyProps: db.NotifyProps, - LastUpdateAt: db.LastUpdateAt, - SchemeAdmin: rolesResult.schemeAdmin, - SchemeUser: rolesResult.schemeUser, - SchemeGuest: rolesResult.schemeGuest, - ExplicitRoles: strings.Join(rolesResult.explicitRoles, " "), + ChannelId: db.ChannelId, + UserId: db.UserId, + Roles: strings.Join(rolesResult.roles, " "), + LastViewedAt: db.LastViewedAt, + MsgCount: db.MsgCount, + MsgCountRoot: db.MsgCountRoot, + MentionCount: db.MentionCount, + MentionCountRoot: db.MentionCountRoot, + UrgentMentionCount: db.UrgentMentionCount, + NotifyProps: db.NotifyProps, + LastUpdateAt: db.LastUpdateAt, + SchemeAdmin: rolesResult.schemeAdmin, + SchemeUser: rolesResult.schemeUser, + SchemeGuest: rolesResult.schemeGuest, + ExplicitRoles: strings.Join(rolesResult.explicitRoles, " "), }, TeamName: db.TeamName, TeamDisplayName: db.TeamDisplayName, @@ -471,7 +477,20 @@ func newSqlChannelStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface func (s *SqlChannelStore) initializeQueries() { s.channelMembersForTeamWithSchemeSelectQuery = s.getQueryBuilder(). Select( - "ChannelMembers.*", + "ChannelMembers.ChannelId", + "ChannelMembers.UserId", + "ChannelMembers.Roles", + "ChannelMembers.LastViewedAt", + "ChannelMembers.MsgCount", + "ChannelMembers.MentionCount", + "ChannelMembers.MentionCountRoot", + "COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount", + "ChannelMembers.MsgCountRoot", + "ChannelMembers.NotifyProps", + "ChannelMembers.LastUpdateAt", + "ChannelMembers.SchemeUser", + "ChannelMembers.SchemeAdmin", + "ChannelMembers.SchemeGuest", "TeamScheme.DefaultChannelGuestRole TeamSchemeDefaultGuestRole", "TeamScheme.DefaultChannelUserRole TeamSchemeDefaultUserRole", "TeamScheme.DefaultChannelAdminRole TeamSchemeDefaultAdminRole", @@ -779,7 +798,7 @@ func (s SqlChannelStore) GetChannelUnread(channelId, userId string) (*model.Chan var unreadChannel model.ChannelUnread err := s.GetReplicaX().Get(&unreadChannel, `SELECT - Channels.TeamId TeamId, Channels.Id ChannelId, (Channels.TotalMsgCount - ChannelMembers.MsgCount) MsgCount, (Channels.TotalMsgCountRoot - ChannelMembers.MsgCountRoot) MsgCountRoot, ChannelMembers.MentionCount MentionCount, ChannelMembers.MentionCountRoot MentionCountRoot, ChannelMembers.NotifyProps NotifyProps + Channels.TeamId TeamId, Channels.Id ChannelId, (Channels.TotalMsgCount - ChannelMembers.MsgCount) MsgCount, (Channels.TotalMsgCountRoot - ChannelMembers.MsgCountRoot) MsgCountRoot, ChannelMembers.MentionCount MentionCount, ChannelMembers.MentionCountRoot MentionCountRoot, COALESCE(ChannelMembers.UrgentMentionCount, 0) UrgentMentionCount, ChannelMembers.NotifyProps NotifyProps FROM Channels, ChannelMembers WHERE @@ -1612,7 +1631,20 @@ func (s SqlChannelStore) GetDeleted(teamId string, offset int, limit int, userId var channelMembersWithSchemeSelectQuery = ` SELECT - ChannelMembers.*, + ChannelMembers.ChannelId, + ChannelMembers.UserId, + ChannelMembers.Roles, + ChannelMembers.LastViewedAt, + ChannelMembers.MsgCount, + ChannelMembers.MentionCount, + ChannelMembers.MentionCountRoot, + COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount, + ChannelMembers.MsgCountRoot, + ChannelMembers.NotifyProps, + ChannelMembers.LastUpdateAt, + ChannelMembers.SchemeUser, + ChannelMembers.SchemeAdmin, + ChannelMembers.SchemeGuest, COALESCE(Teams.DisplayName, '') TeamDisplayName, COALESCE(Teams.Name, '') TeamName, COALESCE(Teams.UpdateAt, 0) TeamUpdateAt, @@ -2048,7 +2080,20 @@ func (s SqlChannelStore) GetMemberForPost(postId string, userId string) (*model. var dbMember channelMemberWithSchemeRoles query := ` SELECT - ChannelMembers.*, + ChannelMembers.ChannelId, + ChannelMembers.UserId, + ChannelMembers.Roles, + ChannelMembers.LastViewedAt, + ChannelMembers.MsgCount, + ChannelMembers.MentionCount, + ChannelMembers.MentionCountRoot, + COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount, + ChannelMembers.MsgCountRoot, + ChannelMembers.NotifyProps, + ChannelMembers.LastUpdateAt, + ChannelMembers.SchemeUser, + ChannelMembers.SchemeAdmin, + ChannelMembers.SchemeGuest, TeamScheme.DefaultChannelGuestRole TeamSchemeDefaultGuestRole, TeamScheme.DefaultChannelUserRole TeamSchemeDefaultUserRole, TeamScheme.DefaultChannelAdminRole TeamSchemeDefaultAdminRole, @@ -2438,6 +2483,7 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) Update("ChannelMembers cm"). Set("MentionCount", 0). Set("MentionCountRoot", 0). + Set("UrgentMentionCount", 0). Set("MsgCount", sq.Expr("greatest(cm.MsgCount, c.TotalMsgCount)")). Set("MsgCountRoot", sq.Expr("greatest(cm.MsgCountRoot, c.TotalMsgCountRoot)")). Set("LastViewedAt", sq.Expr("greatest(cm.LastViewedAt, c.LastPostAt)")). @@ -2497,6 +2543,7 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) updateQuery := s.getQueryBuilder().Update("ChannelMembers"). Set("MentionCount", 0). Set("MentionCountRoot", 0). + Set("UrgentMentionCount", 0). Set("MsgCount", msgCountQuery). Set("MsgCountRoot", msgCountQueryRoot). Set("LastViewedAt", lastViewedQuery). @@ -2518,6 +2565,31 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) return times, nil } +func (s SqlChannelStore) CountUrgentPostsAfter(channelId string, timestamp int64, userId string) (int, error) { + query := s.getQueryBuilder(). + Select("count(*)"). + From("PostsPriority"). + Join("Posts ON Posts.Id = PostsPriority.PostId"). + Where(sq.And{ + sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}, + sq.Eq{"Posts.ChannelId": channelId}, + sq.Gt{"Posts.CreateAt": timestamp}, + sq.Eq{"Posts.DeleteAt": 0}, + }) + + if userId != "" { + query = query.Where(sq.Eq{"Posts.UserId": userId}) + } + + var urgent int64 + err := s.GetReplicaX().GetBuilder(&urgent, query) + if err != nil { + return 0, errors.Wrap(err, "failed to count urgent Posts") + } + + return int(urgent), nil +} + // CountPostsAfter returns the number of posts in the given channel created after but not including the given timestamp. If given a non-empty user ID, only counts posts made by that user. func (s SqlChannelStore) CountPostsAfter(channelId string, timestamp int64, userId string) (int, int, error) { joinLeavePostTypes := []string{ @@ -2566,13 +2638,14 @@ func (s SqlChannelStore) CountPostsAfter(channelId string, timestamp int64, user if err != nil { return 0, 0, errors.Wrap(err, "failed to count root Posts") } + return int(unread), int(unreadRoot), nil } // UpdateLastViewedAtPost updates a ChannelMember as if the user last read the channel at the time of the given post. // If the provided mentionCount is -1, the given post and all posts after it are considered to be mentions. Returns // an updated model.ChannelUnreadAt that can be returned to the client. -func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { +func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { unreadDate := unreadPost.CreateAt - 1 unread, unreadRoot, err := s.CountPostsAfter(unreadPost.ChannelId, unreadDate, "") @@ -2587,6 +2660,7 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s params := map[string]any{ "mentions": mentionCount, "mentionsroot": mentionCountRoot, + "urgentmentions": urgentMentionCount, "unreadcount": unread, "unreadcountroot": unreadRoot, "lastviewedat": unreadDate, @@ -2603,6 +2677,7 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s SET MentionCount = :mentions, MentionCountRoot = :mentionsroot, + UrgentMentionCount = :urgentmentions, MsgCount = (SELECT TotalMsgCount FROM Channels WHERE ID = :channelid) - :unreadcount, MsgCountRoot = (SELECT TotalMsgCountRoot FROM Channels WHERE ID = :channelid) - :unreadcountroot, LastViewedAt = :lastviewedat, @@ -2625,6 +2700,7 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s cm.MsgCountRoot MsgCountRoot, cm.MentionCount MentionCount, cm.MentionCountRoot MentionCountRoot, + COALESCE(cm.UrgentMentionCount, 0) UrgentMentionCount, cm.LastViewedAt LastViewedAt, cm.NotifyProps NotifyProps FROM @@ -2643,7 +2719,7 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s return result, nil } -func (s SqlChannelStore) IncrementMentionCount(channelId string, userIDs []string, isRoot bool) error { +func (s SqlChannelStore) IncrementMentionCount(channelId string, userIDs []string, isRoot bool, isUrgent bool) error { now := model.GetMillis() rootInc := 0 @@ -2651,10 +2727,16 @@ func (s SqlChannelStore) IncrementMentionCount(channelId string, userIDs []strin rootInc = 1 } + urgentInc := 0 + if isUrgent { + urgentInc = 1 + } + sql, args, err := s.getQueryBuilder(). Update("ChannelMembers"). Set("MentionCount", sq.Expr("MentionCount + 1")). Set("MentionCountRoot", sq.Expr("MentionCountRoot + ?", rootInc)). + Set("UrgentMentionCount", sq.Expr("UrgentMentionCount + ?", urgentInc)). Set("LastUpdateAt", now). Where(sq.Eq{ "UserId": userIDs, @@ -2832,7 +2914,21 @@ func (s SqlChannelStore) GetMembersForUser(teamID string, userID string) (model. func (s SqlChannelStore) GetMembersForUserWithCursor(userID, teamID string, opts *store.ChannelMemberGraphQLSearchOpts) (model.ChannelMembers, error) { query := s.getQueryBuilder(). - Select("ChannelMembers.*", + Select( + "ChannelMembers.ChannelId", + "ChannelMembers.UserId", + "ChannelMembers.Roles", + "ChannelMembers.LastViewedAt", + "ChannelMembers.MsgCount", + "ChannelMembers.MentionCount", + "ChannelMembers.MentionCountRoot", + "COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount", + "ChannelMembers.MsgCountRoot", + "ChannelMembers.NotifyProps", + "ChannelMembers.LastUpdateAt", + "ChannelMembers.SchemeUser", + "ChannelMembers.SchemeAdmin", + "ChannelMembers.SchemeGuest", "TeamScheme.DefaultChannelGuestRole TeamSchemeDefaultGuestRole", "TeamScheme.DefaultChannelUserRole TeamSchemeDefaultUserRole", "TeamScheme.DefaultChannelAdminRole TeamSchemeDefaultAdminRole", @@ -3790,6 +3886,7 @@ func (s SqlChannelStore) MigrateChannelMembers(fromChannelId string, fromUserId LastViewedAt=:LastViewedAt, MsgCount=:MsgCount, MentionCount=:MentionCount, + UrgentMentionCount=:UrgentMentionCount, NotifyProps=:NotifyProps, LastUpdateAt=:LastUpdateAt, SchemeUser=:SchemeUser, @@ -3932,6 +4029,7 @@ func (s SqlChannelStore) GetChannelMembersForExport(userId string, teamId string ChannelMembers.MsgCount, ChannelMembers.MentionCount, ChannelMembers.MentionCountRoot, + COALESCE(ChannelMembers.UrgentMentionCount, 0) AS UrgentMentionCount, ChannelMembers.NotifyProps, ChannelMembers.LastUpdateAt, ChannelMembers.SchemeUser, @@ -3981,7 +4079,7 @@ func (s SqlChannelStore) GetAllDirectChannelsForExportAfter(limit int, afterId s channelIds = append(channelIds, channel.Id) } query = s.getQueryBuilder(). - Select("u.Username as Username, ChannelId, UserId, cm.Roles as Roles, LastViewedAt, MsgCount, MentionCount, MentionCountRoot, cm.NotifyProps as NotifyProps, LastUpdateAt, SchemeUser, SchemeAdmin, (SchemeGuest IS NOT NULL AND SchemeGuest) as SchemeGuest"). + Select("u.Username as Username, ChannelId, UserId, cm.Roles as Roles, LastViewedAt, MsgCount, MentionCount, MentionCountRoot, COALESCE(UrgentMentionCount, 0) UrgentMentionCount, cm.NotifyProps as NotifyProps, LastUpdateAt, SchemeUser, SchemeAdmin, (SchemeGuest IS NOT NULL AND SchemeGuest) as SchemeGuest"). From("ChannelMembers cm"). Join("Users u ON ( u.Id = cm.UserId )"). Where(sq.And{ diff --git a/store/sqlstore/post_priority_store.go b/store/sqlstore/post_priority_store.go new file mode 100644 index 0000000000..03b302e3c5 --- /dev/null +++ b/store/sqlstore/post_priority_store.go @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + sq "github.com/mattermost/squirrel" +) + +type SqlPostPriorityStore struct { + *SqlStore +} + +func newSqlPostPriorityStore(sqlStore *SqlStore) store.PostPriorityStore { + return &SqlPostPriorityStore{ + SqlStore: sqlStore, + } +} + +func (s *SqlPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + query := s.getQueryBuilder(). + Select("Priority", "RequestedAck", "PersistentNotifications"). + From("PostsPriority"). + Where(sq.Eq{"PostId": postId}) + + var postPriority model.PostPriority + err := s.GetReplicaX().GetBuilder(&postPriority, query) + if err != nil { + return nil, err + } + + return &postPriority, nil +} + +func (s *SqlPostPriorityStore) GetForPosts(postIds []string) ([]*model.PostPriority, error) { + var priority []*model.PostPriority + + perPage := 200 + for i := 0; i < len(postIds); i += perPage { + j := i + perPage + if len(postIds) < j { + j = len(postIds) + } + + query := s.getQueryBuilder(). + Select("PostId", "Priority", "RequestedAck", "PersistentNotifications"). + From("PostsPriority"). + Where(sq.Eq{"PostId": postIds[i:j]}) + + var priorityBatch []*model.PostPriority + err := s.GetReplicaX().SelectBuilder(&priority, query) + + if err != nil { + return nil, err + } + + priority = append(priority, priorityBatch...) + } + + return priority, nil +} diff --git a/store/sqlstore/post_priority_store_test.go b/store/sqlstore/post_priority_store_test.go new file mode 100644 index 0000000000..3ab4627476 --- /dev/null +++ b/store/sqlstore/post_priority_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/store/storetest" +) + +func TestPostPriorityStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestPostPriorityStore) +} diff --git a/store/sqlstore/post_store.go b/store/sqlstore/post_store.go index 5afdddac63..b7f5979cd9 100644 --- a/store/sqlstore/post_store.go +++ b/store/sqlstore/post_store.go @@ -219,6 +219,10 @@ func (s *SqlPostStore) SaveMultiple(posts []*model.Post) ([]*model.Post, int, er return nil, -1, errors.Wrap(err, "update thread from posts failed") } + if err = s.savePostsPriority(transaction, posts); err != nil { + return nil, -1, errors.Wrap(err, "failed to save PostPriority") + } + if err = transaction.Commit(); err != nil { // don't need to rollback here since the transaction is already closed return posts, -1, errors.Wrap(err, "commit_transaction") @@ -2920,6 +2924,24 @@ func (s *SqlPostStore) updateThreadAfterReplyDeletion(transaction *sqlxTxWrapper return nil } +func (s *SqlPostStore) savePostsPriority(transaction *sqlxTxWrapper, posts []*model.Post) error { + for _, post := range posts { + if post.GetPriority() != nil { + postPriority := &model.PostPriority{ + PostId: post.Id, + ChannelId: post.ChannelId, + Priority: post.Metadata.Priority.Priority, + RequestedAck: post.Metadata.Priority.RequestedAck, + PersistentNotifications: post.Metadata.Priority.PersistentNotifications, + } + if _, err := transaction.NamedExec(`INSERT INTO PostsPriority (PostId, ChannelId, Priority, RequestedAck, PersistentNotifications) VALUES (:PostId, :ChannelId, :Priority, :RequestedAck, :PersistentNotifications)`, postPriority); err != nil { + return err + } + } + } + return nil +} + func (s *SqlPostStore) updateThreadsFromPosts(transaction *sqlxTxWrapper, posts []*model.Post) error { postsByRoot := map[string][]*model.Post{} var rootIds []string diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 2730ece664..0c5aad077f 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -109,6 +109,7 @@ type SqlStoreStores struct { linkMetadata store.LinkMetadataStore sharedchannel store.SharedChannelStore notifyAdmin store.NotifyAdminStore + postPriority store.PostPriorityStore } type SqlStore struct { @@ -214,6 +215,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.group = newSqlGroupStore(store) store.stores.productNotices = newSqlProductNoticesStore(store) store.stores.notifyAdmin = newSqlNotifyAdminStore(store) + store.stores.postPriority = newSqlPostPriorityStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -955,6 +957,10 @@ func (ss *SqlStore) SharedChannel() store.SharedChannelStore { return ss.stores.sharedchannel } +func (ss *SqlStore) PostPriority() store.PostPriorityStore { + return ss.stores.postPriority +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/store/sqlstore/thread_store.go b/store/sqlstore/thread_store.go index e314e82093..0de9c5db45 100644 --- a/store/sqlstore/thread_store.go +++ b/store/sqlstore/thread_store.go @@ -7,11 +7,11 @@ import ( "context" "database/sql" "strconv" - "sync" "time" sq "github.com/mattermost/squirrel" "github.com/pkg/errors" + "golang.org/x/sync/errgroup" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/store" @@ -30,6 +30,7 @@ type JoinedThread struct { Participants model.StringArray ThreadDeleteAt int64 TeamId string + IsUrgent bool model.Post } @@ -51,6 +52,7 @@ func (thread *JoinedThread) toThreadResponse(users map[string]*model.User) *mode Participants: threadParticipants, Post: thread.Post.ToNilIfInvalid(), DeleteAt: thread.ThreadDeleteAt, + IsUrgent: thread.IsUrgent, } } @@ -213,6 +215,46 @@ func (s *SqlThreadStore) GetTotalUnreadMentions(userId, teamId string, opts mode return totalUnreadMentions, nil } +// GetTotalUnreadUrgentMentions counts the number of unread mentions for the given user, optionally +// constrained to the given team + DMs/GMs. +func (s *SqlThreadStore) GetTotalUnreadUrgentMentions(userId, teamId string, opts model.GetUserThreadsOpts) (int64, error) { + var totalUnreadUrgentMentions int64 + + query := s.getQueryBuilder(). + Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0)"). + From("ThreadMemberships"). + Join("PostsPriority ON PostsPriority.PostId = ThreadMemberships.PostId"). + Where(sq.Eq{ + "ThreadMemberships.UserId": userId, + "ThreadMemberships.Following": true, + "PostsPriority.Priority": model.PostPriorityUrgent, + }) + + if teamId != "" || !opts.Deleted { + query = query.Join("Threads ON Threads.PostId = ThreadMemberships.PostId") + } + + if teamId != "" { + query = query. + Where(sq.Or{ + sq.Eq{"Threads.ThreadTeamId": teamId}, + sq.Eq{"Threads.ThreadTeamId": ""}, + }) + } + + if !opts.Deleted { + query = query. + Where(sq.Eq{"COALESCE(Threads.ThreadDeleteAt, 0)": 0}) + } + + err := s.GetReplicaX().GetBuilder(&totalUnreadUrgentMentions, query) + if err != nil { + return 0, errors.Wrapf(err, "failed to count unread urgent mentions for user id=%s", userId) + } + + return totalUnreadUrgentMentions, nil +} + func (s *SqlThreadStore) GetThreadsForUser(userId, teamId string, opts model.GetUserThreadsOpts) ([]*model.ThreadResponse, error) { pageSize := uint64(30) if opts.PageSize != 0 { @@ -243,6 +285,17 @@ func (s *SqlThreadStore) GetThreadsForUser(userId, teamId string, opts model.Get Where(sq.Eq{"ThreadMemberships.UserId": userId}). Where(sq.Eq{"ThreadMemberships.Following": true}) + if opts.IncludeIsUrgent { + urgencyCase := sq. + Case(). + When(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}, "true"). + Else("false") + + query = query. + Column(sq.Alias(urgencyCase, "IsUrgent")). + LeftJoin("PostsPriority ON PostsPriority.PostId = Threads.PostId") + } + // If a team is specified, constrain to channels in that team or DMs/GMs without // a team at all. if teamId != "" { @@ -322,7 +375,7 @@ func (s *SqlThreadStore) GetThreadsForUser(userId, teamId string, opts model.Get // GetTeamsUnreadForUser returns the total unread threads and unread mentions // for a user from all teams. -func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { +func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { fetchConditions := sq.And{ sq.Eq{"ThreadMemberships.UserId": userID}, sq.Eq{"ThreadMemberships.Following": true}, @@ -330,8 +383,7 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) sq.Eq{"COALESCE(Threads.ThreadDeleteAt, 0)": 0}, } - var wg sync.WaitGroup - var err1, err2 error + var eg errgroup.Group unreadThreads := []struct { Count int64 @@ -341,13 +393,15 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) Count int64 TeamId string }{} + unreadUrgentMentions := []struct { + Count int64 + TeamId string + }{} // Running these concurrently hasn't shown any major downside // than running them serially. So using a bit of perf boost. // In any case, they will be replaced by computed columns later. - wg.Add(1) - go func() { - defer wg.Done() + eg.Go(func() error { repliesQuery := s.getQueryBuilder(). Select("COUNT(Threads.PostId) AS Count, ThreadTeamId AS TeamId"). From("Threads"). @@ -356,15 +410,10 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) Where("Threads.LastReplyAt > ThreadMemberships.LastViewed"). GroupBy("Threads.ThreadTeamId") - err := s.GetReplicaX().SelectBuilder(&unreadThreads, repliesQuery) - if err != nil { - err1 = errors.Wrap(err, "failed to get total unread threads") - } - }() + return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadThreads, repliesQuery), "failed to get total unread threads") + }) - wg.Add(1) - go func() { - defer wg.Done() + eg.Go(func() error { mentionsQuery := s.getQueryBuilder(). Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0) AS Count, ThreadTeamId AS TeamId"). From("ThreadMemberships"). @@ -372,20 +421,27 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) Where(fetchConditions). GroupBy("Threads.ThreadTeamId") - err := s.GetReplicaX().SelectBuilder(&unreadMentions, mentionsQuery) - if err != nil { - err2 = errors.Wrap(err, "failed to get total unread mentions") - } - }() + return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadMentions, mentionsQuery), "failed to get total unread mentions") + }) + + if includeUrgentMentionCount { + eg.Go(func() error { + urgentMentionsQuery := s.getQueryBuilder(). + Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0) AS Count, ThreadTeamId AS TeamId"). + From("ThreadMemberships"). + LeftJoin("Threads ON Threads.PostId = ThreadMemberships.PostId"). + Join("PostsPriority ON PostsPriority.PostId = ThreadMemberships.PostId"). + Where(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}). + Where(fetchConditions). + GroupBy("Threads.ThreadTeamId") + + return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadUrgentMentions, urgentMentionsQuery), "failed to get total unread urgent mentions") + }) + } // Wait for them to be over - wg.Wait() - - if err1 != nil { - return nil, err1 - } - if err2 != nil { - return nil, err2 + if err := eg.Wait(); err != nil { + return nil, err } res := make(map[string]*model.TeamUnread) @@ -405,6 +461,15 @@ func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) } } } + for _, item := range unreadUrgentMentions { + if _, ok := res[item.TeamId]; ok { + res[item.TeamId].ThreadUrgentMentionCount = item.Count + } else { + res[item.TeamId] = &model.TeamUnread{ + ThreadUrgentMentionCount: item.Count, + } + } + } return res, nil } @@ -436,7 +501,7 @@ func (s *SqlThreadStore) GetThreadFollowers(threadID string, fetchOnlyActive boo return users, nil } -func (s *SqlThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { +func (s *SqlThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended, postPriorityEnabled bool) (*model.ThreadResponse, error) { if !threadMembership.Following { return nil, nil // in case the thread is not followed anymore - return nil error to be interpreted as 404 } @@ -462,6 +527,17 @@ func (s *SqlThreadStore) GetThreadForUser(threadMembership *model.ThreadMembersh LeftJoin("Posts ON Posts.Id = Threads.PostId"). Where(sq.Eq{"Threads.PostId": threadMembership.PostId}) + if postPriorityEnabled { + urgencyCase := sq. + Case(). + When(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}, "true"). + Else("false") + + query = query. + Column(sq.Alias(urgencyCase, "IsUrgent")). + LeftJoin("PostsPriority ON PostsPriority.PostId = Threads.PostId") + } + err := s.GetReplicaX().GetBuilder(&thread, query) if err != nil { if err == sql.ErrNoRows { diff --git a/store/store.go b/store/store.go index 055b0dfeea..c99b1fa7c2 100644 --- a/store/store.go +++ b/store/store.go @@ -84,6 +84,7 @@ type Store interface { SetContext(context context.Context) Context() context.Context NotifyAdmin() NotifyAdminStore + PostPriority() PostPriorityStore } type RetentionPolicyStore interface { @@ -240,9 +241,10 @@ type ChannelStore interface { PermanentDeleteMembersByUser(userID string) error PermanentDeleteMembersByChannel(channelID string) error UpdateLastViewedAt(channelIds []string, userID string) (map[string]int64, error) - UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) + UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) CountPostsAfter(channelID string, timestamp int64, userID string) (int, int, error) - IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error + CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) + IncrementMentionCount(channelID string, userIDs []string, isRoot, isUrgent bool) error AnalyticsTypeCount(teamID string, channelType model.ChannelType) (int64, error) GetMembersForUser(teamID string, userID string) (model.ChannelMembers, error) GetTeamMembersForChannel(channelID string) ([]string, error) @@ -322,9 +324,10 @@ type ThreadStore interface { GetTotalUnreadThreads(userId, teamID string, opts model.GetUserThreadsOpts) (int64, error) GetTotalThreads(userId, teamID string, opts model.GetUserThreadsOpts) (int64, error) GetTotalUnreadMentions(userId, teamID string, opts model.GetUserThreadsOpts) (int64, error) + GetTotalUnreadUrgentMentions(userId, teamID string, opts model.GetUserThreadsOpts) (int64, error) GetThreadsForUser(userId, teamID string, opts model.GetUserThreadsOpts) ([]*model.ThreadResponse, error) - GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) - GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) + GetThreadForUser(threadMembership *model.ThreadMembership, extended, postPriorityIsEnabled bool) (*model.ThreadResponse, error) + GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) MarkAllAsRead(userID string, threadIds []string) error MarkAllAsReadByTeam(userID, teamID string) error @@ -970,6 +973,11 @@ type SharedChannelStore interface { UpdateAttachmentLastSyncAt(id string, syncTime int64) error } +type PostPriorityStore interface { + GetForPost(postId string) (*model.PostPriority, error) + GetForPosts(ids []string) ([]*model.PostPriority, error) +} + // ChannelSearchOpts contains options for searching channels. // // NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records. diff --git a/store/storetest/channel_store.go b/store/storetest/channel_store.go index c1843bc8be..28e66f1bef 100644 --- a/store/storetest/channel_store.go +++ b/store/storetest/channel_store.go @@ -104,6 +104,7 @@ func TestChannelStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("GetMembersForUserWithCursor", func(t *testing.T) { testChannelStoreGetMembersForUserWithCursor(t, ss) }) t.Run("GetMembersForUserWithPagination", func(t *testing.T) { testChannelStoreGetMembersForUserWithPagination(t, ss) }) t.Run("CountPostsAfter", func(t *testing.T) { testCountPostsAfter(t, ss) }) + t.Run("CountUrgentPostsAfter", func(t *testing.T) { testCountUrgentPostsAfter(t, ss) }) t.Run("UpdateLastViewedAt", func(t *testing.T) { testChannelStoreUpdateLastViewedAt(t, ss) }) t.Run("IncrementMentionCount", func(t *testing.T) { testChannelStoreIncrementMentionCount(t, ss) }) t.Run("UpdateChannelMember", func(t *testing.T) { testUpdateChannelMember(t, ss) }) @@ -4833,6 +4834,66 @@ func testCountPostsAfter(t *testing.T, ss store.Store) { }) } +func testCountUrgentPostsAfter(t *testing.T, ss store.Store) { + t.Run("should count all posts with or without the given user ID", func(t *testing.T) { + userId1 := model.NewId() + userId2 := model.NewId() + + channelId := model.NewId() + + p1, err := ss.Post().Save(&model.Post{ + UserId: userId1, + ChannelId: channelId, + CreateAt: 1000, + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + }, + }) + require.NoError(t, err) + + _, err = ss.Post().Save(&model.Post{ + UserId: userId1, + ChannelId: channelId, + CreateAt: 1001, + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + }, + }) + require.NoError(t, err) + + _, err = ss.Post().Save(&model.Post{ + UserId: userId2, + ChannelId: channelId, + CreateAt: 1002, + }) + require.NoError(t, err) + + count, err := ss.Channel().CountUrgentPostsAfter(channelId, p1.CreateAt-1, "") + require.NoError(t, err) + assert.Equal(t, 1, count) + + count, err = ss.Channel().CountUrgentPostsAfter(channelId, p1.CreateAt, "") + require.NoError(t, err) + assert.Equal(t, 0, count) + + count, err = ss.Channel().CountUrgentPostsAfter(channelId, p1.CreateAt-1, userId1) + require.NoError(t, err) + assert.Equal(t, 1, count) + + count, err = ss.Channel().CountUrgentPostsAfter(channelId, p1.CreateAt, userId1) + require.NoError(t, err) + assert.Equal(t, 0, count) + }) +} + func testChannelStoreUpdateLastViewedAt(t *testing.T, ss store.Store) { o1 := model.Channel{} o1.TeamId = model.NewId() @@ -4912,16 +4973,16 @@ func testChannelStoreIncrementMentionCount(t *testing.T, ss store.Store) { _, err := ss.Channel().SaveMember(&m1) require.NoError(t, err) - err = ss.Channel().IncrementMentionCount(m1.ChannelId, []string{m1.UserId}, false) + err = ss.Channel().IncrementMentionCount(m1.ChannelId, []string{m1.UserId}, false, false) require.NoError(t, err, "failed to update") - err = ss.Channel().IncrementMentionCount(m1.ChannelId, []string{"missing id"}, false) + err = ss.Channel().IncrementMentionCount(m1.ChannelId, []string{"missing id"}, false, false) require.NoError(t, err, "failed to update") - err = ss.Channel().IncrementMentionCount("missing id", []string{m1.UserId}, false) + err = ss.Channel().IncrementMentionCount("missing id", []string{m1.UserId}, false, false) require.NoError(t, err, "failed to update") - err = ss.Channel().IncrementMentionCount("missing id", []string{"missing id"}, false) + err = ss.Channel().IncrementMentionCount("missing id", []string{"missing id"}, false, false) require.NoError(t, err, "failed to update") } diff --git a/store/storetest/mocks/ChannelStore.go b/store/storetest/mocks/ChannelStore.go index 0b73494d83..5cfe28d6a0 100644 --- a/store/storetest/mocks/ChannelStore.go +++ b/store/storetest/mocks/ChannelStore.go @@ -192,6 +192,27 @@ func (_m *ChannelStore) CountPostsAfter(channelID string, timestamp int64, userI return r0, r1, r2 } +// CountUrgentPostsAfter provides a mock function with given fields: channelID, timestamp, userID +func (_m *ChannelStore) CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) { + ret := _m.Called(channelID, timestamp, userID) + + var r0 int + if rf, ok := ret.Get(0).(func(string, int64, string) int); ok { + r0 = rf(channelID, timestamp, userID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int64, string) error); ok { + r1 = rf(channelID, timestamp, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateDirectChannel provides a mock function with given fields: userID, otherUserID, channelOptions func (_m *ChannelStore) CreateDirectChannel(userID *model.User, otherUserID *model.User, channelOptions ...model.ChannelOption) (*model.Channel, error) { _va := make([]interface{}, len(channelOptions)) @@ -1646,13 +1667,13 @@ func (_m *ChannelStore) GroupSyncedChannelCount() (int64, error) { return r0, r1 } -// IncrementMentionCount provides a mock function with given fields: channelID, userIDs, isRoot -func (_m *ChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error { - ret := _m.Called(channelID, userIDs, isRoot) +// IncrementMentionCount provides a mock function with given fields: channelID, userIDs, isRoot, isUrgent +func (_m *ChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool, isUrgent bool) error { + ret := _m.Called(channelID, userIDs, isRoot, isUrgent) var r0 error - if rf, ok := ret.Get(0).(func(string, []string, bool) error); ok { - r0 = rf(channelID, userIDs, isRoot) + if rf, ok := ret.Get(0).(func(string, []string, bool, bool) error); ok { + r0 = rf(channelID, userIDs, isRoot, isUrgent) } else { r0 = ret.Error(0) } @@ -2192,13 +2213,13 @@ func (_m *ChannelStore) UpdateLastViewedAt(channelIds []string, userID string) ( return r0, r1 } -// UpdateLastViewedAtPost provides a mock function with given fields: unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot -func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { - ret := _m.Called(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) +// UpdateLastViewedAtPost provides a mock function with given fields: unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot +func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { + ret := _m.Called(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) var r0 *model.ChannelUnreadAt - if rf, ok := ret.Get(0).(func(*model.Post, string, int, int, bool) *model.ChannelUnreadAt); ok { - r0 = rf(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + if rf, ok := ret.Get(0).(func(*model.Post, string, int, int, int, bool) *model.ChannelUnreadAt); ok { + r0 = rf(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.ChannelUnreadAt) @@ -2206,8 +2227,8 @@ func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID st } var r1 error - if rf, ok := ret.Get(1).(func(*model.Post, string, int, int, bool) error); ok { - r1 = rf(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + if rf, ok := ret.Get(1).(func(*model.Post, string, int, int, int, bool) error); ok { + r1 = rf(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) } else { r1 = ret.Error(1) } diff --git a/store/storetest/mocks/PostPriorityStore.go b/store/storetest/mocks/PostPriorityStore.go new file mode 100644 index 0000000000..de126ec11a --- /dev/null +++ b/store/storetest/mocks/PostPriorityStore.go @@ -0,0 +1,61 @@ +// Code generated by mockery v2.10.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v6/model" + mock "github.com/stretchr/testify/mock" +) + +// PostPriorityStore is an autogenerated mock type for the PostPriorityStore type +type PostPriorityStore struct { + mock.Mock +} + +// GetForPost provides a mock function with given fields: postId +func (_m *PostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + ret := _m.Called(postId) + + var r0 *model.PostPriority + if rf, ok := ret.Get(0).(func(string) *model.PostPriority); ok { + r0 = rf(postId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostPriority) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForPosts provides a mock function with given fields: ids +func (_m *PostPriorityStore) GetForPosts(ids []string) ([]*model.PostPriority, error) { + ret := _m.Called(ids) + + var r0 []*model.PostPriority + if rf, ok := ret.Get(0).(func([]string) []*model.PostPriority); ok { + r0 = rf(ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostPriority) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index bd7c7e81c7..1d8e8ac326 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -475,6 +475,22 @@ func (_m *Store) Post() store.PostStore { return r0 } +// PostPriority provides a mock function with given fields: +func (_m *Store) PostPriority() store.PostPriorityStore { + ret := _m.Called() + + var r0 store.PostPriorityStore + if rf, ok := ret.Get(0).(func() store.PostPriorityStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PostPriorityStore) + } + } + + return r0 +} + // Preference provides a mock function with given fields: func (_m *Store) Preference() store.PreferenceStore { ret := _m.Called() diff --git a/store/storetest/mocks/ThreadStore.go b/store/storetest/mocks/ThreadStore.go index 19d82db686..caf49a4e5e 100644 --- a/store/storetest/mocks/ThreadStore.go +++ b/store/storetest/mocks/ThreadStore.go @@ -119,13 +119,13 @@ func (_m *ThreadStore) GetMembershipsForUser(userId string, teamID string) ([]*m return r0, r1 } -// GetTeamsUnreadForUser provides a mock function with given fields: userID, teamIDs -func (_m *ThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { - ret := _m.Called(userID, teamIDs) +// GetTeamsUnreadForUser provides a mock function with given fields: userID, teamIDs, includeUrgentMentionCount +func (_m *ThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { + ret := _m.Called(userID, teamIDs, includeUrgentMentionCount) var r0 map[string]*model.TeamUnread - if rf, ok := ret.Get(0).(func(string, []string) map[string]*model.TeamUnread); ok { - r0 = rf(userID, teamIDs) + if rf, ok := ret.Get(0).(func(string, []string, bool) map[string]*model.TeamUnread); ok { + r0 = rf(userID, teamIDs, includeUrgentMentionCount) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]*model.TeamUnread) @@ -133,8 +133,8 @@ func (_m *ThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (m } var r1 error - if rf, ok := ret.Get(1).(func(string, []string) error); ok { - r1 = rf(userID, teamIDs) + if rf, ok := ret.Get(1).(func(string, []string, bool) error); ok { + r1 = rf(userID, teamIDs, includeUrgentMentionCount) } else { r1 = ret.Error(1) } @@ -165,13 +165,13 @@ func (_m *ThreadStore) GetThreadFollowers(threadID string, fetchOnlyActive bool) return r0, r1 } -// GetThreadForUser provides a mock function with given fields: threadMembership, extended -func (_m *ThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { - ret := _m.Called(threadMembership, extended) +// GetThreadForUser provides a mock function with given fields: threadMembership, extended, postPriorityIsEnabled +func (_m *ThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool, postPriorityIsEnabled bool) (*model.ThreadResponse, error) { + ret := _m.Called(threadMembership, extended, postPriorityIsEnabled) var r0 *model.ThreadResponse - if rf, ok := ret.Get(0).(func(*model.ThreadMembership, bool) *model.ThreadResponse); ok { - r0 = rf(threadMembership, extended) + if rf, ok := ret.Get(0).(func(*model.ThreadMembership, bool, bool) *model.ThreadResponse); ok { + r0 = rf(threadMembership, extended, postPriorityIsEnabled) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.ThreadResponse) @@ -179,8 +179,8 @@ func (_m *ThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership } var r1 error - if rf, ok := ret.Get(1).(func(*model.ThreadMembership, bool) error); ok { - r1 = rf(threadMembership, extended) + if rf, ok := ret.Get(1).(func(*model.ThreadMembership, bool, bool) error); ok { + r1 = rf(threadMembership, extended, postPriorityIsEnabled) } else { r1 = ret.Error(1) } @@ -341,6 +341,27 @@ func (_m *ThreadStore) GetTotalUnreadThreads(userId string, teamID string, opts return r0, r1 } +// GetTotalUnreadUrgentMentions provides a mock function with given fields: userId, teamID, opts +func (_m *ThreadStore) GetTotalUnreadUrgentMentions(userId string, teamID string, opts model.GetUserThreadsOpts) (int64, error) { + ret := _m.Called(userId, teamID, opts) + + var r0 int64 + if rf, ok := ret.Get(0).(func(string, string, model.GetUserThreadsOpts) int64); ok { + r0 = rf(userId, teamID, opts) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, model.GetUserThreadsOpts) error); ok { + r1 = rf(userId, teamID, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // MaintainMembership provides a mock function with given fields: userID, postID, opts func (_m *ThreadStore) MaintainMembership(userID string, postID string, opts store.ThreadMembershipOpts) (*model.ThreadMembership, error) { ret := _m.Called(userID, postID, opts) diff --git a/store/storetest/post_priority_store.go b/store/storetest/post_priority_store.go new file mode 100644 index 0000000000..68efa6ec8d --- /dev/null +++ b/store/storetest/post_priority_store.go @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "database/sql" + "errors" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostPriorityStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("GetForPost", func(t *testing.T) { testPostPriorityStoreGetForPost(t, ss) }) +} + +func testPostPriorityStoreGetForPost(t *testing.T, ss store.Store) { + + t.Run("Save post priority when in post's metadata", func(t *testing.T) { + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + + p2 := model.Post{} + p2.ChannelId = model.NewId() + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p3 := model.Post{} + p3.ChannelId = model.NewId() + p3.UserId = model.NewId() + p3.Message = NewTestId() + + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2, &p3}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + pp1, err := ss.PostPriority().GetForPost(p1.Id) + require.NoError(t, err) + assert.Equal(t, "important", *pp1.Priority) + assert.Equal(t, true, *pp1.RequestedAck) + assert.Equal(t, false, *pp1.PersistentNotifications) + + pp2, err := ss.PostPriority().GetForPost(p2.Id) + require.NoError(t, err) + assert.Equal(t, model.PostPriorityUrgent, *pp2.Priority) + assert.Equal(t, false, *pp2.RequestedAck) + assert.Equal(t, true, *pp2.PersistentNotifications) + + _, err = ss.PostPriority().GetForPost(p3.Id) + assert.True(t, errors.Is(err, sql.ErrNoRows)) + }) +} diff --git a/store/storetest/post_store.go b/store/storetest/post_store.go index 677d049218..f3c7d91630 100644 --- a/store/storetest/post_store.go +++ b/store/storetest/post_store.go @@ -238,6 +238,31 @@ func testPostStoreSave(t *testing.T, ss store.Store) { assert.Greater(t, rchannel3.LastPostAt, rchannel2.LastPostAt) assert.Equal(t, int64(3), rchannel3.TotalMsgCount) }) + + t.Run("Save post with priority metadata set", func(t *testing.T) { + o1 := model.Post{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.Message = NewTestId() + + o1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + + p, err := ss.Post().Save(&o1) + require.NoError(t, err, "couldn't save item") + assert.Equal(t, int64(0), p.ReplyCount) + + pp, err := ss.PostPriority().GetForPost(p.Id) + require.NoError(t, err, "couldn't save item") + assert.Equal(t, "important", *pp.Priority) + assert.Equal(t, true, *pp.RequestedAck) + assert.Equal(t, false, *pp.PersistentNotifications) + }) } func testPostStoreSaveMultiple(t *testing.T, ss store.Store) { diff --git a/store/storetest/store.go b/store/storetest/store.go index 7979f8dac5..4f785c0426 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -56,6 +56,7 @@ type Store struct { ProductNoticesStore mocks.ProductNoticesStore context context.Context NotifyAdminStore mocks.NotifyAdminStore + PostPriorityStore mocks.PostPriorityStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -100,6 +101,7 @@ func (s *Store) NotifyAdmin() store.NotifyAdminStore { return &s.NotifyAdmin func (s *Store) Group() store.GroupStore { return &s.GroupStore } func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMetadataStore } func (s *Store) SharedChannel() store.SharedChannelStore { return &s.SharedChannelStore } +func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorityStore } func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } func (s *Store) Close() { /* do nothing */ } func (s *Store) LockToMaster() { /* do nothing */ } @@ -158,5 +160,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.ProductNoticesStore, &s.SharedChannelStore, &s.NotifyAdminStore, + &s.PostPriorityStore, ) } diff --git a/store/storetest/thread_store.go b/store/storetest/thread_store.go index e90e9f7d89..8bf646b503 100644 --- a/store/storetest/thread_store.go +++ b/store/storetest/thread_store.go @@ -32,7 +32,7 @@ func TestThreadStore(t *testing.T, ss store.Store, s SqlStore) { } func testThreadStorePopulation(t *testing.T, ss store.Store) { - makeSomePosts := func() []*model.Post { + makeSomePosts := func(urgent bool) []*model.Post { u1 := model.User{ Email: MakeEmail(), @@ -61,6 +61,16 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { o.UserId = u.Id o.Message = NewTestId() + if urgent { + o.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + } + } + otmp, err3 := ss.Post().Save(&o) require.NoError(t, err3) o2 := model.Post{} @@ -100,7 +110,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { return newPosts } t.Run("Save replies creates a thread", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) thread, err := ss.Thread().Get(newPosts[0].Id) require.NoError(t, err, "couldn't get thread") require.NotNil(t, thread) @@ -133,7 +143,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Delete a reply updates count on a thread", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) thread, err := ss.Thread().Get(newPosts[0].Id) require.NoError(t, err, "couldn't get thread") require.NotNil(t, thread) @@ -307,7 +317,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Thread membership 'viewed' timestamp is updated properly", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, @@ -341,7 +351,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Thread membership 'viewed' timestamp is updated properly for new membership", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, @@ -356,7 +366,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Updating post does not make thread unread", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, IncrementMentions: false, @@ -366,14 +376,14 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { } m, err := ss.Thread().MaintainMembership(newPosts[0].UserId, newPosts[0].Id, opts) require.NoError(t, err) - th, err := ss.Thread().GetThreadForUser(m, false) + th, err := ss.Thread().GetThreadForUser(m, false, false) require.NoError(t, err) require.Equal(t, int64(2), th.UnreadReplies) m.LastViewed = newPosts[2].UpdateAt + 1 _, err = ss.Thread().UpdateMembership(m) require.NoError(t, err) - th, err = ss.Thread().GetThreadForUser(m, false) + th, err = ss.Thread().GetThreadForUser(m, false, false) require.NoError(t, err) require.Equal(t, int64(0), th.UnreadReplies) @@ -382,13 +392,13 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { _, err = ss.Post().Update(editedPost, newPosts[2]) require.NoError(t, err) - th, err = ss.Thread().GetThreadForUser(m, false) + th, err = ss.Thread().GetThreadForUser(m, false, false) require.NoError(t, err) require.Equal(t, int64(0), th.UnreadReplies) }) t.Run("Empty participantID should not appear in thread response", func(t *testing.T) { - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, IncrementMentions: false, @@ -399,7 +409,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { m, err := ss.Thread().MaintainMembership("", newPosts[0].Id, opts) require.NoError(t, err) m.UserId = newPosts[0].UserId - th, err := ss.Thread().GetThreadForUser(m, true) + th, err := ss.Thread().GetThreadForUser(m, true, false) require.NoError(t, err) for _, user := range th.Participants { require.NotNil(t, user) @@ -407,7 +417,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { }) t.Run("Get unread reply counts for thread", func(t *testing.T) { t.Skip("MM-41797") - newPosts := makeSomePosts() + newPosts := makeSomePosts(false) opts := store.ThreadMembershipOpts{ Following: true, IncrementMentions: false, @@ -435,6 +445,36 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { require.NoError(t, err) require.Equal(t, int64(2), unreads) }) + + testCases := []bool{true, false} + + for _, isUrgent := range testCases { + t.Run("Return is urgent for user thread/s", func(t *testing.T) { + newPosts := makeSomePosts(isUrgent) + opts := store.ThreadMembershipOpts{ + Following: true, + IncrementMentions: false, + UpdateFollowing: true, + UpdateViewedTimestamp: true, + UpdateParticipants: false, + } + + userID := newPosts[0].UserId + _, e := ss.Thread().MaintainMembership(userID, newPosts[0].Id, opts) + require.NoError(t, e) + + m, e := ss.Thread().GetMembershipForUser(userID, newPosts[0].Id) + require.NoError(t, e) + + th, e := ss.Thread().GetThreadForUser(m, false, true) + require.NoError(t, e) + require.Equal(t, isUrgent, th.IsUrgent) + + threads, e := ss.Thread().GetThreadsForUser(userID, "", model.GetUserThreadsOpts{IncludeIsUrgent: true}) + require.NoError(t, e) + require.Equal(t, isUrgent, threads[0].IsUrgent) + }) + } } func threadStoreCreateReply(t *testing.T, ss store.Store, channelID, postID, userID string, createAt int64) *model.Post { @@ -660,7 +700,7 @@ func testGetTeamsUnreadForUser(t *testing.T, ss store.Store) { threadStoreCreateReply(t, ss, channel1.Id, post.Id, post.UserId, model.GetMillis()) createThreadMembership(userID, post.Id) - teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}) + teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}, true) require.NoError(t, err) assert.Len(t, teamsUnread, 1) assert.Equal(t, int64(1), teamsUnread[team1.Id].ThreadCount) @@ -674,7 +714,7 @@ func testGetTeamsUnreadForUser(t *testing.T, ss store.Store) { threadStoreCreateReply(t, ss, channel1.Id, post.Id, post.UserId, model.GetMillis()) createThreadMembership(userID, post.Id) - teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}) + teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}, true) require.NoError(t, err) assert.Len(t, teamsUnread, 1) assert.Equal(t, int64(2), teamsUnread[team1.Id].ThreadCount) @@ -693,16 +733,24 @@ func testGetTeamsUnreadForUser(t *testing.T, ss store.Store) { Type: model.ChannelTypeOpen, }, -1) require.NoError(t, err) + post2, err := ss.Post().Save(&model.Post{ ChannelId: channel2.Id, UserId: userID, Message: model.NewRandomString(10), + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + }, }) require.NoError(t, err) threadStoreCreateReply(t, ss, channel2.Id, post2.Id, post2.UserId, model.GetMillis()) createThreadMembership(userID, post2.Id) - teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id, team2.Id}) + teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id, team2.Id}, true) require.NoError(t, err) assert.Len(t, teamsUnread, 2) assert.Equal(t, int64(2), teamsUnread[team1.Id].ThreadCount) @@ -715,11 +763,12 @@ func testGetTeamsUnreadForUser(t *testing.T, ss store.Store) { _, err = ss.Thread().MaintainMembership(userID, post2.Id, opts) require.NoError(t, err) - teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team2.Id}) + teamsUnread, err = ss.Thread().GetTeamsUnreadForUser(userID, []string{team2.Id}, true) require.NoError(t, err) assert.Len(t, teamsUnread, 1) assert.Equal(t, int64(1), teamsUnread[team2.Id].ThreadCount) assert.Equal(t, int64(1), teamsUnread[team2.Id].ThreadMentionCount) + assert.Equal(t, int64(1), teamsUnread[team2.Id].ThreadUrgentMentionCount) } type byPostId []*model.Post @@ -831,6 +880,13 @@ func testVarious(t *testing.T, ss store.Store) { ChannelId: team1channel1.Id, UserId: user1ID, Message: model.NewRandomString(10), + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + }, }) require.NoError(t, err) @@ -1032,6 +1088,33 @@ func testVarious(t *testing.T, ss store.Store) { } }) + t.Run("GetTotalUnreadUrgentMentions", func(t *testing.T) { + testCases := []struct { + Description string + UserID string + TeamID string + Options model.GetUserThreadsOpts + ExpectedThreads []*model.Post + }{ + {"all teams, user1", user1ID, "", model.GetUserThreadsOpts{}, []*model.Post{ + team1channel1post3, + }}, + {"team1, user1", user1ID, team1.Id, model.GetUserThreadsOpts{}, []*model.Post{ + team1channel1post3, + }}, + {"team2, user1", user1ID, team2.Id, model.GetUserThreadsOpts{}, []*model.Post{}}, + } + + for _, testCase := range testCases { + t.Run(testCase.Description, func(t *testing.T) { + totalUnreadUrgentMentions, err := ss.Thread().GetTotalUnreadUrgentMentions(testCase.UserID, testCase.TeamID, testCase.Options) + require.NoError(t, err) + + assert.EqualValues(t, int64(len(testCase.ExpectedThreads)), totalUnreadUrgentMentions) + }) + } + }) + assertThreadPosts := func(t *testing.T, threads []*model.ThreadResponse, expectedPosts []*model.Post) { t.Helper() @@ -1166,7 +1249,7 @@ func testMarkAllAsReadByChannels(t *testing.T, ss store.Store) { assertThreadReplyCount := func(t *testing.T, userID string, count int64) { t.Helper() - teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}) + teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{team1.Id}, false) require.NoError(t, err) require.Len(t, teamsUnread, 1, "unexpected unread teams count") assert.Equal(t, count, teamsUnread[team1.Id].ThreadCount, "unexpected thread count") @@ -1623,7 +1706,7 @@ func testMarkAllAsReadByTeam(t *testing.T, ss store.Store) { assertThreadReplyCount := func(t *testing.T, userID, teamID string, count int64, message string) { t.Helper() - teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{teamID}) + teamsUnread, err := ss.Thread().GetTeamsUnreadForUser(userID, []string{teamID}, true) require.NoError(t, err) require.Lenf(t, teamsUnread, 1, "unexpected unread teams count: %s", message) assert.Equalf(t, count, teamsUnread[teamID].ThreadCount, "unexpected thread count: %s", message) diff --git a/store/storetest/user_store.go b/store/storetest/user_store.go index c938056249..138c3cfb14 100644 --- a/store/storetest/user_store.go +++ b/store/storetest/user_store.go @@ -2468,7 +2468,7 @@ func testUserUnreadCount(t *testing.T, ss store.Store) { // Post one message with mention to open channel _, nErr = ss.Post().Save(&p1) require.NoError(t, nErr) - nErr = ss.Channel().IncrementMentionCount(c1.Id, []string{u2.Id, u3.Id}, false) + nErr = ss.Channel().IncrementMentionCount(c1.Id, []string{u2.Id, u3.Id}, false, false) require.NoError(t, nErr) // Post 2 messages without mention to direct channel @@ -2479,7 +2479,7 @@ func testUserUnreadCount(t *testing.T, ss store.Store) { _, nErr = ss.Post().Save(&p2) require.NoError(t, nErr) - nErr = ss.Channel().IncrementMentionCount(c2.Id, []string{u2.Id}, false) + nErr = ss.Channel().IncrementMentionCount(c2.Id, []string{u2.Id}, false, false) require.NoError(t, nErr) p3 := model.Post{} @@ -2489,7 +2489,7 @@ func testUserUnreadCount(t *testing.T, ss store.Store) { _, nErr = ss.Post().Save(&p3) require.NoError(t, nErr) - nErr = ss.Channel().IncrementMentionCount(c2.Id, []string{u2.Id}, false) + nErr = ss.Channel().IncrementMentionCount(c2.Id, []string{u2.Id}, false, false) require.NoError(t, nErr) badge, unreadCountErr := ss.User().GetUnreadCount(u2.Id, false) @@ -2501,7 +2501,7 @@ func testUserUnreadCount(t *testing.T, ss store.Store) { require.Equal(t, int64(1), badge, "should have 1 unread message") // Increment root mentions by 1 - nErr = ss.Channel().IncrementMentionCount(c1.Id, []string{u3.Id}, true) + nErr = ss.Channel().IncrementMentionCount(c1.Id, []string{u3.Id}, true, false) require.NoError(t, nErr) // CRT is enabled, only root mentions are counted diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 9e8bb2fade..ba5d2436e7 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -36,6 +36,7 @@ type TimerLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore ReactionStore store.ReactionStore @@ -130,6 +131,10 @@ func (s *TimerLayer) Post() store.PostStore { return s.PostStore } +func (s *TimerLayer) PostPriority() store.PostPriorityStore { + return s.PostPriorityStore +} + func (s *TimerLayer) Preference() store.PreferenceStore { return s.PreferenceStore } @@ -300,6 +305,11 @@ type TimerLayerPostStore struct { Root *TimerLayer } +type TimerLayerPostPriorityStore struct { + store.PostPriorityStore + Root *TimerLayer +} + type TimerLayerPreferenceStore struct { store.PreferenceStore Root *TimerLayer @@ -671,6 +681,22 @@ func (s *TimerLayerChannelStore) CountPostsAfter(channelID string, timestamp int return result, resultVar1, err } +func (s *TimerLayerChannelStore) CountUrgentPostsAfter(channelID string, timestamp int64, userID string) (int, error) { + start := time.Now() + + result, err := s.ChannelStore.CountUrgentPostsAfter(channelID, timestamp, userID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.CountUrgentPostsAfter", success, elapsed) + } + return result, err +} + func (s *TimerLayerChannelStore) CreateDirectChannel(userID *model.User, otherUserID *model.User, channelOptions ...model.ChannelOption) (*model.Channel, error) { start := time.Now() @@ -1711,10 +1737,10 @@ func (s *TimerLayerChannelStore) GroupSyncedChannelCount() (int64, error) { return result, err } -func (s *TimerLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool) error { +func (s *TimerLayerChannelStore) IncrementMentionCount(channelID string, userIDs []string, isRoot bool, isUrgent bool) error { start := time.Now() - err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot) + err := s.ChannelStore.IncrementMentionCount(channelID, userIDs, isRoot, isUrgent) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -2248,10 +2274,10 @@ func (s *TimerLayerChannelStore) UpdateLastViewedAt(channelIds []string, userID return result, err } -func (s *TimerLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { +func (s *TimerLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, urgentMentionCount int, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { start := time.Now() - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, setUnreadCountRoot) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, urgentMentionCount, setUnreadCountRoot) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -5891,6 +5917,38 @@ func (s *TimerLayerPostStore) Update(newPost *model.Post, oldPost *model.Post) ( return result, err } +func (s *TimerLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { + start := time.Now() + + result, err := s.PostPriorityStore.GetForPost(postId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPriorityStore.GetForPost", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostPriorityStore) GetForPosts(ids []string) ([]*model.PostPriority, error) { + start := time.Now() + + result, err := s.PostPriorityStore.GetForPosts(ids) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPriorityStore.GetForPosts", success, elapsed) + } + return result, err +} + func (s *TimerLayerPreferenceStore) CleanupFlagsBatch(limit int64) (int64, error) { start := time.Now() @@ -8881,10 +8939,10 @@ func (s *TimerLayerThreadStore) GetMembershipsForUser(userId string, teamID stri return result, err } -func (s *TimerLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string) (map[string]*model.TeamUnread, error) { +func (s *TimerLayerThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) { start := time.Now() - result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs) + result, err := s.ThreadStore.GetTeamsUnreadForUser(userID, teamIDs, includeUrgentMentionCount) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -8913,10 +8971,10 @@ func (s *TimerLayerThreadStore) GetThreadFollowers(threadID string, fetchOnlyAct return result, err } -func (s *TimerLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, error) { +func (s *TimerLayerThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool, postPriorityIsEnabled bool) (*model.ThreadResponse, error) { start := time.Now() - result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended) + result, err := s.ThreadStore.GetThreadForUser(threadMembership, extended, postPriorityIsEnabled) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -9041,6 +9099,22 @@ func (s *TimerLayerThreadStore) GetTotalUnreadThreads(userId string, teamID stri return result, err } +func (s *TimerLayerThreadStore) GetTotalUnreadUrgentMentions(userId string, teamID string, opts model.GetUserThreadsOpts) (int64, error) { + start := time.Now() + + result, err := s.ThreadStore.GetTotalUnreadUrgentMentions(userId, teamID, opts) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.GetTotalUnreadUrgentMentions", success, elapsed) + } + return result, err +} + func (s *TimerLayerThreadStore) MaintainMembership(userID string, postID string, opts store.ThreadMembershipOpts) (*model.ThreadMembership, error) { start := time.Now() @@ -11270,6 +11344,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.OAuthStore = &TimerLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &TimerLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &TimerLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostPriorityStore = &TimerLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &TimerLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &TimerLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} newStore.ReactionStore = &TimerLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} From 27db854089a6752b996fdcc0089621cc44624f5e Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Thu, 24 Nov 2022 02:41:23 +0200 Subject: [PATCH 23/80] MM-47750: Adds PostAcknowledgements table and apis (#21689) * MM-46410: adds urgency on mention counts We have introduced priority for posts in https://github.com/mattermost/mattermost-webapp/pull/10951. We do need to color the mention badges in the webapp with a prominent color when a mention is posted in an urgent message. A thread has urgent mentions if the root post is marked as urgent, and the replies contain mentions to the user viewing the thread. This PR adds two columns, urgentmentioncount, and isurgent, in channelmembers, and threads tables respectively. Furthermore when asking for team/thread mention counts, we also return urgent mention counts for the user. * Fixes method in tests * empty commit * Fixes method call * Fixes single thread response is_urgent * Fixes errors * Fixes mysql migration and adds graphql schema * Fixes tests * Refactors IsUrgent and Adds PostsPriority table Changes: - removes is_urgent from the threads table - adds a new table to hold posts priorities - refactors priority out of the props and into the new table * Fixes * Adds translation strings * Fixes migrations and tests * Fixes tests * empty * Adds Priority to Copy * empty * Fixes priority not saved when boards is enabled We are nilifying Metadata when post.ForPlugin(), which didn't save Priority for a post when Boards was enabled. This commit copies metadata again to the post, so metadata are reinstated. * Fixes tests * Adding store tests and fixes syntax error * Uses threads.ThreadTeamId * Fixes error * Adds UrgentMentionCount in graphql api test * Fetches post priority in batches * Addresses review comments * Restore only priority on create post * Fixes tests * Nits * Some refactoring * Fixes get thread options when post priority enabled * Adds missing translation * Use the constant instead of "urgent" string * Renames urgent constant * MM-47750: Adds PostAcknowledgements table and apis - Adds post acknowledgement api/app/store methods to be able to save and delete post acknowledgements by users. - Adds wesbsocket events for acknowledgement created/deleted - Returns post acknowledgements in the post's metadata * Empty * Fixes incorrect urgent count when marking a post as unread * Adds license * Fixes ACK api, and adds tests * Fixes vet * Fixes tests * Addresses review comments * Remove unnecessary lines * Adds config option and changes return of delete ack * Empty * Empty * Enable config by default * Fixes intl * Fixes test after setting config default true * Changes endpoints to PostForUser * Avoids replica lag * Fixes error in merge * Fixes RetryLayer tests due to merge * Empty * Empty * Empty Co-authored-by: Mattermod --- api4/post.go | 77 ++++++++ api4/post_test.go | 84 ++++++++ api4/user_test.go | 2 + app/app_iface.go | 4 + app/opentracing/opentracing_layer.go | 88 +++++++++ app/post_acknowledgements.go | 130 +++++++++++++ app/post_acknowledgements_test.go | 149 ++++++++++++++ app/post_metadata.go | 12 ++ config/client.go | 4 + db/migrations/migrations.list | 4 + ...0098_create_post_acknowledgements.down.sql | 1 + ...000098_create_post_acknowledgements.up.sql | 6 + ...0098_create_post_acknowledgements.down.sql | 1 + ...000098_create_post_acknowledgements.up.sql | 6 + i18n/en.json | 36 ++++ model/client4.go | 22 +++ model/config.go | 2 +- model/post_acknowledgement.go | 24 +++ model/post_metadata.go | 21 +- model/websocket_message.go | 2 + store/opentracinglayer/opentracinglayer.go | 101 ++++++++++ store/retrylayer/retrylayer.go | 116 +++++++++++ store/retrylayer/retrylayer_test.go | 1 + store/sqlstore/post_acknowledgements_store.go | 144 ++++++++++++++ .../post_acknowledgements_store_test.go | 14 ++ store/sqlstore/store.go | 6 + store/store.go | 9 + .../mocks/PostAcknowledgementStore.go | 121 ++++++++++++ store/storetest/mocks/Store.go | 16 ++ .../storetest/post_acknowledgements_store.go | 181 ++++++++++++++++++ store/storetest/store.go | 27 +-- store/timerlayer/timerlayer.go | 91 +++++++++ 32 files changed, 1483 insertions(+), 19 deletions(-) create mode 100644 app/post_acknowledgements.go create mode 100644 app/post_acknowledgements_test.go create mode 100644 db/migrations/mysql/000098_create_post_acknowledgements.down.sql create mode 100644 db/migrations/mysql/000098_create_post_acknowledgements.up.sql create mode 100644 db/migrations/postgres/000098_create_post_acknowledgements.down.sql create mode 100644 db/migrations/postgres/000098_create_post_acknowledgements.up.sql create mode 100644 model/post_acknowledgement.go create mode 100644 store/sqlstore/post_acknowledgements_store.go create mode 100644 store/sqlstore/post_acknowledgements_store_test.go create mode 100644 store/storetest/mocks/PostAcknowledgementStore.go create mode 100644 store/storetest/post_acknowledgements_store.go diff --git a/api4/post.go b/api4/post.go index b4ad80d653..6aa7049563 100644 --- a/api4/post.go +++ b/api4/post.go @@ -38,6 +38,9 @@ func (api *API) InitPost() { api.BaseRoutes.Post.Handle("/pin", api.APISessionRequired(pinPost)).Methods("POST") api.BaseRoutes.Post.Handle("/unpin", api.APISessionRequired(unpinPost)).Methods("POST") + + api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(acknowledgePost)).Methods("POST") + api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(unacknowledgePost)).Methods("DELETE") } func createPost(c *Context, w http.ResponseWriter, r *http.Request) { @@ -941,6 +944,80 @@ func unpinPost(c *Context, w http.ResponseWriter, _ *http.Request) { saveIsPinnedPost(c, w, false) } +func acknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { + // license check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequirePostId().RequireUserId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + acknowledgement, appErr := c.App.SaveAcknowledgementForPost(c.AppContext, c.Params.PostId, c.Params.UserId) + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(acknowledgement) + if err != nil { + c.Err = model.NewAppError("acknowledgePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(js) +} + +func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { + // license check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequirePostId().RequireUserId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + _, err := c.App.GetSinglePost(c.Params.PostId, false) + if err != nil { + c.Err = err + return + } + + appErr := c.App.DeleteAcknowledgementForPost(c.AppContext, c.Params.PostId, c.Params.UserId) + if appErr != nil { + c.Err = appErr + return + } + + ReturnStatusOK(w) +} + func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { c.RequirePostId() if c.Err != nil { diff --git a/api4/post_test.go b/api4/post_test.go index 86b064191e..3240347e1e 100644 --- a/api4/post_test.go +++ b/api4/post_test.go @@ -3342,3 +3342,87 @@ func TestPostReminder(t *testing.T) { require.Truef(t, caught, "User should have received %s event", model.WebsocketEventEphemeralMessage) } + +func TestAcknowledgePost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + client := th.Client + + post := th.BasicPost + ack, _, err := client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 1) + require.Equal(t, acks[0], ack) + + _, resp, err := client.AcknowledgePost("junk", th.BasicUser.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.AcknowledgePost(GenerateTestId(), th.BasicUser.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + _, resp, err = client.AcknowledgePost(post.Id, "junk") + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.AcknowledgePost(post.Id, th.BasicUser2.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + client.Logout() + _, resp, err = client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) + + _, _, err = th.SystemAdminClient.AcknowledgePost(post.Id, th.SystemAdminUser.Id) + require.NoError(t, err) +} + +func TestUnacknowledgePost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + client := th.Client + + post := th.BasicPost + ack, _, err := client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 1) + require.Equal(t, acks[0], ack) + + resp, err := client.UnacknowledgePost("junk", th.BasicUser.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + resp, err = client.UnacknowledgePost(GenerateTestId(), th.BasicUser.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + resp, err = client.UnacknowledgePost(post.Id, "junk") + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + resp, err = client.UnacknowledgePost(post.Id, th.BasicUser2.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + _, err = client.UnacknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 0) + + client.Logout() + resp, err = client.UnacknowledgePost(post.Id, th.BasicUser.Id) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) +} diff --git a/api4/user_test.go b/api4/user_test.go index d269593fce..17195f7d6d 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -6569,6 +6569,7 @@ func TestSingleThreadGet(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ThreadAutoFollow = true + *cfg.ServiceSettings.PostPriority = false *cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn *cfg.ServiceSettings.PostPriority = true cfg.FeatureFlags.PostPriority = true @@ -6615,6 +6616,7 @@ func TestSingleThreadGet(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true }) tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) diff --git a/app/app_iface.go b/app/app_iface.go index 093d97a035..352ece5283 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -509,6 +509,7 @@ type AppIface interface { DeactivateGuests(c *request.Context) *model.AppError DeactivateMfa(userID string) *model.AppError DeauthorizeOAuthAppForUser(userID, appID string) *model.AppError + DeleteAcknowledgementForPost(c *request.Context, postID, userID string) *model.AppError DeleteAllExpiredPluginKeys() *model.AppError DeleteAllKeysForPlugin(pluginID string) *model.AppError DeleteBrandImage() *model.AppError @@ -567,6 +568,8 @@ type AppIface interface { GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) GeneratePublicLink(siteURL string, info *model.FileInfo) string GenerateSupportPacket() []model.FileData + GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) + GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) GetAllChannels(c request.CTX, page, perPage int, opts model.ChannelSearchOpts) (model.ChannelListWithTeamData, *model.AppError) GetAllChannelsCount(c request.CTX, opts model.ChannelSearchOpts) (int64, *model.AppError) @@ -984,6 +987,7 @@ type AppIface interface { SanitizeProfile(user *model.User, asAdmin bool) SanitizeTeam(session model.Session, team *model.Team) *model.Team SanitizeTeams(session model.Session, teams []*model.Team) []*model.Team + SaveAcknowledgementForPost(c *request.Context, postID, userID string) (*model.PostAcknowledgement, *model.AppError) SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError SaveAdminNotifyData(data *model.NotifyAdminData) (*model.NotifyAdminData, *model.AppError) SaveBrandImage(imageData *multipart.FileHeader) *model.AppError diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index d58f160c4b..f4ac4fbb51 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -2838,6 +2838,28 @@ func (a *OpenTracingAppLayer) DefaultChannelNames(c request.CTX) []string { return resultVar0 } +func (a *OpenTracingAppLayer) DeleteAcknowledgementForPost(c *request.Context, postID string, userID string) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteAcknowledgementForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.DeleteAcknowledgementForPost(c, postID, userID) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) DeleteAllExpiredPluginKeys() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteAllExpiredPluginKeys") @@ -4411,6 +4433,50 @@ func (a *OpenTracingAppLayer) GenerateSupportPacket() []model.FileData { return resultVar0 } +func (a *OpenTracingAppLayer) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAcknowledgementsForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetAcknowledgementsForPost(postID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAcknowledgementsForPostList") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetAcknowledgementsForPostList(postList) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetActivePluginManifests") @@ -14312,6 +14378,28 @@ func (a *OpenTracingAppLayer) SanitizeTeams(session model.Session, teams []*mode return resultVar0 } +func (a *OpenTracingAppLayer) SaveAcknowledgementForPost(c *request.Context, postID string, userID string) (*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAcknowledgementForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.SaveAcknowledgementForPost(c, postID, userID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAdminNotification") diff --git a/app/post_acknowledgements.go b/app/post_acknowledgements.go new file mode 100644 index 0000000000..a91f76d02b --- /dev/null +++ b/app/post_acknowledgements.go @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +func (a *App) SaveAcknowledgementForPost(c *request.Context, postID, userID string) (*model.PostAcknowledgement, *model.AppError) { + post, err := a.GetSinglePost(postID, false) + if err != nil { + return nil, err + } + + channel, err := a.GetChannel(c, post.ChannelId) + if err != nil { + return nil, err + } + + if channel.DeleteAt > 0 { + return nil, model.NewAppError("SaveAcknowledgementForPost", "api.acknowledgement.save.archived_channel.app_error", nil, "", http.StatusForbidden) + } + + acknowledgedAt := model.GetMillis() + acknowledgement, nErr := a.Srv().Store().PostAcknowledgement().Save(postID, userID, acknowledgedAt) + + if nErr != nil { + var appErr *model.AppError + switch { + case errors.As(nErr, &appErr): + return nil, appErr + default: + return nil, model.NewAppError("SaveAcknowledgementForPost", "app.acknowledgement.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + + a.Srv().Go(func() { + a.sendAcknowledgementEvent(model.WebsocketEventAcknowledgementAdded, acknowledgement, post) + }) + + return acknowledgement, nil +} + +func (a *App) DeleteAcknowledgementForPost(c *request.Context, postID, userID string) *model.AppError { + post, err := a.GetSinglePost(postID, false) + if err != nil { + return err + } + + channel, err := a.GetChannel(c, post.ChannelId) + if err != nil { + return err + } + + if channel.DeleteAt > 0 { + return model.NewAppError("DeleteAcknowledgementForPost", "api.acknowledgement.delete.archived_channel.app_error", nil, "", http.StatusForbidden) + } + + oldAck, nErr := a.Srv().Store().PostAcknowledgement().Get(postID, userID) + + if nErr != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(nErr, &nfErr): + return model.NewAppError("GetPostAcknowledgement", "app.acknowledgement.get.app_error", nil, "", http.StatusNotFound).Wrap(nErr) + default: + return model.NewAppError("GetPostAcknowledgement", "app.acknowledgement.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + + if model.GetMillis()-oldAck.AcknowledgedAt > 5*60*1000 { + return model.NewAppError("DeleteAcknowledgementForPost", "api.acknowledgement.delete.deadline.app_error", nil, "", http.StatusForbidden) + } + + nErr = a.Srv().Store().PostAcknowledgement().Delete(oldAck) + if nErr != nil { + return model.NewAppError("DeleteAcknowledgementForPost", "app.acknowledgement.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + + a.Srv().Go(func() { + a.sendAcknowledgementEvent(model.WebsocketEventAcknowledgementRemoved, oldAck, post) + }) + + return nil +} + +func (a *App) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) { + acknowledgements, nErr := a.Srv().Store().PostAcknowledgement().GetForPost(postID) + if nErr != nil { + return nil, model.NewAppError("GetAcknowledgementsForPost", "app.acknowledgement.getforpost.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + + return acknowledgements, nil +} + +func (a *App) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) { + acknowledgements, err := a.Srv().Store().PostAcknowledgement().GetForPosts(postList.Order) + + if err != nil { + return nil, model.NewAppError("GetPostAcknowledgementsForPostList", "app.acknowledgement.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + acknowledgementsMap := make(map[string][]*model.PostAcknowledgement) + + for _, ack := range acknowledgements { + acknowledgementsMap[ack.PostId] = append(acknowledgementsMap[ack.PostId], ack) + } + + return acknowledgementsMap, nil +} + +func (a *App) sendAcknowledgementEvent(event string, acknowledgement *model.PostAcknowledgement, post *model.Post) { + // send out that a acknowledgement has been added/removed + message := model.NewWebSocketEvent(event, "", post.ChannelId, "", nil, "") + + acknowledgementJSON, err := json.Marshal(acknowledgement) + if err != nil { + a.Log().Warn("Failed to encode acknowledgement to JSON", mlog.Err(err)) + } + message.Add("acknowledgement", string(acknowledgementJSON)) + a.Publish(message) +} diff --git a/app/post_acknowledgements_test.go b/app/post_acknowledgements_test.go new file mode 100644 index 0000000000..f1306c29fd --- /dev/null +++ b/app/post_acknowledgements_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func TestPostAcknowledgementsApp(t *testing.T) { + t.Run("SaveAcknowledgementForPost", func(t *testing.T) { testSaveAcknowledgementForPost(t) }) + t.Run("DeleteAcknowledgementForPost", func(t *testing.T) { testDeleteAcknowledgementForPost(t) }) + t.Run("GetAcknowledgementsForPostList", func(t *testing.T) { testGetAcknowledgementsForPostList(t) }) +} + +func testSaveAcknowledgementForPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("save acknowledgment for post should save acknowledgement", func(t *testing.T) { + post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "message", + }, "", true) + + require.Nil(t, err) + + acknowledgment, err := th.App.SaveAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + require.Greater(t, acknowledgment.AcknowledgedAt, int64(0)) + require.Equal(t, post.Id, acknowledgment.PostId) + require.Equal(t, th.BasicUser.Id, acknowledgment.UserId) + }) +} + +func testDeleteAcknowledgementForPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + t.Run("delete acknowledgment for post should delete acknowledgement", func(t *testing.T) { + _, err = th.App.SaveAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + acknowledgments, err := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + + err = th.App.DeleteAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + acknowledgments, err = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Empty(t, acknowledgments) + }) + + t.Run("delete acknowledgment for post after 5 min after acknowledged should not delete", func(t *testing.T) { + _, nErr := th.App.Srv().Store().PostAcknowledgement().Save(post.Id, th.BasicUser.Id, model.GetMillis()-int64(6*60*1000)) + require.NoError(t, nErr) + + acknowledgments, err := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + + err = th.App.DeleteAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.NotNil(t, err) + require.Equal(t, 403, err.StatusCode) + + acknowledgments, err = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + }) +} + +func testGetAcknowledgementsForPostList(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + p1, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + p2, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + p3, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + t.Run("get acknowledgments for post list should return a map", func(t *testing.T) { + _, err = th.App.SaveAcknowledgementForPost(th.Context, p1.Id, th.BasicUser.Id) + require.Nil(t, err) + _, err = th.App.SaveAcknowledgementForPost(th.Context, p2.Id, th.BasicUser.Id) + require.Nil(t, err) + _, err = th.App.SaveAcknowledgementForPost(th.Context, p1.Id, th.BasicUser2.Id) + require.Nil(t, err) + + postList := model.NewPostList() + postList.AddPost(p1) + postList.AddOrder(p1.Id) + postList.AddPost(p2) + postList.AddOrder(p2.Id) + postList.AddPost(p3) + postList.AddOrder(p3.Id) + + acks1, err := th.App.GetAcknowledgementsForPost(p1.Id) + require.Nil(t, err) + acks2, err := th.App.GetAcknowledgementsForPost(p2.Id) + require.Nil(t, err) + + acknowledgementsMap, err := th.App.GetAcknowledgementsForPostList(postList) + require.Nil(t, err) + + expected := map[string][]*model.PostAcknowledgement{ + p1.Id: acks1, + p2.Id: acks2, + } + require.Equal(t, expected, acknowledgementsMap) + require.Len(t, acknowledgementsMap[p1.Id], 2) + require.Len(t, acknowledgementsMap[p2.Id], 1) + require.Nil(t, acknowledgementsMap[p3.Id]) + }) +} diff --git a/app/post_metadata.go b/app/post_metadata.go index 368d0803f0..347dfe8708 100644 --- a/app/post_metadata.go +++ b/app/post_metadata.go @@ -63,10 +63,15 @@ func (a *App) PreparePostListForClient(c request.CTX, originalList *model.PostLi if a.isPostPriorityEnabled() { priority, _ := a.GetPriorityForPostList(list) + acknowledgements, _ := a.GetAcknowledgementsForPostList(list) + for _, id := range list.Order { if _, ok := priority[id]; ok { list.Posts[id].Metadata.Priority = priority[id] } + if _, ok := acknowledgements[id]; ok { + list.Posts[id].Metadata.Acknowledgements = acknowledgements[id] + } } } @@ -139,6 +144,13 @@ func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNe } else { post.Metadata.Priority = priority } + + // Post's acknowledgements if any + if acknowledgements, err := a.GetAcknowledgementsForPost(post.Id); err != nil { + mlog.Warn("Failed to get post acknowledgements for a post", mlog.String("post_id", post.Id), mlog.Err(err)) + } else { + post.Metadata.Acknowledgements = acknowledgements + } } return post diff --git a/config/client.go b/config/client.go index c6a8587865..c6e650578b 100644 --- a/config/client.go +++ b/config/client.go @@ -206,6 +206,10 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li if license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise { props["EnableCustomGroups"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomGroups) } + + if (license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise) && c.FeatureFlags.PostPriority { + props["PostAcknowledgements"] = "true" + } } return props diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index 1acb91f075..ba9b1103ba 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -194,6 +194,8 @@ db/migrations/mysql/000096_threads_threadteamid.down.sql db/migrations/mysql/000096_threads_threadteamid.up.sql db/migrations/mysql/000097_create_posts_priority.down.sql db/migrations/mysql/000097_create_posts_priority.up.sql +db/migrations/mysql/000098_create_post_acknowledgements.down.sql +db/migrations/mysql/000098_create_post_acknowledgements.up.sql db/migrations/postgres/000001_create_teams.down.sql db/migrations/postgres/000001_create_teams.up.sql db/migrations/postgres/000002_create_team_members.down.sql @@ -388,3 +390,5 @@ db/migrations/postgres/000096_threads_threadteamid.down.sql db/migrations/postgres/000096_threads_threadteamid.up.sql db/migrations/postgres/000097_create_posts_priority.down.sql db/migrations/postgres/000097_create_posts_priority.up.sql +db/migrations/postgres/000098_create_post_acknowledgements.down.sql +db/migrations/postgres/000098_create_post_acknowledgements.up.sql diff --git a/db/migrations/mysql/000098_create_post_acknowledgements.down.sql b/db/migrations/mysql/000098_create_post_acknowledgements.down.sql new file mode 100644 index 0000000000..2360ca5a6e --- /dev/null +++ b/db/migrations/mysql/000098_create_post_acknowledgements.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS PostAcknowledgements; diff --git a/db/migrations/mysql/000098_create_post_acknowledgements.up.sql b/db/migrations/mysql/000098_create_post_acknowledgements.up.sql new file mode 100644 index 0000000000..9eb7567a85 --- /dev/null +++ b/db/migrations/mysql/000098_create_post_acknowledgements.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS PostAcknowledgements ( + PostId varchar(26) NOT NULL, + UserId varchar(26) NOT NULL, + AcknowledgedAt bigint(20) DEFAULT NULL, + PRIMARY KEY (PostId, UserId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/db/migrations/postgres/000098_create_post_acknowledgements.down.sql b/db/migrations/postgres/000098_create_post_acknowledgements.down.sql new file mode 100644 index 0000000000..dc5e96624d --- /dev/null +++ b/db/migrations/postgres/000098_create_post_acknowledgements.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS postacknowledgements; diff --git a/db/migrations/postgres/000098_create_post_acknowledgements.up.sql b/db/migrations/postgres/000098_create_post_acknowledgements.up.sql new file mode 100644 index 0000000000..8f35e7b685 --- /dev/null +++ b/db/migrations/postgres/000098_create_post_acknowledgements.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS postacknowledgements( + postid VARCHAR(26) NOT NULL, + userid VARCHAR(26) NOT NULL, + acknowledgedat bigint, + PRIMARY KEY (postid, userid) +); diff --git a/i18n/en.json b/i18n/en.json index 589c90973a..d7b4627505 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -59,6 +59,18 @@ "id": "September", "translation": "September" }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "You cannot remove an acknowledgment in an archived channel." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "You cannot delete an acknowledgment after 5min have passed." + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "You cannot acknowledgment in an archived channel." + }, { "id": "api.admin.add_certificate.array.app_error", "translation": "No file under 'certificate' in request." @@ -4459,6 +4471,22 @@ "id": "api.websocket_handler.server_busy.app_error", "translation": "Server is busy, non-critical services are temporarily unavailable." }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Unable to delete acknowledgement." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Unable to get acknowledgement." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "Unable to get acknowledgement for post." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "Unable to save acknowledgement for post." + }, { "id": "app.admin.saml.failure_decode_metadata_xml_from_idp.app_error", "translation": "Could not decode the XML metadata information received from the Identity Provider." @@ -7927,6 +7955,14 @@ "id": "model.access.is_valid.user_id.app_error", "translation": "Invalid user id." }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Invalid post id." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Invalid user id." + }, { "id": "model.authorize.is_valid.auth_code.app_error", "translation": "Invalid authorization code." diff --git a/model/client4.go b/model/client4.go index 47a1ce366b..b05895859e 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8448,6 +8448,28 @@ func (c *Client4) GetNewTeamMembersSince(teamID string, timeRange string, page i return newTeamMembersList, BuildResponse(r), nil } +func (c *Client4) AcknowledgePost(postId, userId string) (*PostAcknowledgement, *Response, error) { + r, err := c.DoAPIPost(c.userRoute(userId)+c.postRoute(postId)+"/ack", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var ack *PostAcknowledgement + if jsonErr := json.NewDecoder(r.Body).Decode(&ack); jsonErr != nil { + return nil, nil, NewAppError("AcknowledgePost", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return ack, BuildResponse(r), nil +} + +func (c *Client4) UnacknowledgePost(postId, userId string) (*Response, error) { + r, err := c.DoAPIDelete(c.userRoute(userId) + c.postRoute(postId) + "/ack") + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} + func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) { r, err := c.DoAPIPost(c.ldapRoute()+"/users/"+userID+"/group_sync_memberships", "") if err != nil { diff --git a/model/config.go b/model/config.go index be7a88b61d..a538f7032e 100644 --- a/model/config.go +++ b/model/config.go @@ -845,7 +845,7 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { } if s.PostPriority == nil { - s.PostPriority = NewBool(false) + s.PostPriority = NewBool(true) } } diff --git a/model/post_acknowledgement.go b/model/post_acknowledgement.go new file mode 100644 index 0000000000..227a678e6b --- /dev/null +++ b/model/post_acknowledgement.go @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import "net/http" + +type PostAcknowledgement struct { + UserId string `json:"user_id"` + PostId string `json:"post_id"` + AcknowledgedAt int64 `json:"acknowledged_at"` +} + +func (o *PostAcknowledgement) IsValid() *AppError { + if !IsValidId(o.UserId) { + return NewAppError("PostAcknowledgement.IsValid", "model.acknowledgement.is_valid.user_id.app_error", nil, "user_id="+o.UserId, http.StatusBadRequest) + } + + if !IsValidId(o.PostId) { + return NewAppError("PostAcknowledgement.IsValid", "model.acknowledgement.is_valid.post_id.app_error", nil, "post_id="+o.PostId, http.StatusBadRequest) + } + + return nil +} diff --git a/model/post_metadata.go b/model/post_metadata.go index 3730d06f55..ee49eb4f48 100644 --- a/model/post_metadata.go +++ b/model/post_metadata.go @@ -23,8 +23,11 @@ type PostMetadata struct { // Reactions holds reactions made to the post. Reactions []*Reaction `json:"reactions,omitempty"` - // Reactions holds reactions made to the post. + // Priority holds info about priority settings for the post. Priority *PostPriority `json:"priority,omitempty"` + + // Acknowledgements holds acknowledgements made by users to the post + Acknowledgements []*PostAcknowledgement `json:"acknowledgements,omitempty"` } type PostImage struct { @@ -57,6 +60,9 @@ func (p *PostMetadata) Copy() *PostMetadata { reactionsCopy := make([]*Reaction, len(p.Reactions)) copy(reactionsCopy, p.Reactions) + acknowledgementsCopy := make([]*PostAcknowledgement, len(p.Acknowledgements)) + copy(acknowledgementsCopy, p.Acknowledgements) + var postPriorityCopy *PostPriority if p.Priority != nil { postPriorityCopy = &PostPriority{ @@ -69,11 +75,12 @@ func (p *PostMetadata) Copy() *PostMetadata { } return &PostMetadata{ - Embeds: embedsCopy, - Emojis: emojisCopy, - Files: filesCopy, - Images: imagesCopy, - Reactions: reactionsCopy, - Priority: postPriorityCopy, + Embeds: embedsCopy, + Emojis: emojisCopy, + Files: filesCopy, + Images: imagesCopy, + Reactions: reactionsCopy, + Priority: postPriorityCopy, + Acknowledgements: acknowledgementsCopy, } } diff --git a/model/websocket_message.go b/model/websocket_message.go index 9cd3892453..8cd2ad1961 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -76,6 +76,8 @@ const ( WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadReadChanged = "thread_read_changed" WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received" + WebsocketEventAcknowledgementAdded = "post_acknowledgement_added" + WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed" ) type WebSocketMessage interface { diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 3ab5c4715d..38e136d493 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -37,6 +37,7 @@ type OpenTracingLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore @@ -132,6 +133,10 @@ func (s *OpenTracingLayer) Post() store.PostStore { return s.PostStore } +func (s *OpenTracingLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + func (s *OpenTracingLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -306,6 +311,11 @@ type OpenTracingLayerPostStore struct { Root *OpenTracingLayer } +type OpenTracingLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *OpenTracingLayer +} + type OpenTracingLayerPostPriorityStore struct { store.PostPriorityStore Root *OpenTracingLayer @@ -6544,6 +6554,96 @@ func (s *OpenTracingLayerPostStore) Update(newPost *model.Post, oldPost *model.P return result, err } +func (s *OpenTracingLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PostAcknowledgementStore.Delete(acknowledgement) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.Get(postID, userID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.GetForPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.GetForPost(postID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.GetForPosts") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPriorityStore.GetForPost") @@ -12591,6 +12691,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.OAuthStore = &OpenTracingLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &OpenTracingLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &OpenTracingLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &OpenTracingLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} newStore.PostPriorityStore = &OpenTracingLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &OpenTracingLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &OpenTracingLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 8dc782fce7..1da922dec1 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -40,6 +40,7 @@ type RetryLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore @@ -135,6 +136,10 @@ func (s *RetryLayer) Post() store.PostStore { return s.PostStore } +func (s *RetryLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + func (s *RetryLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -309,6 +314,11 @@ type RetryLayerPostStore struct { Root *RetryLayer } +type RetryLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *RetryLayer +} + type RetryLayerPostPriorityStore struct { store.PostPriorityStore Root *RetryLayer @@ -7420,6 +7430,111 @@ func (s *RetryLayerPostStore) Update(newPost *model.Post, oldPost *model.Post) ( } +func (s *RetryLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + + tries := 0 + for { + err := s.PostAcknowledgementStore.Delete(acknowledgement) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.Get(postID, userID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.GetForPost(postID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { tries := 0 @@ -14355,6 +14470,7 @@ func New(childStore store.Store) *RetryLayer { newStore.OAuthStore = &RetryLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &RetryLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &RetryLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &RetryLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} newStore.PostPriorityStore = &RetryLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &RetryLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &RetryLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} diff --git a/store/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index b45bc7c561..7efbfa899a 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -55,6 +55,7 @@ func genStore() *mocks.Store { mock.On("Webhook").Return(&mocks.WebhookStore{}) mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{}) mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) + mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) return mock } diff --git a/store/sqlstore/post_acknowledgements_store.go b/store/sqlstore/post_acknowledgements_store.go new file mode 100644 index 0000000000..d3de3addea --- /dev/null +++ b/store/sqlstore/post_acknowledgements_store.go @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +type SqlPostAcknowledgementStore struct { + *SqlStore +} + +func newSqlPostAcknowledgementStore(sqlStore *SqlStore) store.PostAcknowledgementStore { + return &SqlPostAcknowledgementStore{sqlStore} +} + +func (s *SqlPostAcknowledgementStore) Get(postID, userID string) (*model.PostAcknowledgement, error) { + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.Eq{"PostId": postID}, + sq.Eq{"UserId": userID}, + sq.NotEq{"AcknowledgedAt": 0}, + }) + + var acknowledgement model.PostAcknowledgement + err := s.GetReplicaX().GetBuilder(&acknowledgement, query) + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("PostAcknowledgement", postID) + } + + return nil, err + } + + return &acknowledgement, nil +} + +func (s *SqlPostAcknowledgementStore) Save(postID, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + if acknowledgedAt == 0 { + acknowledgedAt = model.GetMillis() + } + + acknowledgement := &model.PostAcknowledgement{ + UserId: userID, + PostId: postID, + AcknowledgedAt: acknowledgedAt, + } + + if err := acknowledgement.IsValid(); err != nil { + return nil, err + } + + query := s.getQueryBuilder(). + Insert("PostAcknowledgements"). + Columns("PostId", "UserId", "AcknowledgedAt"). + Values(acknowledgement.PostId, acknowledgement.UserId, acknowledgement.AcknowledgedAt) + + if s.DriverName() == model.DatabaseDriverMysql { + query = query.SuffixExpr(sq.Expr("ON DUPLICATE KEY UPDATE AcknowledgedAt = ?", acknowledgement.AcknowledgedAt)) + } else { + query = query.SuffixExpr(sq.Expr("ON CONFLICT (postid, userid) DO UPDATE SET AcknowledgedAt = ?", acknowledgement.AcknowledgedAt)) + } + + _, err := s.GetMasterX().ExecBuilder(query) + if err != nil { + return nil, err + } + + return acknowledgement, nil +} + +func (s *SqlPostAcknowledgementStore) Delete(ack *model.PostAcknowledgement) error { + query := s.getQueryBuilder(). + Update("PostAcknowledgements"). + Set("AcknowledgedAt", 0). + Where(sq.And{ + sq.Eq{"PostId": ack.PostId}, + sq.Eq{"UserId": ack.UserId}, + }) + + _, err := s.GetMasterX().ExecBuilder(query) + if err != nil { + return err + } + + return nil +} + +func (s *SqlPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + var acknowledgements []*model.PostAcknowledgement + + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.NotEq{"AcknowledgedAt": 0}, + sq.Eq{"PostId": postID}, + }) + + err := s.GetReplicaX().SelectBuilder(&acknowledgements, query) + if err != nil { + return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for postID=%s", postID) + } + + return acknowledgements, nil +} + +func (s *SqlPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + var acknowledgements []*model.PostAcknowledgement + + perPage := 200 + for i := 0; i < len(postIds); i += perPage { + j := i + perPage + if len(postIds) < j { + j = len(postIds) + } + + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.Eq{"PostId": postIds[i:j]}, + sq.NotEq{"AcknowledgedAt": 0}, + }) + + var acknowledgementsBatch []*model.PostAcknowledgement + err := s.GetReplicaX().SelectBuilder(&acknowledgementsBatch, query) + if err != nil { + return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for post list") + } + + acknowledgements = append(acknowledgements, acknowledgementsBatch...) + } + + return acknowledgements, nil +} diff --git a/store/sqlstore/post_acknowledgements_store_test.go b/store/sqlstore/post_acknowledgements_store_test.go new file mode 100644 index 0000000000..7de92cfef4 --- /dev/null +++ b/store/sqlstore/post_acknowledgements_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/store/storetest" +) + +func TestPostAcknowledgementsStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestPostAcknowledgementsStore) +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 0c5aad077f..6a0cdbc51a 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -110,6 +110,7 @@ type SqlStoreStores struct { sharedchannel store.SharedChannelStore notifyAdmin store.NotifyAdminStore postPriority store.PostPriorityStore + postAcknowledgement store.PostAcknowledgementStore } type SqlStore struct { @@ -216,6 +217,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.productNotices = newSqlProductNoticesStore(store) store.stores.notifyAdmin = newSqlNotifyAdminStore(store) store.stores.postPriority = newSqlPostPriorityStore(store) + store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -961,6 +963,10 @@ func (ss *SqlStore) PostPriority() store.PostPriorityStore { return ss.stores.postPriority } +func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { + return ss.stores.postAcknowledgement +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/store/store.go b/store/store.go index c99b1fa7c2..4b3f4a94ea 100644 --- a/store/store.go +++ b/store/store.go @@ -85,6 +85,7 @@ type Store interface { Context() context.Context NotifyAdmin() NotifyAdminStore PostPriority() PostPriorityStore + PostAcknowledgement() PostAcknowledgementStore } type RetentionPolicyStore interface { @@ -978,6 +979,14 @@ type PostPriorityStore interface { GetForPosts(ids []string) ([]*model.PostPriority, error) } +type PostAcknowledgementStore interface { + Get(postID, userID string) (*model.PostAcknowledgement, error) + GetForPost(postID string) ([]*model.PostAcknowledgement, error) + GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) + Save(postID, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) + Delete(acknowledgement *model.PostAcknowledgement) error +} + // ChannelSearchOpts contains options for searching channels. // // NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records. diff --git a/store/storetest/mocks/PostAcknowledgementStore.go b/store/storetest/mocks/PostAcknowledgementStore.go new file mode 100644 index 0000000000..ad130137da --- /dev/null +++ b/store/storetest/mocks/PostAcknowledgementStore.go @@ -0,0 +1,121 @@ +// Code generated by mockery v2.10.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v6/model" + mock "github.com/stretchr/testify/mock" +) + +// PostAcknowledgementStore is an autogenerated mock type for the PostAcknowledgementStore type +type PostAcknowledgementStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: acknowledgement +func (_m *PostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + ret := _m.Called(acknowledgement) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.PostAcknowledgement) error); ok { + r0 = rf(acknowledgement) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: postID, userID +func (_m *PostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + ret := _m.Called(postID, userID) + + var r0 *model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string, string) *model.PostAcknowledgement); ok { + r0 = rf(postID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(postID, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForPost provides a mock function with given fields: postID +func (_m *PostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + ret := _m.Called(postID) + + var r0 []*model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string) []*model.PostAcknowledgement); ok { + r0 = rf(postID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForPosts provides a mock function with given fields: postIds +func (_m *PostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + ret := _m.Called(postIds) + + var r0 []*model.PostAcknowledgement + if rf, ok := ret.Get(0).(func([]string) []*model.PostAcknowledgement); ok { + r0 = rf(postIds) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(postIds) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: postID, userID, acknowledgedAt +func (_m *PostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + ret := _m.Called(postID, userID, acknowledgedAt) + + var r0 *model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string, string, int64) *model.PostAcknowledgement); ok { + r0 = rf(postID, userID, acknowledgedAt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, int64) error); ok { + r1 = rf(postID, userID, acknowledgedAt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index 1d8e8ac326..cfa20ca980 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -475,6 +475,22 @@ func (_m *Store) Post() store.PostStore { return r0 } +// PostAcknowledgement provides a mock function with given fields: +func (_m *Store) PostAcknowledgement() store.PostAcknowledgementStore { + ret := _m.Called() + + var r0 store.PostAcknowledgementStore + if rf, ok := ret.Get(0).(func() store.PostAcknowledgementStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PostAcknowledgementStore) + } + } + + return r0 +} + // PostPriority provides a mock function with given fields: func (_m *Store) PostPriority() store.PostPriorityStore { ret := _m.Called() diff --git a/store/storetest/post_acknowledgements_store.go b/store/storetest/post_acknowledgements_store.go new file mode 100644 index 0000000000..ac751119bf --- /dev/null +++ b/store/storetest/post_acknowledgements_store.go @@ -0,0 +1,181 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/stretchr/testify/require" +) + +func TestPostAcknowledgementsStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("Save", func(t *testing.T) { testPostAcknowledgementsStoreSave(t, ss) }) + t.Run("GetForPost", func(t *testing.T) { testPostAcknowledgementsStoreGetForPost(t, ss) }) + t.Run("GetForPosts", func(t *testing.T) { testPostAcknowledgementsStoreGetForPosts(t, ss) }) +} + +func testPostAcknowledgementsStoreSave(t *testing.T, ss store.Store) { + userId1 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, err := ss.Post().Save(&p1) + require.NoError(t, err) + + t.Run("consecutive saves should just update the acknowledged at", func(t *testing.T) { + _, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + _, err = ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1}) + }) +} + +func testPostAcknowledgementsStoreGetForPost(t *testing.T, ss store.Store) { + userId1 := model.NewId() + userId2 := model.NewId() + userId3 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, err := ss.Post().Save(&p1) + require.NoError(t, err) + + t.Run("get acknowledgements for post", func(t *testing.T) { + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + ack2, err := ss.PostAcknowledgement().Save(p1.Id, userId2, 0) + require.NoError(t, err) + ack3, err := ss.PostAcknowledgement().Save(p1.Id, userId3, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2, ack3}) + + err = ss.PostAcknowledgement().Delete(ack1) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack2, ack3}) + + err = ss.PostAcknowledgement().Delete(ack2) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3}) + + err = ss.PostAcknowledgement().Delete(ack3) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.Empty(t, acknowledgements) + }) +} + +func testPostAcknowledgementsStoreGetForPosts(t *testing.T, ss store.Store) { + userId1 := model.NewId() + userId2 := model.NewId() + userId3 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + p2 := model.Post{} + p2.ChannelId = model.NewId() + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(""), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + t.Run("get acknowledgements for post", func(t *testing.T) { + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + ack2, err := ss.PostAcknowledgement().Save(p1.Id, userId2, 0) + require.NoError(t, err) + ack3, err := ss.PostAcknowledgement().Save(p2.Id, userId2, 0) + require.NoError(t, err) + ack4, err := ss.PostAcknowledgement().Save(p2.Id, userId3, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPosts([]string{p1.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2}) + + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3, ack4}) + + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2, ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack1) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack2, ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack2) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack3) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack4}) + + err = ss.PostAcknowledgement().Delete(ack4) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.Empty(t, acknowledgements) + }) +} diff --git a/store/storetest/store.go b/store/storetest/store.go index 4f785c0426..93a85d3ab1 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -57,6 +57,7 @@ type Store struct { context context.Context NotifyAdminStore mocks.NotifyAdminStore PostPriorityStore mocks.PostPriorityStore + PostAcknowledgementStore mocks.PostAcknowledgementStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -102,17 +103,20 @@ func (s *Store) Group() store.GroupStore { return &s.GroupStore func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMetadataStore } func (s *Store) SharedChannel() store.SharedChannelStore { return &s.SharedChannelStore } func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorityStore } -func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } -func (s *Store) Close() { /* do nothing */ } -func (s *Store) LockToMaster() { /* do nothing */ } -func (s *Store) UnlockFromMaster() { /* do nothing */ } -func (s *Store) DropAllTables() { /* do nothing */ } -func (s *Store) GetDbVersion(bool) (string, error) { return "", nil } -func (s *Store) GetInternalMasterDB() *sql.DB { return nil } -func (s *Store) GetInternalReplicaDB() *sql.DB { return nil } -func (s *Store) GetInternalReplicaDBs() []*sql.DB { return nil } -func (s *Store) RecycleDBConnections(time.Duration) {} -func (s *Store) GetDBSchemaVersion() (int, error) { return 1, nil } +func (s *Store) PostAcknowledgement() store.PostAcknowledgementStore { + return &s.PostAcknowledgementStore +} +func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } +func (s *Store) Close() { /* do nothing */ } +func (s *Store) LockToMaster() { /* do nothing */ } +func (s *Store) UnlockFromMaster() { /* do nothing */ } +func (s *Store) DropAllTables() { /* do nothing */ } +func (s *Store) GetDbVersion(bool) (string, error) { return "", nil } +func (s *Store) GetInternalMasterDB() *sql.DB { return nil } +func (s *Store) GetInternalReplicaDB() *sql.DB { return nil } +func (s *Store) GetInternalReplicaDBs() []*sql.DB { return nil } +func (s *Store) RecycleDBConnections(time.Duration) {} +func (s *Store) GetDBSchemaVersion() (int, error) { return 1, nil } func (s *Store) GetAppliedMigrations() ([]model.AppliedMigration, error) { return []model.AppliedMigration{}, nil } @@ -161,5 +165,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.SharedChannelStore, &s.NotifyAdminStore, &s.PostPriorityStore, + &s.PostAcknowledgementStore, ) } diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index ba5d2436e7..3395ab8b45 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -36,6 +36,7 @@ type TimerLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore @@ -131,6 +132,10 @@ func (s *TimerLayer) Post() store.PostStore { return s.PostStore } +func (s *TimerLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + func (s *TimerLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -305,6 +310,11 @@ type TimerLayerPostStore struct { Root *TimerLayer } +type TimerLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *TimerLayer +} + type TimerLayerPostPriorityStore struct { store.PostPriorityStore Root *TimerLayer @@ -5917,6 +5927,86 @@ func (s *TimerLayerPostStore) Update(newPost *model.Post, oldPost *model.Post) ( return result, err } +func (s *TimerLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + start := time.Now() + + err := s.PostAcknowledgementStore.Delete(acknowledgement) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.Get(postID, userID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.GetForPost(postID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.GetForPost", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.GetForPosts", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Save", success, elapsed) + } + return result, err +} + func (s *TimerLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { start := time.Now() @@ -11344,6 +11434,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.OAuthStore = &TimerLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &TimerLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &TimerLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &TimerLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} newStore.PostPriorityStore = &TimerLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &TimerLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &TimerLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} From 5e5769c4ee522ccc7a1e12c05932c21e538e3852 Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Thu, 24 Nov 2022 05:21:40 +0200 Subject: [PATCH 24/80] MM-45317: global drafts endpoints and ws events (#20614) * MM-23881: global drafts endpoints and ws events Adds endpoints: - create/update drafts - delete draft - get drafts Adds WS events: - draft_updated - draft_created - draft_deleted * Ordering and WS event name fixes * Adds PostID to the drafts table In the future the drafts will include edited posts, this commit adds the post id in the combined pkey of the table. * Fixes route for deleting a thread draft * Fixes failed checks * Fixes migrations * Fixes migration * Extract translation strings * Removes PostID since we won't sync editing posts * Fixes tests * Fixes i18n * Update migrations for global drafts * update branch with latest master changes * Add feature flag for global drafts * Set global drafts feature flag default to true * Added support for files in drafts * Fix failing i18n check * Added support for deleting files in drafts * Revert "Added support for deleting files in drafts" This reverts commit 45dfd04a760359de2e8814d652c9ef46daf994f6. * Triggering new test server * Add config setting 'AllowSyncedDrafts' for syncing drafts with server * Triggering new test server * Triggering new test server * Add guard for config setting and add initial tests * Fix i18n and lint errors * Triggering new test server * Add tests for drafts * fix lint issues * Add tests for model/draft * Triggering new test server * Triggering new test server * Trigger new test server * Address PR comments * Change left join to regular join in GetDraftsForUser * Fix broken test Maybe consider adding an inclDeleted field if we want to get deleted drafts in the future * fix translations * Add store tests for drafts * fix test naming * remove comment * update migrations * set feature flag default to false * update migrations Co-authored-by: Mylon Suren Co-authored-by: Mattermod --- api4/api.go | 5 + api4/drafts.go | 134 ++++++ api4/drafts_test.go | 232 ++++++++++ app/app_iface.go | 6 + app/draft.go | 211 +++++++++ app/draft_test.go | 438 ++++++++++++++++++ app/opentracing/opentracing_layer.go | 132 ++++++ config/client.go | 1 + db/migrations/migrations.list | 4 + .../mysql/000099_create_drafts.down.sql | 1 + .../mysql/000099_create_drafts.up.sql | 12 + .../postgres/000099_create_drafts.down.sql | 1 + .../postgres/000099_create_drafts.up.sql | 12 + i18n/en.json | 68 +++ model/client4.go | 58 +++ model/config.go | 5 + model/draft.go | 101 ++++ model/draft_test.go | 80 ++++ model/feature_flags.go | 3 + model/websocket_message.go | 3 + services/telemetry/telemetry.go | 1 + store/opentracinglayer/opentracinglayer.go | 101 ++++ store/retrylayer/retrylayer.go | 116 +++++ store/retrylayer/retrylayer_test.go | 1 + store/sqlstore/draft_store.go | 240 ++++++++++ store/sqlstore/draft_store_test.go | 350 ++++++++++++++ store/sqlstore/store.go | 6 + store/store.go | 9 + store/storetest/draft_store.go | 13 + store/storetest/mocks/DraftStore.go | 121 +++++ store/storetest/mocks/Store.go | 16 + store/storetest/store.go | 3 + store/timerlayer/timerlayer.go | 91 ++++ 33 files changed, 2575 insertions(+) create mode 100644 api4/drafts.go create mode 100644 api4/drafts_test.go create mode 100644 app/draft.go create mode 100644 app/draft_test.go create mode 100644 db/migrations/mysql/000099_create_drafts.down.sql create mode 100644 db/migrations/mysql/000099_create_drafts.up.sql create mode 100644 db/migrations/postgres/000099_create_drafts.down.sql create mode 100644 db/migrations/postgres/000099_create_drafts.up.sql create mode 100644 model/draft.go create mode 100644 model/draft_test.go create mode 100644 store/sqlstore/draft_store.go create mode 100644 store/sqlstore/draft_store_test.go create mode 100644 store/storetest/draft_store.go create mode 100644 store/storetest/mocks/DraftStore.go diff --git a/api4/api.go b/api4/api.go index 28114af4fe..ea4b72119a 100644 --- a/api4/api.go +++ b/api4/api.go @@ -139,6 +139,8 @@ type Routes struct { InsightsForUser *mux.Router // 'api/v4/users/me/top' Usage *mux.Router // 'api/v4/usage' + + Drafts *mux.Router // 'api/v4/drafts' } type API struct { @@ -265,6 +267,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter() + api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -308,6 +312,7 @@ func Init(srv *app.Server) (*API, error) { api.InitExport() api.InitInsights() api.InitUsage() + api.InitDrafts() if err := api.InitGraphQL(); err != nil { return nil, err } diff --git a/api4/drafts.go b/api4/drafts.go new file mode 100644 index 0000000000..2dd6210c72 --- /dev/null +++ b/api4/drafts.go @@ -0,0 +1,134 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +func (api *API) InitDrafts() { + api.BaseRoutes.Drafts.Handle("", api.APISessionRequired(upsertDraft)).Methods("POST") + + api.BaseRoutes.TeamForUser.Handle("/drafts", api.APISessionRequired(getDrafts)).Methods("GET") + + api.BaseRoutes.ChannelForUser.Handle("/drafts/{thread_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteDraft)).Methods("DELETE") + api.BaseRoutes.ChannelForUser.Handle("/drafts", api.APISessionRequired(deleteDraft)).Methods("DELETE") +} + +func upsertDraft(c *Context, w http.ResponseWriter, r *http.Request) { + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("upsertDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + var draft model.Draft + if jsonErr := json.NewDecoder(r.Body).Decode(&draft); jsonErr != nil { + c.SetInvalidParam("draft") + return + } + + draft.DeleteAt = 0 + draft.UserId = c.AppContext.Session().UserId + connectionID := r.Header.Get(model.ConnectionId) + + hasPermission := false + + if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost) { + hasPermission = true + } else if channel, err := c.App.GetChannel(c.AppContext, draft.ChannelId); err == nil { + // Temporary permission check method until advanced permissions, please do not copy + if channel.Type == model.ChannelTypeOpen && c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePostPublic) { + hasPermission = true + } + } + + if !hasPermission { + c.SetPermissionError(model.PermissionCreatePost) + return + } + + dt, err := c.App.UpsertDraft(c.AppContext, &draft, connectionID) + if err != nil { + c.Err = err + return + } + + w.WriteHeader(http.StatusCreated) + + if err := json.NewEncoder(w).Encode(dt); err != nil { + mlog.Warn("Error while writing response", mlog.Err(err)) + } +} + +func getDrafts(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Err != nil { + return + } + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("getDrafts", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + hasPermission := false + + if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + hasPermission = true + } + + if !hasPermission { + c.SetPermissionError(model.PermissionCreatePost) + return + } + + drafts, err := c.App.GetDraftsForUser(c.AppContext.Session().UserId, c.Params.TeamId) + if err != nil { + c.Err = err + return + } + + if err := json.NewEncoder(w).Encode(drafts); err != nil { + mlog.Warn("Error while writing response", mlog.Err(err)) + } +} + +func deleteDraft(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Err != nil { + return + } + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("deleteDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + rootID := "" + + connectionID := r.Header.Get(model.ConnectionId) + + if c.Params.ThreadId != "" { + rootID = c.Params.ThreadId + } + + userID := c.AppContext.Session().UserId + channelID := c.Params.ChannelId + + draft, err := c.App.GetDraft(userID, channelID, rootID) + if err != nil || c.AppContext.Session().UserId != draft.UserId { + c.SetPermissionError(model.PermissionDeletePost) + return + } + + if _, err := c.App.DeleteDraft(userID, channelID, rootID, connectionID); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} diff --git a/api4/drafts_test.go b/api4/drafts_test.go new file mode 100644 index 0000000000..879d78bbb8 --- /dev/null +++ b/api4/drafts_test.go @@ -0,0 +1,232 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/utils/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpsertDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + // set config + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel := th.BasicChannel + user := th.BasicUser + + draft := &model.Draft{ + CreateAt: 12345, + UpdateAt: 12345, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "original", + } + + // try to upsert draft + draftResp, _, err := client.UpsertDraft(draft) + require.NoError(t, err) + + assert.Equal(t, draft.UserId, draftResp.UserId) + assert.Equal(t, draft.Message, draftResp.Message) + assert.Equal(t, draft.ChannelId, draftResp.ChannelId) + + // upload file + sent, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) + + fileResp, _, err := client.UploadFile(sent, channel.Id, "test.png") + require.NoError(t, err) + + draftWithFiles := draft + draftWithFiles.FileIds = []string{fileResp.FileInfos[0].Id} + + // try to upsert draft with file + draftResp, _, err = client.UpsertDraft(draftWithFiles) + require.NoError(t, err) + + assert.Equal(t, draftWithFiles.UserId, draftResp.UserId) + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + + // try to upsert draft for invalid channel + draftInvalidChannel := draft + draftInvalidChannel.ChannelId = "12345" + + _, resp, err := client.UpsertDraft(draft) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // try to upsert draft without config setting set to true + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + _, resp, err = client.UpsertDraft(draft) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) +} + +func TestGetDrafts(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel1 := th.BasicChannel + channel2 := th.BasicChannel2 + user := th.BasicUser + team := th.BasicTeam + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel1.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 11111, + UpdateAt: 32222, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + // upsert draft1 + _, _, err := client.UpsertDraft(draft1) + require.NoError(t, err) + + // upsert draft2 + _, _, err = client.UpsertDraft(draft2) + require.NoError(t, err) + + // try to get drafts + draftResp, _, err := client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.UserId, draftResp[1].UserId) + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + + assert.Len(t, draftResp, 2) + + // try to get drafts on invalid team + _, resp, err := client.GetDrafts(user.Id, "12345") + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // try to get drafts when config is turned off + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + _, resp, err = client.GetDrafts(user.Id, team.Id) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) +} + +func TestDeleteDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel1 := th.BasicChannel + channel2 := th.BasicChannel2 + user := th.BasicUser + team := th.BasicTeam + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel1.Id, + Message: "draft1", + RootId: "", + } + + draft2 := &model.Draft{ + CreateAt: 11111, + UpdateAt: 32222, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + RootId: model.NewId(), + } + + // upsert draft1 + _, _, err := client.UpsertDraft(draft1) + require.NoError(t, err) + + // upsert draft2 + _, _, err = client.UpsertDraft(draft2) + require.NoError(t, err) + + //get drafts + draftResp, _, err := client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.UserId, draftResp[1].UserId) + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + + // try to delete draft1 + _, _, err = client.DeleteDraft(user.Id, channel1.Id, draft1.RootId) + require.NoError(t, err) + + //get drafts + draftResp, _, err = client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + assert.Len(t, draftResp, 1) +} diff --git a/app/app_iface.go b/app/app_iface.go index 352ece5283..e3c02a0d80 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -474,6 +474,7 @@ type AppIface interface { CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) CreateCommand(cmd *model.Command) (*model.Command, *model.AppError) CreateCommandWebhook(commandID string, args *model.CommandArgs) (*model.CommandWebhook, *model.AppError) + CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) CreateGroup(group *model.Group) (*model.Group, *model.AppError) CreateGroupChannel(c request.CTX, userIDs []string, creatorId string) (*model.Channel, *model.AppError) @@ -515,6 +516,7 @@ type AppIface interface { DeleteBrandImage() *model.AppError DeleteChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError DeleteCommand(commandID string) *model.AppError + DeleteDraft(userID, channelID, rootID, connectionID string) (*model.Draft, *model.AppError) DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError DeleteEphemeralPost(userID, postID string) DeleteExport(name string) *model.AppError @@ -626,6 +628,8 @@ type AppIface interface { GetCustomStatus(userID string) (*model.CustomStatus, *model.AppError) GetDefaultProfileImage(user *model.User) ([]byte, *model.AppError) GetDeletedChannels(c request.CTX, teamID string, offset int, limit int, userID string) (model.ChannelList, *model.AppError) + GetDraft(userID, channelID, rootID string) (*model.Draft, *model.AppError) + GetDraftsForUser(userID, teamID string) ([]*model.Draft, *model.AppError) GetEmoji(c request.CTX, emojiId string) (*model.Emoji, *model.AppError) GetEmojiByName(c request.CTX, emojiName string) (*model.Emoji, *model.AppError) GetEmojiImage(c request.CTX, emojiId string) ([]byte, string, *model.AppError) @@ -1096,6 +1100,7 @@ type AppIface interface { UpdateChannelPrivacy(c request.CTX, oldChannel *model.Channel, user *model.User) (*model.Channel, *model.AppError) UpdateCommand(oldCmd, updatedCmd *model.Command) (*model.Command, *model.AppError) UpdateConfig(f func(*model.Config)) + UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post UpdateExpiredDNDStatuses() ([]*model.Status, error) UpdateGroup(group *model.Group) (*model.Group, *model.AppError) @@ -1141,6 +1146,7 @@ type AppIface interface { UpdateUserRolesWithUser(c request.CTX, user *model.User, newRoles string, sendWebSocketEvent bool) (*model.User, *model.AppError) UploadData(c *request.Context, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) UploadEmojiImage(c request.CTX, id string, imageData *multipart.FileHeader) *model.AppError + UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) diff --git a/app/draft.go b/app/draft.go new file mode 100644 index 0000000000..86786c909f --- /dev/null +++ b/app/draft.go @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +func (a *App) GetDraft(userID, channelID, rootID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("GetDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + draft, err := a.Srv().Store().Draft().Get(userID, channelID, rootID) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, nfErr.Error(), http.StatusNotFound) + default: + return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + return draft, nil +} + +func (a *App) UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("UpsertDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + dt, dErr := a.Srv().Store().Draft().Get(draft.UserId, draft.ChannelId, draft.RootId) + var notFoundErr *store.ErrNotFound + if dErr != nil && !errors.As(dErr, ¬FoundErr) { + return nil, model.NewAppError("UpsertDraft", "app.select_error", nil, dErr.Error(), http.StatusInternalServerError) + } + + var err *model.AppError + if dt == nil { + dt, err = a.CreateDraft(c, draft, connectionID) + if err != nil { + return nil, err + } + } else { + dt, err = a.UpdateDraft(c, draft, connectionID) + if err != nil { + return nil, err + } + } + + return dt, nil +} + +func (a *App) CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("CreateDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + // Check that channel exists and has not been deleted + channel, errCh := a.Srv().Store().Channel().Get(draft.ChannelId, true) + if errCh != nil { + err := model.NewAppError("CreateDraft", "api.context.invalid_param.app_error", map[string]interface{}{"Name": "draft.channel_id"}, errCh.Error(), http.StatusBadRequest) + return nil, err + } + + if channel.DeleteAt != 0 { + err := model.NewAppError("CreateDraft", "api.draft.create_draft.can_not_draft_to_deleted.error", nil, "", http.StatusBadRequest) + return nil, err + } + + _, nErr := a.Srv().Store().User().Get(context.Background(), draft.UserId) + if nErr != nil { + return nil, model.NewAppError("CreateDraft", "app.user.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt, nErr := a.Srv().Store().Draft().Save(draft) + if nErr != nil { + return nil, model.NewAppError("CreateDraft", "app.draft.save.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt = a.prepareDraftWithFileInfos(draft.UserId, dt) + + message := model.NewWebSocketEvent(model.WebsocketEventDraftCreated, "", dt.ChannelId, dt.UserId, nil, connectionID) + draftJSON, jsonErr := json.Marshal(dt) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON", mlog.Err(jsonErr)) + } + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return dt, nil +} + +func (a *App) UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts { + return nil, model.NewAppError("UpsertDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + // Check that channel exists and has not been deleted + channel, errCh := a.Srv().Store().Channel().Get(draft.ChannelId, true) + if errCh != nil { + err := model.NewAppError("UpdateDraft", "api.context.invalid_param.app_error", map[string]interface{}{"Name": "draft.channel_id"}, errCh.Error(), http.StatusBadRequest) + return nil, err + } + + if channel.DeleteAt != 0 { + err := model.NewAppError("UpdateDraft", "api.draft.create_draft.can_not_draft_to_deleted.error", nil, "", http.StatusBadRequest) + return nil, err + } + + _, nErr := a.Srv().Store().User().Get(context.Background(), draft.UserId) + if nErr != nil { + return nil, model.NewAppError("UpdateDraft", "app.user.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt, nErr := a.Srv().Store().Draft().Update(draft) + if nErr != nil { + return nil, model.NewAppError("UpdateDraft", "app.draft.update.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt = a.prepareDraftWithFileInfos(draft.UserId, dt) + + message := model.NewWebSocketEvent(model.WebsocketEventDraftUpdated, "", draft.ChannelId, draft.UserId, nil, connectionID) + draftJSON, jsonErr := json.Marshal(dt) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON", mlog.Err(jsonErr)) + } + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return dt, nil +} + +func (a *App) GetDraftsForUser(userID, teamID string) ([]*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("GetDraftsForUser", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + drafts, err := a.Srv().Store().Draft().GetDraftsForUser(userID, teamID) + + if err != nil { + return nil, model.NewAppError("GetDraftsForUser", "app.draft.get_drafts.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + for _, draft := range drafts { + a.prepareDraftWithFileInfos(userID, draft) + } + return drafts, nil +} + +func (a *App) prepareDraftWithFileInfos(userID string, draft *model.Draft) *model.Draft { + if fileInfos, err := a.getFileInfosForDraft(draft); err != nil { + mlog.Error("Failed to get files for a user's drafts", mlog.String("user_id", userID), mlog.Err(err)) + } else { + draft.Metadata = &model.PostMetadata{} + draft.Metadata.Files = fileInfos + } + + return draft +} + +func (a *App) getFileInfosForDraft(draft *model.Draft) ([]*model.FileInfo, *model.AppError) { + if len(draft.FileIds) == 0 { + return nil, nil + } + + fileInfos, err := a.Srv().Store().FileInfo().GetByIds(draft.FileIds) + if err != nil { + return nil, model.NewAppError("GetFileInfosForDraft", "app.draft.get_for_draft.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + a.generateMiniPreviewForInfos(fileInfos) + + return fileInfos, nil +} + +func (a *App) DeleteDraft(userID, channelID, rootID, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("DeleteDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + draft, nErr := a.Srv().Store().Draft().Get(userID, channelID, rootID) + if nErr != nil { + return nil, model.NewAppError("DeleteDraft", "app.draft.get.app_error", nil, nErr.Error(), http.StatusBadRequest) + } + + if err := a.Srv().Store().Draft().Delete(userID, channelID, rootID); err != nil { + return nil, model.NewAppError("DeleteDraft", "app.draft.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + draftJSON, jsonErr := json.Marshal(draft) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON") + } + + message := model.NewWebSocketEvent(model.WebsocketEventDraftDeleted, "", draft.ChannelId, draft.UserId, nil, connectionID) + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return draft, nil +} diff --git a/app/draft_test.go b/app/draft_test.go new file mode 100644 index 0000000000..fc543de443 --- /dev/null +++ b/app/draft_test.go @@ -0,0 +1,438 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/utils/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft", + } + + _, upsertDraftErr := th.App.UpsertDraft(th.Context, draft, "") + assert.Nil(t, upsertDraftErr) + + t.Run("get draft", func(t *testing.T) { + draftResp, err := th.App.GetDraft(user.Id, channel.Id, "") + assert.Nil(t, err) + + assert.Equal(t, draft.Message, draftResp.Message) + assert.Equal(t, draft.ChannelId, draftResp.ChannelId) + }) + + t.Run("get draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.GetDraft(user.Id, channel.Id, "") + assert.NotNil(t, err) + }) +} + +func TestUpsertDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00002, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft2", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("upsert draft", func(t *testing.T) { + draftResp, err := th.App.UpsertDraft(th.Context, draft2, "") + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + assert.Equal(t, draft2.CreateAt, draftResp.CreateAt) + + assert.NotEqual(t, draft1.UpdateAt, draftResp.UpdateAt) + }) + + t.Run("upsert draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.UpsertDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestCreateDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + channel2 := th.CreateChannel(th.Context, th.BasicTeam) + th.AddUserToChannel(user, channel2) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("create draft", func(t *testing.T) { + draftResp, err := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + }) + + t.Run("create draft with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, uploadFileErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, uploadFileErr) + + draftWithFiles := draft2 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, err := th.App.CreateDraft(th.Context, draftWithFiles, "") + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + }) + + t.Run("create draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.CreateDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestUpdateDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00002, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft2", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("update draft", func(t *testing.T) { + draftResp, err := th.App.UpdateDraft(th.Context, draft2, "") + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + + assert.NotEqual(t, draft1.UpdateAt, draftResp.UpdateAt) + }) + + t.Run("update draft with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, uploadFileErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, uploadFileErr) + + draftWithFiles := draft1 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, err := th.App.UpdateDraft(th.Context, draft1, "") + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + }) + + t.Run("create draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.UpdateDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestGetDraftsForUser(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + channel2 := th.CreateChannel(th.Context, th.BasicTeam) + th.AddUserToChannel(user, channel2) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, createDraftErr1 := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr1) + + _, createDraftErr2 := th.App.CreateDraft(th.Context, draft2, "") + assert.Nil(t, createDraftErr2) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + }) + + t.Run("get drafts with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, updateDraftErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, updateDraftErr) + + draftWithFiles := draft1 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, updateDraftErr := th.App.UpdateDraft(th.Context, draft1, "") + assert.Nil(t, updateDraftErr) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + + draftsWithFilesResp, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftsWithFilesResp[0].Message) + assert.Equal(t, draftWithFiles.ChannelId, draftsWithFilesResp[0].ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftsWithFilesResp[0].FileIds) + + assert.Equal(t, fileResp.Name, draftsWithFilesResp[0].Metadata.Files[0].Name) + + assert.Len(t, draftsWithFilesResp, 2) + }) + + t.Run("get drafts feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.NotNil(t, err) + }) +} + +func TestDeleteDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("delete draft", func(t *testing.T) { + draftResp, err := th.App.DeleteDraft(user.Id, channel.Id, "", "") + assert.Nil(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + }) + + t.Run("get drafts feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.DeleteDraft(user.Id, channel.Id, "", "") + assert.NotNil(t, err) + }) +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index f4ac4fbb51..63a3389735 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -2007,6 +2007,28 @@ func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, param return resultVar0 } +func (a *OpenTracingAppLayer) CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.CreateDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateEmoji") @@ -2992,6 +3014,28 @@ func (a *OpenTracingAppLayer) DeleteCommand(commandID string) *model.AppError { return resultVar0 } +func (a *OpenTracingAppLayer) DeleteDraft(userID string, channelID string, rootID string, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.DeleteDraft(userID, channelID, rootID, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteEmoji") @@ -5848,6 +5892,50 @@ func (a *OpenTracingAppLayer) GetDeletedChannels(c request.CTX, teamID string, o return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetDraft(userID string, channelID string, rootID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetDraft(userID, channelID, rootID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetDraftsForUser") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetDraftsForUser(userID, teamID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetEmoji(c request.CTX, emojiId string) (*model.Emoji, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetEmoji") @@ -16968,6 +17056,28 @@ func (a *OpenTracingAppLayer) UpdateDNDStatusOfUsers() { a.app.UpdateDNDStatusOfUsers() } +func (a *OpenTracingAppLayer) UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.UpdateDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateEphemeralPost") @@ -18057,6 +18167,28 @@ func (a *OpenTracingAppLayer) UploadFileX(c *request.Context, channelID string, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpsertDraft") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.UpsertDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpsertGroupMember") diff --git a/config/client.go b/config/client.go index c6e650578b..7d05dda917 100644 --- a/config/client.go +++ b/config/client.go @@ -132,6 +132,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["EnableCustomGroups"] = "false" props["InsightsEnabled"] = strconv.FormatBool(c.FeatureFlags.InsightsEnabled) props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority) + props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts) if license != nil { props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer) diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index ba9b1103ba..5ce8502a01 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -196,6 +196,8 @@ db/migrations/mysql/000097_create_posts_priority.down.sql db/migrations/mysql/000097_create_posts_priority.up.sql db/migrations/mysql/000098_create_post_acknowledgements.down.sql db/migrations/mysql/000098_create_post_acknowledgements.up.sql +db/migrations/mysql/000099_create_drafts.down.sql +db/migrations/mysql/000099_create_drafts.up.sql db/migrations/postgres/000001_create_teams.down.sql db/migrations/postgres/000001_create_teams.up.sql db/migrations/postgres/000002_create_team_members.down.sql @@ -392,3 +394,5 @@ db/migrations/postgres/000097_create_posts_priority.down.sql db/migrations/postgres/000097_create_posts_priority.up.sql db/migrations/postgres/000098_create_post_acknowledgements.down.sql db/migrations/postgres/000098_create_post_acknowledgements.up.sql +db/migrations/postgres/000099_create_drafts.down.sql +db/migrations/postgres/000099_create_drafts.up.sql diff --git a/db/migrations/mysql/000099_create_drafts.down.sql b/db/migrations/mysql/000099_create_drafts.down.sql new file mode 100644 index 0000000000..02a72a656f --- /dev/null +++ b/db/migrations/mysql/000099_create_drafts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS Drafts; diff --git a/db/migrations/mysql/000099_create_drafts.up.sql b/db/migrations/mysql/000099_create_drafts.up.sql new file mode 100644 index 0000000000..d580fcb70e --- /dev/null +++ b/db/migrations/mysql/000099_create_drafts.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS Drafts ( + CreateAt bigint(20) DEFAULT NULL, + UpdateAt bigint(20) DEFAULT NULL, + DeleteAt bigint(20) DEFAULT NULL, + UserId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + RootId varchar(26) DEFAULT '', + Message text, + Props text, + FileIds text, + PRIMARY KEY (UserId, ChannelId, RootId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/db/migrations/postgres/000099_create_drafts.down.sql b/db/migrations/postgres/000099_create_drafts.down.sql new file mode 100644 index 0000000000..d8c5bb3d4f --- /dev/null +++ b/db/migrations/postgres/000099_create_drafts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS drafts; diff --git a/db/migrations/postgres/000099_create_drafts.up.sql b/db/migrations/postgres/000099_create_drafts.up.sql new file mode 100644 index 0000000000..6896cf1b9c --- /dev/null +++ b/db/migrations/postgres/000099_create_drafts.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS drafts ( + createat bigint, + updateat bigint, + deleteat bigint, + userid VARCHAR(26), + channelid VARCHAR(26), + rootid VARCHAR(26) DEFAULT '', + message VARCHAR(65535), + props VARCHAR(8000), + fileids VARCHAR(300), + PRIMARY KEY (userid, channelid, rootid) +); diff --git a/i18n/en.json b/i18n/en.json index d7b4627505..4087b69144 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1642,6 +1642,14 @@ "id": "api.custom_status.set_custom_statuses.update.app_error", "translation": "Failed to update the custom status. Please add either emoji or custom text status or both." }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Can not save draft to deleted channel" + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Drafts feature is disabled." + }, { "id": "api.elasticsearch.test_elasticsearch_settings_nil.app_error", "translation": "Elasticsearch settings has unset values." @@ -4959,6 +4967,34 @@ "id": "app.custom_group.unique_name", "translation": "group name is not unique" }, + { + "id": "app.draft.delete.app_error", + "translation": "Unable to delete the Draft." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Drafts feature is disabled." + }, + { + "id": "app.draft.get.app_error", + "translation": "Unable to get the Draft." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Unable to get user's Drafts." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Unable to get files for Draft." + }, + { + "id": "app.draft.save.app_error", + "translation": "Unable to save the Draft." + }, + { + "id": "app.draft.update.app_error", + "translation": "Unable to update the Draft." + }, { "id": "app.email.no_rate_limiter.app_error", "translation": "Rate limiter is not set up." @@ -8627,6 +8663,38 @@ "id": "model.config.is_valid.write_timeout.app_error", "translation": "Invalid value for write timeout." }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Invalid channel id." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Create at must be a valid time." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Invalid file ids." + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "Invalid message." + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Invalid props." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Invalid root id." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "Update at must be a valid time." + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Invalid user id." + }, { "id": "model.emoji.create_at.app_error", "translation": "Create at must be a valid time." diff --git a/model/client4.go b/model/client4.go index b05895859e..1057d0c55b 100644 --- a/model/client4.go +++ b/model/client4.go @@ -42,6 +42,7 @@ const ( StatusFail = "FAIL" StatusUnhealthy = "UNHEALTHY" StatusRemove = "REMOVE" + ConnectionId = "Connection-Id" ClientDir = "client" @@ -433,6 +434,10 @@ func (c *Client4) commandMoveRoute(commandId string) string { return fmt.Sprintf(c.commandsRoute()+"/%v/move", commandId) } +func (c *Client4) draftsRoute() string { + return "/drafts" +} + func (c *Client4) emojisRoute() string { return "/emoji" } @@ -6229,6 +6234,59 @@ func (c *Client4) GetChannelPoliciesForUser(userID string, offset, limit int) (* return &channels, BuildResponse(r), nil } +// Drafts Sections + +// UpsertDraft will create a new draft or update a draft if it already exists +func (c *Client4) UpsertDraft(draft *Draft) (*Draft, *Response, error) { + buf, err := json.Marshal(draft) + if err != nil { + return nil, nil, NewAppError("UpsertDraft", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(c.draftsRoute(), buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var df Draft + err = json.NewDecoder(r.Body).Decode(&df) + if err != nil { + return nil, nil, NewAppError("UpsertDraft", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &df, BuildResponse(r), err +} + +// GetDrafts will get all drafts for a user +func (c *Client4) GetDrafts(userId, teamId string) ([]*Draft, *Response, error) { + r, err := c.DoAPIGet(c.userRoute(userId)+c.teamRoute(teamId)+"/drafts", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var drafts []*Draft + err = json.NewDecoder(r.Body).Decode(&drafts) + if err != nil { + return nil, nil, NewAppError("GetDrafts", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return drafts, BuildResponse(r), nil +} + +func (c *Client4) DeleteDraft(userId, channelId, rootId string) (*Draft, *Response, error) { + r, err := c.DoAPIDelete(c.userRoute(userId) + c.channelRoute(channelId) + "/drafts") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var df *Draft + err = json.NewDecoder(r.Body).Decode(&df) + if err != nil { + return nil, BuildResponse(r), NewAppError("DeleteDraft", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return df, BuildResponse(r), nil +} + // Commands Section // CreateCommand will create a new command if the user have the right permissions. diff --git a/model/config.go b/model/config.go index a538f7032e..dbb39a987c 100644 --- a/model/config.go +++ b/model/config.go @@ -383,6 +383,7 @@ type ServiceSettings struct { CollapsedThreads *string `access:"experimental_features"` ManagedResourcePaths *string `access:"environment_web_server,write_restrictable,cloud_restrictable"` EnableCustomGroups *bool `access:"site_users_and_teams"` + AllowSyncedDrafts *bool `access:"site_posts"` } func (s *ServiceSettings) SetDefaults(isUpdate bool) { @@ -847,6 +848,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.PostPriority == nil { s.PostPriority = NewBool(true) } + + if s.AllowSyncedDrafts == nil { + s.AllowSyncedDrafts = NewBool(true) + } } type ClusterSettings struct { diff --git a/model/draft.go b/model/draft.go new file mode 100644 index 0000000000..e683959b1b --- /dev/null +++ b/model/draft.go @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/http" + "sync" + "unicode/utf8" +) + +type Draft struct { + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + RootId string `json:"root_id"` + + Message string `json:"message"` + + propsMu sync.RWMutex `db:"-"` // Unexported mutex used to guard Draft.Props. + Props StringInterface `json:"props"` // Deprecated: use GetProps() + FileIds StringArray `json:"file_ids,omitempty"` + Metadata *PostMetadata `json:"metadata,omitempty"` +} + +func (o *Draft) IsValid(maxDraftSize int) *AppError { + if o.CreateAt == 0 { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.create_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if o.UpdateAt == 0 { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.update_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if !IsValidId(o.UserId) { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.user_id.app_error", nil, "", http.StatusBadRequest) + } + + if !IsValidId(o.ChannelId) { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest) + } + + if !(IsValidId(o.RootId) || o.RootId == "") { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.root_id.app_error", nil, "", http.StatusBadRequest) + } + + if utf8.RuneCountInString(o.Message) > maxDraftSize { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.msg.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if utf8.RuneCountInString(ArrayToJSON(o.FileIds)) > PostFileidsMaxRunes { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.file_ids.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if utf8.RuneCountInString(StringInterfaceToJSON(o.GetProps())) > PostPropsMaxRunes { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.props.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + return nil +} + +func (o *Draft) SetProps(props StringInterface) { + o.propsMu.Lock() + defer o.propsMu.Unlock() + o.Props = props +} + +func (o *Draft) GetProps() StringInterface { + o.propsMu.RLock() + defer o.propsMu.RUnlock() + return o.Props +} + +func (o *Draft) PreSave() { + if o.CreateAt == 0 { + o.CreateAt = GetMillis() + } + + o.UpdateAt = o.CreateAt + o.PreCommit() +} + +func (o *Draft) PreCommit() { + if o.GetProps() == nil { + o.SetProps(make(map[string]interface{})) + } + + if o.FileIds == nil { + o.FileIds = []string{} + } + + // There's a rare bug where the client sends up duplicate FileIds so protect against that + o.FileIds = RemoveDuplicateStrings(o.FileIds) +} + +func (o *Draft) PreUpdate() { + o.UpdateAt = GetMillis() + o.PreCommit() +} diff --git a/model/draft_test.go b/model/draft_test.go new file mode 100644 index 0000000000..2e931c31dd --- /dev/null +++ b/model/draft_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDraftIsValid(t *testing.T) { + o := Draft{} + maxDraftSize := 10000 + + err := o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.CreateAt = GetMillis() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.UpdateAt = GetMillis() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.UserId = NewId() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.ChannelId = NewId() + o.RootId = "123" + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.RootId = "" + + o.Message = strings.Repeat("0", maxDraftSize+1) + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.Message = strings.Repeat("0", maxDraftSize) + err = o.IsValid(maxDraftSize) + assert.Nil(t, err) + + o.Message = "test" + err = o.IsValid(maxDraftSize) + assert.Nil(t, err) + + o.FileIds = StringArray{strings.Repeat("0", maxDraftSize+1)} + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) +} + +func TestDraftPreSave(t *testing.T) { + o := Draft{Message: "test"} + o.PreSave() + + assert.NotEqual(t, 0, o.CreateAt) + + past := GetMillis() - 1 + o = Draft{Message: "test", CreateAt: past} + o.PreSave() + + assert.LessOrEqual(t, o.CreateAt, past) +} + +func TestDraftPreUpdate(t *testing.T) { + o := Draft{Message: "test"} + o.PreUpdate() + + assert.NotEqual(t, 0, o.UpdateAt) + + past := GetMillis() - 1 + o = Draft{Message: "test", UpdateAt: past} + o.PreSave() + + assert.GreaterOrEqual(t, o.UpdateAt, past) +} diff --git a/model/feature_flags.go b/model/feature_flags.go index 29d4c015fd..9331177c57 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -79,6 +79,8 @@ type FeatureFlags struct { ReduceOnBoardingTaskList bool ThreadsEverywhere bool + + GlobalDrafts bool } func (f *FeatureFlags) SetDefaults() { @@ -109,6 +111,7 @@ func (f *FeatureFlags) SetDefaults() { f.AnnualSubscription = false f.ReduceOnBoardingTaskList = false f.ThreadsEverywhere = false + f.GlobalDrafts = false } func (f *FeatureFlags) Plugins() map[string]string { diff --git a/model/websocket_message.go b/model/websocket_message.go index 8cd2ad1961..1ad2a3b8b0 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -76,6 +76,9 @@ const ( WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadReadChanged = "thread_read_changed" WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received" + WebsocketEventDraftCreated = "draft_created" + WebsocketEventDraftUpdated = "draft_updated" + WebsocketEventDraftDeleted = "draft_deleted" WebsocketEventAcknowledgementAdded = "post_acknowledgement_added" WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed" ) diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index 3e2bba1566..c72759b128 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -450,6 +450,7 @@ func (ts *TelemetryService) trackConfig() { "restrict_link_previews": isDefault(*cfg.ServiceSettings.RestrictLinkPreviews, ""), "enable_custom_groups": *cfg.ServiceSettings.EnableCustomGroups, "post_priority": *cfg.ServiceSettings.PostPriority, + "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, }) ts.SendTelemetry(TrackConfigTeam, map[string]any{ diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 38e136d493..61d30cf909 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -27,6 +27,7 @@ type OpenTracingLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -93,6 +94,10 @@ func (s *OpenTracingLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *OpenTracingLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *OpenTracingLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -261,6 +266,11 @@ type OpenTracingLayerComplianceStore struct { Root *OpenTracingLayer } +type OpenTracingLayerDraftStore struct { + store.DraftStore + Root *OpenTracingLayer +} + type OpenTracingLayerEmojiStore struct { store.EmojiStore Root *OpenTracingLayer @@ -3228,6 +3238,96 @@ func (s *OpenTracingLayerComplianceStore) Update(compliance *model.Compliance) ( return result, err } +func (s *OpenTracingLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.DraftStore.Delete(userID, channelID, rootID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Get(userID, channelID, rootID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.GetDraftsForUser") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Save(d) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Update") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Update(d) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "EmojiStore.Delete") @@ -12681,6 +12781,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.CommandStore = &OpenTracingLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &OpenTracingLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &OpenTracingLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &OpenTracingLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &OpenTracingLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &OpenTracingLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &OpenTracingLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 1da922dec1..e2e17d5e61 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -30,6 +30,7 @@ type RetryLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -96,6 +97,10 @@ func (s *RetryLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *RetryLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *RetryLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -264,6 +269,11 @@ type RetryLayerComplianceStore struct { Root *RetryLayer } +type RetryLayerDraftStore struct { + store.DraftStore + Root *RetryLayer +} + type RetryLayerEmojiStore struct { store.EmojiStore Root *RetryLayer @@ -3614,6 +3624,111 @@ func (s *RetryLayerComplianceStore) Update(compliance *model.Compliance) (*model } +func (s *RetryLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + + tries := 0 + for { + err := s.DraftStore.Delete(userID, channelID, rootID) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Get(userID, channelID, rootID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Save(d) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Update(d) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { tries := 0 @@ -14460,6 +14575,7 @@ func New(childStore store.Store) *RetryLayer { newStore.CommandStore = &RetryLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &RetryLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &RetryLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &RetryLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &RetryLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &RetryLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &RetryLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} diff --git a/store/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index 7efbfa899a..701cf962f4 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -54,6 +54,7 @@ func genStore() *mocks.Store { mock.On("UserTermsOfService").Return(&mocks.UserTermsOfServiceStore{}) mock.On("Webhook").Return(&mocks.WebhookStore{}) mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{}) + mock.On("Draft").Return(&mocks.DraftStore{}) mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) return mock diff --git a/store/sqlstore/draft_store.go b/store/sqlstore/draft_store.go new file mode 100644 index 0000000000..fbf4d07e3d --- /dev/null +++ b/store/sqlstore/draft_store.go @@ -0,0 +1,240 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "sync" + + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +type SqlDraftStore struct { + *SqlStore + metrics einterfaces.MetricsInterface + maxDraftSizeOnce sync.Once + maxDraftSizeCached int +} + +func draftSliceColumns() []string { + return []string{"CreateAt", "UpdateAt", "DeleteAt", "Message", "RootId", "ChannelId", "UserId", "FileIds", "Props"} +} + +func draftToSlice(draft *model.Draft) []interface{} { + return []interface{}{ + draft.CreateAt, + draft.UpdateAt, + draft.DeleteAt, + draft.Message, + draft.RootId, + draft.ChannelId, + draft.UserId, + model.ArrayToJSON(draft.FileIds), + model.StringInterfaceToJSON(draft.Props), + } +} + +func newSqlDraftStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.DraftStore { + return &SqlDraftStore{ + SqlStore: sqlStore, + metrics: metrics, + maxDraftSizeCached: model.PostMessageMaxRunesV1, + } +} + +func (s *SqlDraftStore) Get(userId, channelId, rootId string) (*model.Draft, error) { + query := s.getQueryBuilder(). + Select("*"). + From("Drafts"). + Where(sq.Eq{ + "UserId": userId, + "ChannelId": channelId, + "RootId": rootId, + "DeleteAt": 0, + }) + + dt := model.Draft{} + err := s.GetReplicaX().GetBuilder(&dt, query) + + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Draft", channelId) + } + return nil, errors.Wrapf(err, "failed to find draft with channelid = %s", channelId) + } + + return &dt, nil +} + +func (s *SqlDraftStore) Save(draft *model.Draft) (*model.Draft, error) { + draft.PreSave() + maxDraftSize := s.GetMaxDraftSize() + if err := draft.IsValid(maxDraftSize); err != nil { + return nil, err + } + + builder := s.getQueryBuilder().Insert("Drafts").Columns(draftSliceColumns()...).Values(draftToSlice(draft)...) + query, args, err := builder.ToSql() + + if err != nil { + return nil, errors.Wrap(err, "save_draft_tosql") + } + + if _, err = s.GetMasterX().Exec(query, args...); err != nil { + return nil, errors.Wrap(err, "failed to save Draft") + } + + return draft, nil +} + +func (s *SqlDraftStore) Update(draft *model.Draft) (*model.Draft, error) { + draft.PreUpdate() + + maxDraftSize := s.GetMaxDraftSize() + if err := draft.IsValid(maxDraftSize); err != nil { + return nil, err + } + + query := s.getQueryBuilder(). + Update("Drafts"). + Set("UpdateAt", draft.UpdateAt). + Set("Message", draft.Message). + Set("Props", draft.Props). + Set("FileIds", draft.FileIds). + Where(sq.Eq{ + "UserId": draft.UserId, + "ChannelId": draft.ChannelId, + "RootId": draft.RootId, + "DeleteAt": 0, + }) + + sql, args, err := query.ToSql() + + if err != nil { + return nil, errors.Wrapf(err, "failed to convert to sql") + } + + if _, err = s.GetMasterX().Exec(sql, args...); err != nil { + return nil, errors.Wrapf(err, "failed to update Draft with channelid=%s", draft.ChannelId) + } + + return draft, nil +} + +func (s *SqlDraftStore) GetDraftsForUser(userID, teamID string) ([]*model.Draft, error) { + var drafts []*model.Draft + + query := s.getQueryBuilder(). + Select("Drafts.*"). + From("Drafts"). + InnerJoin("ChannelMembers ON ChannelMembers.ChannelId = Drafts.ChannelId"). + Where(sq.And{ + sq.Eq{"Drafts.DeleteAt": 0}, + sq.Eq{"Drafts.UserId": userID}, + sq.Eq{"ChannelMembers.UserId": userID}, + }). + OrderBy("Drafts.UpdateAt DESC") + + if teamID != "" { + query = query. + Join("Channels ON Drafts.ChannelId = Channels.Id"). + Where(sq.Or{ + sq.Eq{"Channels.TeamId": teamID}, + sq.Eq{"Channels.TeamId": ""}, + }) + } + + err := s.GetReplicaX().SelectBuilder(&drafts, query) + + if err != nil { + return nil, errors.Wrap(err, "failed to get user drafts") + } + + return drafts, nil +} + +func (s *SqlDraftStore) Delete(userID, channelID, rootID string) error { + time := model.GetMillis() + query := s.getQueryBuilder(). + Update("Drafts"). + Set("UpdateAt", time). + Set("DeleteAt", time). + Where(sq.Eq{ + "UserId": userID, + "ChannelId": channelID, + "RootId": rootID, + }) + + sql, args, err := query.ToSql() + if err != nil { + return errors.Wrapf(err, "failed to convert to sql") + } + + _, err = s.GetMasterX().Exec(sql, args...) + + if err != nil { + return errors.Wrap(err, "failed to delete Draft") + } + + return nil +} + +// GetMaxDraftSize returns the maximum number of runes that may be stored in a post. +func (s *SqlDraftStore) GetMaxDraftSize() int { + s.maxDraftSizeOnce.Do(func() { + s.maxDraftSizeCached = s.determineMaxDraftSize() + }) + return s.maxDraftSizeCached +} + +func (s *SqlDraftStore) determineMaxDraftSize() int { + var maxDraftSizeBytes int32 + + if s.DriverName() == model.DatabaseDriverPostgres { + // The Draft.Message column in Postgres has historically been VARCHAR(4000), but + // may be manually enlarged to support longer drafts. + if err := s.GetReplicaX().Get(&maxDraftSizeBytes, ` + SELECT + COALESCE(character_maximum_length, 0) + FROM + information_schema.columns + WHERE + table_name = 'drafts' + AND column_name = 'message' + `); err != nil { + mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err)) + } + } else if s.DriverName() == model.DatabaseDriverMysql { + // The Draft.Message column in MySQL has historically been TEXT, with a maximum + // limit of 65535. + if err := s.GetReplicaX().Get(&maxDraftSizeBytes, ` + SELECT + COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + table_schema = DATABASE() + AND table_name = 'Drafts' + AND column_name = 'Message' + LIMIT 0, 1 + `); err != nil { + mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err)) + } + } else { + mlog.Warn("No implementation found to determine the maximum supported draft size") + } + + // Assume a worst-case representation of four bytes per rune. + maxDraftSize := int(maxDraftSizeBytes) / 4 + + mlog.Info("Draft.Message has size restrictions", mlog.Int("max_characters", maxDraftSize), mlog.Int32("max_bytes", maxDraftSizeBytes)) + + return maxDraftSize +} diff --git a/store/sqlstore/draft_store_test.go b/store/sqlstore/draft_store_test.go new file mode 100644 index 0000000000..471161d0de --- /dev/null +++ b/store/sqlstore/draft_store_test.go @@ -0,0 +1,350 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/store/storetest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDraftStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestDraftStore) +} + +func TestSaveDraft(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("save drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Save(draft1) + assert.NoError(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Save(draft2) + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) + }) +} + +func TestUpdateDraft(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("update drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Update(draft1) + assert.NoError(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Update(draft2) + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) + }) +} + +func TestDeleteDraft(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("delete drafts", func(t *testing.T) { + err := ss.Draft().Delete(user.Id, channel.Id, "") + assert.NoError(t, err) + + err = ss.Draft().Delete(user.Id, channel2.Id, "") + assert.NoError(t, err) + }) + }) +} + +func TestGetDraft(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Get(user.Id, channel.Id, "") + assert.NoError(t, err) + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Get(user.Id, channel2.Id, "") + assert.NoError(t, err) + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) + }) +} + +func TestGetDraftsForUser(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := ss.Draft().GetDraftsForUser(user.Id, "") + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + }) + }) +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 6a0cdbc51a..4c1bf1f05b 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -108,6 +108,7 @@ type SqlStoreStores struct { UserTermsOfService store.UserTermsOfServiceStore linkMetadata store.LinkMetadataStore sharedchannel store.SharedChannelStore + draft store.DraftStore notifyAdmin store.NotifyAdminStore postPriority store.PostPriorityStore postAcknowledgement store.PostAcknowledgementStore @@ -215,6 +216,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.scheme = newSqlSchemeStore(store) store.stores.group = newSqlGroupStore(store) store.stores.productNotices = newSqlProductNoticesStore(store) + store.stores.draft = newSqlDraftStore(store, metrics) store.stores.notifyAdmin = newSqlNotifyAdminStore(store) store.stores.postPriority = newSqlPostPriorityStore(store) store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) @@ -963,6 +965,10 @@ func (ss *SqlStore) PostPriority() store.PostPriorityStore { return ss.stores.postPriority } +func (ss *SqlStore) Draft() store.DraftStore { + return ss.stores.draft +} + func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { return ss.stores.postAcknowledgement } diff --git a/store/store.go b/store/store.go index 4b3f4a94ea..deebac76f6 100644 --- a/store/store.go +++ b/store/store.go @@ -59,6 +59,7 @@ type Store interface { UserTermsOfService() UserTermsOfServiceStore LinkMetadata() LinkMetadataStore SharedChannel() SharedChannelStore + Draft() DraftStore MarkSystemRanUnitTests() Close() LockToMaster() @@ -979,6 +980,14 @@ type PostPriorityStore interface { GetForPosts(ids []string) ([]*model.PostPriority, error) } +type DraftStore interface { + Save(d *model.Draft) (*model.Draft, error) + Get(userID, channelID, rootID string) (*model.Draft, error) + Delete(userID, channelID, rootID string) error + GetDraftsForUser(userID, teamID string) ([]*model.Draft, error) + Update(d *model.Draft) (*model.Draft, error) +} + type PostAcknowledgementStore interface { Get(postID, userID string) (*model.PostAcknowledgement, error) GetForPost(postID string) ([]*model.PostAcknowledgement, error) diff --git a/store/storetest/draft_store.go b/store/storetest/draft_store.go new file mode 100644 index 0000000000..b540d672d3 --- /dev/null +++ b/store/storetest/draft_store.go @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/store" +) + +func TestDraftStore(t *testing.T, ss store.Store, s SqlStore) { +} diff --git a/store/storetest/mocks/DraftStore.go b/store/storetest/mocks/DraftStore.go new file mode 100644 index 0000000000..b7d89b1308 --- /dev/null +++ b/store/storetest/mocks/DraftStore.go @@ -0,0 +1,121 @@ +// Code generated by mockery v2.10.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v6/model" + mock "github.com/stretchr/testify/mock" +) + +// DraftStore is an autogenerated mock type for the DraftStore type +type DraftStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: userID, channelID, rootID +func (_m *DraftStore) Delete(userID string, channelID string, rootID string) error { + ret := _m.Called(userID, channelID, rootID) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = rf(userID, channelID, rootID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: userID, channelID, rootID +func (_m *DraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { + ret := _m.Called(userID, channelID, rootID) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(string, string, string) *model.Draft); ok { + r0 = rf(userID, channelID, rootID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = rf(userID, channelID, rootID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetDraftsForUser provides a mock function with given fields: userID, teamID +func (_m *DraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + ret := _m.Called(userID, teamID) + + var r0 []*model.Draft + if rf, ok := ret.Get(0).(func(string, string) []*model.Draft); ok { + r0 = rf(userID, teamID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(userID, teamID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: d +func (_m *DraftStore) Save(d *model.Draft) (*model.Draft, error) { + ret := _m.Called(d) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(*model.Draft) *model.Draft); ok { + r0 = rf(d) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Draft) error); ok { + r1 = rf(d) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: d +func (_m *DraftStore) Update(d *model.Draft) (*model.Draft, error) { + ret := _m.Called(d) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(*model.Draft) *model.Draft); ok { + r0 = rf(d) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Draft) error); ok { + r1 = rf(d) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index cfa20ca980..395cc3e9f1 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -187,6 +187,22 @@ func (_m *Store) Context() context.Context { return r0 } +// Draft provides a mock function with given fields: +func (_m *Store) Draft() store.DraftStore { + ret := _m.Called() + + var r0 store.DraftStore + if rf, ok := ret.Get(0).(func() store.DraftStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.DraftStore) + } + } + + return r0 +} + // DropAllTables provides a mock function with given fields: func (_m *Store) DropAllTables() { _m.Called() diff --git a/store/storetest/store.go b/store/storetest/store.go index 93a85d3ab1..e873ae4994 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -54,6 +54,7 @@ type Store struct { LinkMetadataStore mocks.LinkMetadataStore SharedChannelStore mocks.SharedChannelStore ProductNoticesStore mocks.ProductNoticesStore + DraftStore mocks.DraftStore context context.Context NotifyAdminStore mocks.NotifyAdminStore PostPriorityStore mocks.PostPriorityStore @@ -95,6 +96,7 @@ func (s *Store) Role() store.RoleStore { return &s.R func (s *Store) Scheme() store.SchemeStore { return &s.SchemeStore } func (s *Store) TermsOfService() store.TermsOfServiceStore { return &s.TermsOfServiceStore } func (s *Store) UserTermsOfService() store.UserTermsOfServiceStore { return &s.UserTermsOfServiceStore } +func (s *Store) Draft() store.DraftStore { return &s.DraftStore } func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } @@ -163,6 +165,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.ThreadStore, &s.ProductNoticesStore, &s.SharedChannelStore, + &s.DraftStore, &s.NotifyAdminStore, &s.PostPriorityStore, &s.PostAcknowledgementStore, diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 3395ab8b45..86a925e4e4 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -26,6 +26,7 @@ type TimerLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -92,6 +93,10 @@ func (s *TimerLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *TimerLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *TimerLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -260,6 +265,11 @@ type TimerLayerComplianceStore struct { Root *TimerLayer } +type TimerLayerDraftStore struct { + store.DraftStore + Root *TimerLayer +} + type TimerLayerEmojiStore struct { store.EmojiStore Root *TimerLayer @@ -2955,6 +2965,86 @@ func (s *TimerLayerComplianceStore) Update(compliance *model.Compliance) (*model return result, err } +func (s *TimerLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + start := time.Now() + + err := s.DraftStore.Delete(userID, channelID, rootID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Get(userID, channelID, rootID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.GetDraftsForUser", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Save(d) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Save", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Update(d) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Update", success, elapsed) + } + return result, err +} + func (s *TimerLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { start := time.Now() @@ -11424,6 +11514,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.CommandStore = &TimerLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &TimerLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &TimerLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &TimerLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &TimerLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &TimerLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &TimerLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} From 6c55c4d35692c0eee48c042d6ffa87c822e120e2 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Thu, 24 Nov 2022 11:13:29 +0300 Subject: [PATCH 25/80] app/pluginservice: move plugin out from the Channels product (#21697) --- api4/plugin.go | 4 +- api4/plugin_test.go | 2 +- api4/websocket.go | 2 +- app/app_iface.go | 1 + app/channels.go | 67 +---- app/cluster_handlers.go | 6 +- app/collection.go | 16 +- app/download.go | 6 +- app/integration_action.go | 6 +- app/onboarding.go | 4 +- app/opentracing/opentracing_layer.go | 17 ++ app/plugin.go | 368 +++++++++++++++++---------- app/plugin_api.go | 4 +- app/plugin_api_test.go | 24 +- app/plugin_commands.go | 72 ++++-- app/plugin_commands_test.go | 10 +- app/plugin_db_driver.go | 11 +- app/plugin_event.go | 6 +- app/plugin_hooks_test.go | 8 +- app/plugin_install.go | 118 ++++----- app/plugin_install_test.go | 8 +- app/plugin_requests.go | 28 +- app/plugin_requests_test.go | 2 +- app/plugin_shutdown_test.go | 2 +- app/plugin_signature.go | 8 +- app/plugin_statuses.go | 36 +-- app/plugin_test.go | 46 ++-- app/server.go | 14 +- app/web_conn.go | 2 +- cmd/mattermost/commands/init.go | 3 +- web/web_test.go | 4 +- 31 files changed, 497 insertions(+), 408 deletions(-) diff --git a/api4/plugin.go b/api4/plugin.go index be5b298d02..475aa62f21 100644 --- a/api4/plugin.go +++ b/api4/plugin.go @@ -155,7 +155,7 @@ func installMarketplacePlugin(c *Context, w http.ResponseWriter, r *http.Request // https://mattermost.atlassian.net/browse/MM-41981 pluginRequest.Version = "" - manifest, appErr := c.App.Channels().InstallMarketplacePlugin(pluginRequest) + manifest, appErr := c.App.PluginService().InstallMarketplacePlugin(pluginRequest) if appErr != nil { c.Err = appErr return @@ -235,7 +235,7 @@ func removePlugin(c *Context, w http.ResponseWriter, r *http.Request) { return } - err := c.App.Channels().RemovePlugin(c.Params.PluginId) + err := c.App.PluginService().RemovePlugin(c.Params.PluginId) if err != nil { c.Err = err return diff --git a/api4/plugin_test.go b/api4/plugin_test.go index 1967f9a617..3b656c209a 100644 --- a/api4/plugin_test.go +++ b/api4/plugin_test.go @@ -94,7 +94,7 @@ func TestPlugin(t *testing.T) { assert.Equal(t, "testplugin", manifest.Id) }) - th.App.Channels().RemovePlugin(manifest.Id) + th.App.PluginService().RemovePlugin(manifest.Id) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false }) diff --git a/api4/websocket.go b/api4/websocket.go index 5f1cb2cdd3..6c1394050e 100644 --- a/api4/websocket.go +++ b/api4/websocket.go @@ -61,7 +61,7 @@ func connectWebSocket(c *Context, w http.ResponseWriter, r *http.Request) { } } - wc := c.App.Srv().Platform().NewWebConn(cfg, c.App, c.App.Srv().Channels().GetPluginsEnvironment) + wc := c.App.Srv().Platform().NewWebConn(cfg, c.App, c.App.PluginService().GetPluginsEnvironment) if c.AppContext.Session().UserId != "" { c.App.Srv().Platform().HubRegister(wc) } diff --git a/app/app_iface.go b/app/app_iface.go index e3c02a0d80..90a3939e8c 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -928,6 +928,7 @@ type AppIface interface { PermanentDeleteTeamId(c request.CTX, teamID string) *model.AppError PermanentDeleteUser(c *request.Context, user *model.User) *model.AppError PluginCommandsForTeam(teamID string) []*model.Command + PluginService() *PluginService PostActionCookieSecret() []byte PostAddToChannelMessage(c request.CTX, user *model.User, addedUser *model.User, channel *model.Channel, postRootId string) *model.AppError PostPatchWithProxyRemovedFromImageURLs(patch *model.PostPatch) *model.PostPatch diff --git a/app/channels.go b/app/channels.go index 2c9f1cee51..de4ea1b669 100644 --- a/app/channels.go +++ b/app/channels.go @@ -6,17 +6,13 @@ package app import ( "fmt" "runtime" - "strings" "sync" "github.com/pkg/errors" "github.com/mattermost/mattermost-server/v6/app/imaging" - "github.com/mattermost/mattermost-server/v6/app/request" - "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/plugin" "github.com/mattermost/mattermost-server/v6/product" "github.com/mattermost/mattermost-server/v6/services/imageproxy" "github.com/mattermost/mattermost-server/v6/shared/filestore" @@ -40,12 +36,6 @@ type Channels struct { postActionCookieSecret []byte - pluginCommandsLock sync.RWMutex - pluginCommands []*PluginCommand - pluginsLock sync.RWMutex - pluginsEnvironment *plugin.Environment - pluginConfigListenerID string - imageProxy *imageproxy.ImageProxy // cached counts that are used during notice condition validation @@ -77,12 +67,6 @@ type Channels struct { postReminderMut sync.Mutex postReminderTask *model.ScheduledTask - - // collectionTypes maps from collection types to the registering plugin id - collectionTypes map[string]string - // topicTypes maps from topic types to collection types - topicTypes map[string]string - collectionAndTopicTypesMut sync.Mutex } func init() { @@ -100,11 +84,9 @@ func init() { func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { ch := &Channels{ - srv: s, - imageProxy: imageproxy.MakeImageProxy(s.platform, s.httpService, s.Log()), - uploadLockMap: map[string]bool{}, - collectionTypes: map[string]string{}, - topicTypes: map[string]string{}, + srv: s, + imageProxy: imageproxy.MakeImageProxy(s.platform, s.httpService, s.Log()), + uploadLockMap: map[string]bool{}, } // To get another service: @@ -201,10 +183,6 @@ func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { services[RouterKey] = ch.routerSvc // Setup routes. - pluginsRoute := ch.srv.Router.PathPrefix("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter() - pluginsRoute.HandleFunc("", ch.ServePluginRequest) - pluginsRoute.HandleFunc("/public/{public_file:.*}", ch.ServePluginPublicRequest) - pluginsRoute.HandleFunc("/{anything:.*}", ch.ServePluginRequest) services[PostKey] = &postServiceWrapper{ app: &App{ch: ch}, @@ -236,39 +214,6 @@ func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { } func (ch *Channels) Start() error { - // Start plugins - ctx := request.EmptyContext(ch.srv.Log()) - ch.initPlugins(ctx, *ch.cfgSvc.Config().PluginSettings.Directory, *ch.cfgSvc.Config().PluginSettings.ClientDirectory) - - ch.AddConfigListener(func(prevCfg, cfg *model.Config) { - // We compute the difference between configs - // to ensure we don't re-init plugins unnecessarily. - diffs, err := config.Diff(prevCfg, cfg) - if err != nil { - ch.srv.Log().Warn("Error in comparing configs", mlog.Err(err)) - return - } - - hasDiff := false - // TODO: This could be a method on ConfigDiffs itself - for _, diff := range diffs { - if strings.HasPrefix(diff.Path, "PluginSettings.") { - hasDiff = true - break - } - } - - // Do only if some plugin related settings has changed. - if hasDiff { - if *cfg.PluginSettings.Enable { - ch.initPlugins(ctx, *cfg.PluginSettings.Directory, *ch.cfgSvc.Config().PluginSettings.ClientDirectory) - } else { - ch.ShutDownPlugins() - } - } - - }) - // TODO: This should be moved to the platform service. if err := ch.srv.platform.EnsureAsymmetricSigningKey(); err != nil { return errors.Wrapf(err, "unable to ensure asymmetric signing key") @@ -282,8 +227,6 @@ func (ch *Channels) Start() error { } func (ch *Channels) Stop() error { - ch.ShutDownPlugins() - ch.dndTaskMut.Lock() if ch.dndTask != nil { ch.dndTask.Cancel() @@ -318,9 +261,9 @@ type hooksService struct { } func (s *hooksService) RegisterHooks(productID string, hooks any) error { - if s.ch.pluginsEnvironment == nil { + if s.ch.srv.pluginService.pluginsEnvironment == nil { return errors.New("could not find plugins environment") } - return s.ch.pluginsEnvironment.AddProduct(productID, hooks) + return s.ch.srv.pluginService.pluginsEnvironment.AddProduct(productID, hooks) } diff --git a/app/cluster_handlers.go b/app/cluster_handlers.go index beb8f71c73..f5e7c3811d 100644 --- a/app/cluster_handlers.go +++ b/app/cluster_handlers.go @@ -16,7 +16,7 @@ func (s *Server) clusterInstallPluginHandler(msg *model.ClusterMessage) { if jsonErr := json.Unmarshal(msg.Data, &data); jsonErr != nil { mlog.Warn("Failed to decode from JSON", mlog.Err(jsonErr)) } - s.Channels().installPluginFromData(data) + s.pluginService.installPluginFromData(data) } func (s *Server) clusterRemovePluginHandler(msg *model.ClusterMessage) { @@ -24,11 +24,11 @@ func (s *Server) clusterRemovePluginHandler(msg *model.ClusterMessage) { if jsonErr := json.Unmarshal(msg.Data, &data); jsonErr != nil { mlog.Warn("Failed to decode from JSON", mlog.Err(jsonErr)) } - s.Channels().removePluginFromData(data) + s.pluginService.removePluginFromData(data) } func (s *Server) clusterPluginEventHandler(msg *model.ClusterMessage) { - env := s.Channels().GetPluginsEnvironment() + env := s.pluginService.GetPluginsEnvironment() if env == nil { return } diff --git a/app/collection.go b/app/collection.go index 9b895e3bc0..ff489a18db 100644 --- a/app/collection.go +++ b/app/collection.go @@ -10,26 +10,26 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mlog" ) -func (a *App) registerCollectionAndTopic(pluginID, collectionType, topicType string) error { +func (s *PluginService) registerCollectionAndTopic(pluginID, collectionType, topicType string) error { // we have a race condition due to multiple plugins calling this method - a.ch.collectionAndTopicTypesMut.Lock() - defer a.ch.collectionAndTopicTypesMut.Unlock() + s.collectionAndTopicTypesMut.Lock() + defer s.collectionAndTopicTypesMut.Unlock() // check if collectionType was already registered by other plugin - existingPluginID, ok := a.ch.collectionTypes[collectionType] + existingPluginID, ok := s.collectionTypes[collectionType] if ok && existingPluginID != pluginID { return model.NewAppError("registerCollectionAndTopic", "app.collection.add_collection.exists.app_error", nil, "", http.StatusBadRequest) } // check if topicType was already registered to other collection - existingCollectionType, ok := a.ch.topicTypes[topicType] + existingCollectionType, ok := s.topicTypes[topicType] if ok && existingCollectionType != collectionType { return model.NewAppError("registerCollectionAndTopic", "app.collection.add_topic.exists.app_error", nil, "", http.StatusBadRequest) } - a.ch.collectionTypes[collectionType] = pluginID - a.ch.topicTypes[topicType] = collectionType + s.collectionTypes[collectionType] = pluginID + s.topicTypes[topicType] = collectionType - a.ch.srv.Log().Info("registered collection and topic type", mlog.String("plugin_id", pluginID), mlog.String("collection_type", collectionType), mlog.String("topic_type", topicType)) + s.platform.Log().Info("registered collection and topic type", mlog.String("plugin_id", pluginID), mlog.String("collection_type", collectionType), mlog.String("topic_type", topicType)) return nil } diff --git a/app/download.go b/app/download.go index 449f787c46..56438507cc 100644 --- a/app/download.go +++ b/app/download.go @@ -22,10 +22,10 @@ const ( ) func (a *App) DownloadFromURL(downloadURL string) ([]byte, error) { - return a.Srv().downloadFromURL(downloadURL) + return a.Srv().pluginService.downloadFromURL(downloadURL) } -func (s *Server) downloadFromURL(downloadURL string) ([]byte, error) { +func (s *PluginService) downloadFromURL(downloadURL string) ([]byte, error) { if !model.IsValidHTTPURL(downloadURL) { return nil, errors.Errorf("invalid url %s", downloadURL) } @@ -38,7 +38,7 @@ func (s *Server) downloadFromURL(downloadURL string) ([]byte, error) { return nil, errors.Errorf("insecure url not allowed %s", downloadURL) } - client := s.HTTPService().MakeClient(true) + client := s.httpService.MakeClient(true) client.Timeout = HTTPRequestTimeout var resp *http.Response diff --git a/app/integration_action.go b/app/integration_action.go index 4ae7e97e07..51bef2b86f 100644 --- a/app/integration_action.go +++ b/app/integration_action.go @@ -375,10 +375,10 @@ func (w *LocalResponseWriter) WriteHeader(statusCode int) { } func (a *App) doPluginRequest(c *request.Context, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { - return a.ch.doPluginRequest(c, method, rawURL, values, body) + return a.ch.srv.pluginService.doPluginRequest(c, method, rawURL, values, body) } -func (ch *Channels) doPluginRequest(c *request.Context, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { +func (s *PluginService) doPluginRequest(c *request.Context, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { rawURL = strings.TrimPrefix(rawURL, "/") inURL, err := url.Parse(rawURL) if err != nil { @@ -427,7 +427,7 @@ func (ch *Channels) doPluginRequest(c *request.Context, method, rawURL string, v params["plugin_id"] = pluginID r = mux.SetURLVars(r, params) - ch.ServePluginRequest(w, r) + s.ServePluginRequest(w, r) resp := &http.Response{ StatusCode: w.status, diff --git a/app/onboarding.go b/app/onboarding.go index 9a9e25739c..46ff65a23c 100644 --- a/app/onboarding.go +++ b/app/onboarding.go @@ -28,7 +28,7 @@ func (a *App) markAdminOnboardingComplete(c *request.Context) *model.AppError { } func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnboardingRequest) *model.AppError { - pluginsEnvironment := a.Channels().GetPluginsEnvironment() + pluginsEnvironment := a.Srv().pluginService.GetPluginsEnvironment() if pluginsEnvironment == nil { return a.markAdminOnboardingComplete(c) } @@ -41,7 +41,7 @@ func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnbo installRequest := &model.InstallMarketplacePluginRequest{ Id: id, } - _, appErr := a.Channels().InstallMarketplacePlugin(installRequest) + _, appErr := a.Srv().pluginService.InstallMarketplacePlugin(installRequest) if appErr != nil { mlog.Error("Failed to install plugin for onboarding", mlog.String("id", id), mlog.Err(appErr)) return diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 63a3389735..2b65286070 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -13021,6 +13021,23 @@ func (a *OpenTracingAppLayer) PluginCommandsForTeam(teamID string) []*model.Comm return resultVar0 } +func (a *OpenTracingAppLayer) PluginService() *app.PluginService { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PluginService") + + 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.PluginService() + + return resultVar0 +} + func (a *OpenTracingAppLayer) PopulateWebConnConfig(s *model.Session, cfg *platform.WebConnConfig, seqVal string) (*platform.WebConnConfig, error) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PopulateWebConnConfig") diff --git a/app/plugin.go b/app/plugin.go index b48503de5d..b182af6cf5 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -20,16 +20,37 @@ import ( svg "github.com/h2non/go-is-svg" "github.com/pkg/errors" + "github.com/mattermost/mattermost-server/v6/app/platform" "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/config" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/product" + "github.com/mattermost/mattermost-server/v6/services/httpservice" "github.com/mattermost/mattermost-server/v6/services/marketplace" "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/utils/fileutils" ) +type PluginService struct { + platform *platform.PlatformService + channels *Channels + fileStore filestore.FileBackend + httpService httpservice.HTTPService + + pluginCommandsLock sync.RWMutex + pluginCommands []*PluginCommand + pluginsLock sync.RWMutex + pluginsEnvironment *plugin.Environment + pluginConfigListenerID string + // collectionTypes maps from collection types to the registering plugin id + collectionTypes map[string]string + // topicTypes maps from topic types to collection types + topicTypes map[string]string + collectionAndTopicTypesMut sync.Mutex +} + const prepackagedPluginsDir = "prepackaged_plugins" type pluginSignaturePath struct { @@ -63,20 +84,91 @@ func (rs *routerService) getHandler(productID string) (http.Handler, bool) { return handler, ok } +func NewPluginService(platform *platform.PlatformService, channels *Channels, httpService httpservice.HTTPService, router *mux.Router) *PluginService { + ps := &PluginService{ + platform: platform, + channels: channels, + fileStore: platform.FileBackend(), + httpService: httpService, + collectionTypes: make(map[string]string), + topicTypes: make(map[string]string), + } + + pluginsRoute := router.PathPrefix("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter() + pluginsRoute.HandleFunc("", ps.ServePluginRequest) + pluginsRoute.HandleFunc("/public/{public_file:.*}", ps.ServePluginPublicRequest) + pluginsRoute.HandleFunc("/{anything:.*}", ps.ServePluginRequest) + + ps.initPlugins(request.EmptyContext(platform.Log()), *platform.Config().PluginSettings.Directory, *platform.Config().PluginSettings.ClientDirectory) + + return ps +} + +func (a *App) PluginService() *PluginService { + return a.ch.srv.pluginService +} + +func (s *Server) InitializePluginService() error { + product, ok := s.products["channels"] + if !ok { + return errors.New("unable to find channels product") + } + channels, ok := product.(*Channels) + if !ok { + return errors.New("unable to cast product to channels product") + } + s.pluginService = NewPluginService(s.platform, channels, s.httpService, s.Router) + + // Start plugins + ctx := request.EmptyContext(s.platform.Log()) + + // Add the config listener to enable/disable plugins + s.platform.AddConfigListener(func(prevCfg, cfg *model.Config) { + // We compute the difference between configs + // to ensure we don't re-init plugins unnecessarily. + diffs, err := config.Diff(prevCfg, cfg) + if err != nil { + s.platform.Log().Warn("Error in comparing configs", mlog.Err(err)) + return + } + + hasDiff := false + // TODO: This could be a method on ConfigDiffs itself + for _, diff := range diffs { + if strings.HasPrefix(diff.Path, "PluginSettings.") { + hasDiff = true + break + } + } + + // Do only if some plugin related settings has changed. + if hasDiff { + if *cfg.PluginSettings.Enable { + s.pluginService.initPlugins(ctx, *cfg.PluginSettings.Directory, *s.Config().PluginSettings.ClientDirectory) + } else { + s.pluginService.ShutDownPlugins() + } + } + + }) + + return nil +} + // GetPluginsEnvironment returns the plugin environment for use if plugins are enabled and // initialized. // // To get the plugins environment when the plugins are disabled, manually acquire the plugins // lock instead. -func (ch *Channels) GetPluginsEnvironment() *plugin.Environment { - if !*ch.cfgSvc.Config().PluginSettings.Enable { +func (s *PluginService) GetPluginsEnvironment() *plugin.Environment { + if !*s.platform.Config().PluginSettings.Enable { return nil } - ch.pluginsLock.RLock() - defer ch.pluginsLock.RUnlock() + s.pluginsLock.RLock() + defer s.pluginsLock.RUnlock() - return ch.pluginsEnvironment + return s.pluginsEnvironment } // GetPluginsEnvironment returns the plugin environment for use if plugins are enabled and @@ -85,33 +177,33 @@ func (ch *Channels) GetPluginsEnvironment() *plugin.Environment { // To get the plugins environment when the plugins are disabled, manually acquire the plugins // lock instead. func (a *App) GetPluginsEnvironment() *plugin.Environment { - return a.ch.GetPluginsEnvironment() + return a.ch.srv.pluginService.GetPluginsEnvironment() } -func (ch *Channels) SetPluginsEnvironment(pluginsEnvironment *plugin.Environment) { - ch.pluginsLock.Lock() - defer ch.pluginsLock.Unlock() +func (s *PluginService) SetPluginsEnvironment(pluginsEnvironment *plugin.Environment) { + s.pluginsLock.Lock() + defer s.pluginsLock.Unlock() - ch.pluginsEnvironment = pluginsEnvironment - ch.srv.Platform().SetPluginsEnvironment(pluginsEnvironment) + s.pluginsEnvironment = pluginsEnvironment + s.platform.SetPluginsEnvironment(pluginsEnvironment) } -func (ch *Channels) syncPluginsActiveState() { +func (s *PluginService) syncPluginsActiveState() { // Acquiring lock manually, as plugins might be disabled. See GetPluginsEnvironment. - ch.pluginsLock.RLock() - pluginsEnvironment := ch.pluginsEnvironment - ch.pluginsLock.RUnlock() + s.pluginsLock.RLock() + pluginsEnvironment := s.pluginsEnvironment + s.pluginsLock.RUnlock() if pluginsEnvironment == nil { return } - config := ch.cfgSvc.Config().PluginSettings + config := s.platform.Config().PluginSettings if *config.Enable { availablePlugins, err := pluginsEnvironment.Available() if err != nil { - ch.srv.Log().Error("Unable to get available plugins", mlog.Err(err)) + s.platform.Log().Error("Unable to get available plugins", mlog.Err(err)) return } @@ -125,24 +217,24 @@ func (ch *Channels) syncPluginsActiveState() { pluginEnabled = state.Enable } - if hasOverride, value := ch.getPluginStateOverride(pluginID); hasOverride { + if hasOverride, value := s.getPluginStateOverride(pluginID); hasOverride { pluginEnabled = value } if pluginEnabled { // Disable focalboard in product mode. - if pluginID == model.PluginIdFocalboard && ch.cfgSvc.Config().FeatureFlags.BoardsProduct { + if pluginID == model.PluginIdFocalboard && s.platform.Config().FeatureFlags.BoardsProduct { msg := "Plugin cannot run in product mode. Disabling." mlog.Warn(msg, mlog.String("plugin_id", model.PluginIdFocalboard)) // This is a mini-version of ch.disablePlugin. // We don't call that directly, because that will recursively call // this method. - ch.cfgSvc.UpdateConfig(func(cfg *model.Config) { + s.platform.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.PluginStates[pluginID] = &model.PluginState{Enable: false} }) pluginsEnvironment.SetPluginError(pluginID, msg) - ch.unregisterPluginCommands(pluginID) + s.unregisterPluginCommands(pluginID) disabledPlugins = append(disabledPlugins, plugin) continue } @@ -166,7 +258,7 @@ func (ch *Channels) syncPluginsActiveState() { if deactivated && plugin.Manifest.HasClient() { message := model.NewWebSocketEvent(model.WebsocketEventPluginDisabled, "", "", "", nil, "") message.Add("manifest", plugin.Manifest.ClientManifest()) - ch.srv.platform.Publish(message) + s.platform.Publish(message) } }(plugin) } @@ -180,14 +272,14 @@ func (ch *Channels) syncPluginsActiveState() { pluginID := plugin.Manifest.Id updatedManifest, activated, err := pluginsEnvironment.Activate(pluginID) if err != nil { - plugin.WrapLogger(ch.srv.Log()).Error("Unable to activate plugin", mlog.Err(err)) + plugin.WrapLogger(s.platform.Log().(*mlog.Logger)).Error("Unable to activate plugin", mlog.Err(err)) return } if activated { // Notify all cluster clients if ready - if err := ch.notifyPluginEnabled(updatedManifest); err != nil { - ch.srv.Log().Error("Failed to notify cluster on plugin enable", mlog.Err(err)) + if err := s.notifyPluginEnabled(updatedManifest); err != nil { + s.platform.Log().Error("Failed to notify cluster on plugin enable", mlog.Err(err)) } } }(plugin) @@ -197,7 +289,7 @@ func (ch *Channels) syncPluginsActiveState() { pluginsEnvironment.Shutdown() } - if err := ch.notifyPluginStatusesChanged(); err != nil { + if err := s.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } } @@ -207,27 +299,29 @@ func (a *App) NewPluginAPI(c *request.Context, manifest *model.Manifest) plugin. } func (a *App) InitPlugins(c *request.Context, pluginDir, webappPluginDir string) { - a.ch.initPlugins(c, pluginDir, webappPluginDir) + a.ch.srv.pluginService.initPlugins(c, pluginDir, webappPluginDir) } -func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir string) { +func (s *PluginService) initPlugins(c *request.Context, pluginDir, webappPluginDir string) { // Acquiring lock manually, as plugins might be disabled. See GetPluginsEnvironment. defer func() { - ch.srv.Platform().SetPluginsEnvironment(ch.pluginsEnvironment) + // platform service requires plugins environment to be initialized + // so that it can use it in cluster service initialization + s.platform.SetPluginsEnvironment(s.pluginsEnvironment) }() - ch.pluginsLock.RLock() - pluginsEnvironment := ch.pluginsEnvironment - ch.pluginsLock.RUnlock() - if pluginsEnvironment != nil || !*ch.cfgSvc.Config().PluginSettings.Enable { - ch.syncPluginsActiveState() + s.pluginsLock.RLock() + pluginsEnvironment := s.pluginsEnvironment + s.pluginsLock.RUnlock() + if pluginsEnvironment != nil || !*s.platform.Config().PluginSettings.Enable { + s.syncPluginsActiveState() if pluginsEnvironment != nil { - pluginsEnvironment.TogglePluginHealthCheckJob(*ch.cfgSvc.Config().PluginSettings.EnableHealthCheck) + pluginsEnvironment.TogglePluginHealthCheckJob(*s.platform.Config().PluginSettings.EnableHealthCheck) } return } - ch.srv.Log().Info("Starting up plugins") + s.platform.Log().Info("Starting up plugins") if err := os.Mkdir(pluginDir, 0744); err != nil && !os.IsExist(err) { mlog.Error("Failed to start up plugins", mlog.Err(err)) @@ -240,70 +334,70 @@ func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir s } newAPIFunc := func(manifest *model.Manifest) plugin.API { - return New(ServerConnector(ch)).NewPluginAPI(c, manifest) + return New(ServerConnector(s.channels)).NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newAPIFunc, NewDriverImpl(ch.srv), pluginDir, webappPluginDir, ch.srv.Log(), ch.srv.GetMetrics()) + env, err := plugin.NewEnvironment(newAPIFunc, NewDriverImpl(s.platform), pluginDir, webappPluginDir, s.platform.Log().(*mlog.Logger), s.platform.Metrics()) if err != nil { mlog.Error("Failed to start up plugins", mlog.Err(err)) return } - ch.pluginsLock.Lock() - ch.pluginsEnvironment = env - ch.pluginsLock.Unlock() + s.pluginsLock.Lock() + s.pluginsEnvironment = env + s.pluginsLock.Unlock() - ch.pluginsEnvironment.TogglePluginHealthCheckJob(*ch.cfgSvc.Config().PluginSettings.EnableHealthCheck) + s.pluginsEnvironment.TogglePluginHealthCheckJob(*s.platform.Config().PluginSettings.EnableHealthCheck) - if err := ch.syncPlugins(); err != nil { + if err := s.syncPlugins(); err != nil { mlog.Error("Failed to sync plugins from the file store", mlog.Err(err)) } - plugins := ch.processPrepackagedPlugins(prepackagedPluginsDir) - pluginsEnvironment = ch.GetPluginsEnvironment() + plugins := s.processPrepackagedPlugins(prepackagedPluginsDir) + pluginsEnvironment = s.GetPluginsEnvironment() if pluginsEnvironment == nil { mlog.Info("Plugins environment not found, server is likely shutting down") return } pluginsEnvironment.SetPrepackagedPlugins(plugins) - ch.installFeatureFlagPlugins() + s.installFeatureFlagPlugins() // Sync plugin active state when config changes. Also notify plugins. - ch.pluginsLock.Lock() - ch.RemoveConfigListener(ch.pluginConfigListenerID) - ch.pluginConfigListenerID = ch.AddConfigListener(func(old, new *model.Config) { + s.pluginsLock.Lock() + s.platform.RemoveConfigListener(s.pluginConfigListenerID) + s.pluginConfigListenerID = s.platform.AddConfigListener(func(old, new *model.Config) { // If plugin status remains unchanged, only then run this. // Because (*App).InitPlugins is already run as a config change hook. if *old.PluginSettings.Enable == *new.PluginSettings.Enable { - ch.installFeatureFlagPlugins() - ch.syncPluginsActiveState() + s.installFeatureFlagPlugins() + s.syncPluginsActiveState() } - if pluginsEnvironment := ch.GetPluginsEnvironment(); pluginsEnvironment != nil { + if pluginsEnvironment := s.GetPluginsEnvironment(); pluginsEnvironment != nil { pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { if err := hooks.OnConfigurationChange(); err != nil { - ch.srv.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) + s.platform.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) } return true }, plugin.OnConfigurationChangeID) } }) - ch.pluginsLock.Unlock() + s.pluginsLock.Unlock() - ch.syncPluginsActiveState() + s.syncPluginsActiveState() } // SyncPlugins synchronizes the plugins installed locally // with the plugin bundles available in the file store. func (a *App) SyncPlugins() *model.AppError { - return a.ch.syncPlugins() + return a.ch.srv.pluginService.syncPlugins() } // SyncPlugins synchronizes the plugins installed locally // with the plugin bundles available in the file store. -func (ch *Channels) syncPlugins() *model.AppError { +func (s *PluginService) syncPlugins() *model.AppError { mlog.Info("Syncing plugins from the file store") - pluginsEnvironment := ch.GetPluginsEnvironment() + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return model.NewAppError("SyncPlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -319,14 +413,14 @@ func (ch *Channels) syncPlugins() *model.AppError { go func(pluginID string) { defer wg.Done() // Only handle managed plugins with .filestore flag file. - _, err := os.Stat(filepath.Join(*ch.cfgSvc.Config().PluginSettings.Directory, pluginID, managedPluginFileName)) + _, err := os.Stat(filepath.Join(*s.platform.Config().PluginSettings.Directory, pluginID, managedPluginFileName)) if os.IsNotExist(err) { mlog.Warn("Skipping sync for unmanaged plugin", mlog.String("plugin_id", pluginID)) } else if err != nil { mlog.Error("Skipping sync for plugin after failure to check if managed", mlog.String("plugin_id", pluginID), mlog.Err(err)) } else { mlog.Debug("Removing local installation of managed plugin before sync", mlog.String("plugin_id", pluginID)) - if err := ch.removePluginLocally(pluginID); err != nil { + if err := s.removePluginLocally(pluginID); err != nil { mlog.Error("Failed to remove local installation of managed plugin before sync", mlog.String("plugin_id", pluginID), mlog.Err(err)) } } @@ -335,7 +429,7 @@ func (ch *Channels) syncPlugins() *model.AppError { wg.Wait() // Install plugins from the file store. - pluginSignaturePathMap, appErr := ch.getPluginsFromFolder() + pluginSignaturePathMap, appErr := s.getPluginsFromFolder() if appErr != nil { return appErr } @@ -344,7 +438,7 @@ func (ch *Channels) syncPlugins() *model.AppError { wg.Add(1) go func(plugin *pluginSignaturePath) { defer wg.Done() - reader, appErr := ch.srv.fileReader(plugin.path) + reader, appErr := s.fileStore.Reader(plugin.path) if appErr != nil { mlog.Error("Failed to open plugin bundle from file store.", mlog.String("bundle", plugin.path), mlog.Err(appErr)) return @@ -352,8 +446,8 @@ func (ch *Channels) syncPlugins() *model.AppError { defer reader.Close() var signature filestore.ReadCloseSeeker - if *ch.cfgSvc.Config().PluginSettings.RequirePluginSignature { - signature, appErr = ch.srv.fileReader(plugin.signaturePath) + if *s.platform.Config().PluginSettings.RequirePluginSignature { + signature, appErr = s.fileStore.Reader(plugin.signaturePath) if appErr != nil { mlog.Error("Failed to open plugin signature from file store.", mlog.Err(appErr)) return @@ -362,7 +456,7 @@ func (ch *Channels) syncPlugins() *model.AppError { } mlog.Info("Syncing plugin from file store", mlog.String("bundle", plugin.path)) - if _, err := ch.installPluginLocally(reader, signature, installPluginLocallyAlways); err != nil { + if _, err := s.installPluginLocally(reader, signature, installPluginLocallyAlways); err != nil { mlog.Error("Failed to sync plugin from file store", mlog.String("bundle", plugin.path), mlog.Err(err)) } }(plugin) @@ -372,11 +466,11 @@ func (ch *Channels) syncPlugins() *model.AppError { return nil } -func (ch *Channels) ShutDownPlugins() { +func (s *PluginService) ShutDownPlugins() { // Acquiring lock manually, as plugins might be disabled. See GetPluginsEnvironment. - ch.pluginsLock.RLock() - pluginsEnvironment := ch.pluginsEnvironment - ch.pluginsLock.RUnlock() + s.pluginsLock.RLock() + pluginsEnvironment := s.pluginsEnvironment + s.pluginsLock.RUnlock() if pluginsEnvironment == nil { return } @@ -385,14 +479,14 @@ func (ch *Channels) ShutDownPlugins() { pluginsEnvironment.Shutdown() - ch.RemoveConfigListener(ch.pluginConfigListenerID) - ch.pluginConfigListenerID = "" + s.platform.RemoveConfigListener(s.pluginConfigListenerID) + s.pluginConfigListenerID = "" // Acquiring lock manually before cleaning up PluginsEnvironment. - ch.pluginsLock.Lock() - defer ch.pluginsLock.Unlock() - if ch.pluginsEnvironment == pluginsEnvironment { - ch.pluginsEnvironment = nil + s.pluginsLock.Lock() + defer s.pluginsLock.Unlock() + if s.pluginsEnvironment == pluginsEnvironment { + s.pluginsEnvironment = nil } else { mlog.Warn("Another PluginsEnvironment detected while shutting down plugins.") } @@ -418,11 +512,11 @@ func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { // activation if inactive anywhere in the cluster. // Notifies cluster peers through config change. func (a *App) EnablePlugin(id string) *model.AppError { - return a.ch.enablePlugin(id) + return a.PluginService().enablePlugin(id) } -func (ch *Channels) enablePlugin(id string) *model.AppError { - pluginsEnvironment := ch.GetPluginsEnvironment() +func (s *PluginService) enablePlugin(id string) *model.AppError { + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return model.NewAppError("EnablePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -446,16 +540,16 @@ func (ch *Channels) enablePlugin(id string) *model.AppError { return model.NewAppError("EnablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusNotFound) } - if id == model.PluginIdFocalboard && ch.cfgSvc.Config().FeatureFlags.BoardsProduct { + if id == model.PluginIdFocalboard && s.platform.Config().FeatureFlags.BoardsProduct { return model.NewAppError("EnablePlugin", "app.plugin.product_mode.app_error", map[string]any{"Name": model.PluginIdFocalboard}, "", http.StatusBadRequest) } - ch.cfgSvc.UpdateConfig(func(cfg *model.Config) { + s.platform.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: true} }) // This call will implicitly invoke SyncPluginsActiveState which will activate enabled plugins. - if _, _, err := ch.cfgSvc.SaveConfig(ch.cfgSvc.Config(), true); err != nil { + if _, _, err := s.platform.SaveConfig(s.platform.Config(), true); err != nil { if err.Id == "ent.cluster.save_config.error" { return model.NewAppError("EnablePlugin", "app.plugin.cluster.save_config.app_error", nil, "", http.StatusInternalServerError) } @@ -468,7 +562,7 @@ func (ch *Channels) enablePlugin(id string) *model.AppError { // DisablePlugin will set the config for an installed plugin to disabled, triggering deactivation if active. // Notifies cluster peers through config change. func (a *App) DisablePlugin(id string) *model.AppError { - appErr := a.ch.disablePlugin(id) + appErr := a.ch.srv.pluginService.disablePlugin(id) if appErr != nil { return appErr } @@ -476,22 +570,22 @@ func (a *App) DisablePlugin(id string) *model.AppError { return nil } -func (ch *Channels) disablePlugin(id string) *model.AppError { +func (s *PluginService) disablePlugin(id string) *model.AppError { // find all collectionTypes registered by plugin - for collectionTypeToRemove, existingPluginId := range ch.collectionTypes { + for collectionTypeToRemove, existingPluginId := range s.collectionTypes { if existingPluginId != id { continue } // find all topicTypes for existing collectionType - for topicTypeToRemove, existingCollectionType := range ch.topicTypes { + for topicTypeToRemove, existingCollectionType := range s.topicTypes { if existingCollectionType == collectionTypeToRemove { - delete(ch.topicTypes, topicTypeToRemove) + delete(s.topicTypes, topicTypeToRemove) } } - delete(ch.collectionTypes, collectionTypeToRemove) + delete(s.collectionTypes, collectionTypeToRemove) } - pluginsEnvironment := ch.GetPluginsEnvironment() + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return model.NewAppError("DisablePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -515,13 +609,13 @@ func (ch *Channels) disablePlugin(id string) *model.AppError { return model.NewAppError("DisablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusNotFound) } - ch.cfgSvc.UpdateConfig(func(cfg *model.Config) { + s.platform.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: false} }) - ch.unregisterPluginCommands(id) + s.unregisterPluginCommands(id) // This call will implicitly invoke SyncPluginsActiveState which will deactivate disabled plugins. - if _, _, err := ch.cfgSvc.SaveConfig(ch.cfgSvc.Config(), true); err != nil { + if _, _, err := s.platform.SaveConfig(s.platform.Config(), true); err != nil { return model.NewAppError("DisablePlugin", "app.plugin.config.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -608,8 +702,8 @@ func (a *App) GetMarketplacePlugins(filter *model.MarketplacePluginFilter) ([]*m // getPrepackagedPlugin returns a pre-packaged plugin. // // If version is empty, the first matching plugin is returned. -func (ch *Channels) getPrepackagedPlugin(pluginID, version string) (*plugin.PrepackagedPlugin, *model.AppError) { - pluginsEnvironment := ch.GetPluginsEnvironment() +func (s *PluginService) getPrepackagedPlugin(pluginID, version string) (*plugin.PrepackagedPlugin, *model.AppError) { + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("getPrepackagedPlugin", "app.plugin.config.app_error", nil, "plugin environment is nil", http.StatusInternalServerError) } @@ -627,16 +721,16 @@ func (ch *Channels) getPrepackagedPlugin(pluginID, version string) (*plugin.Prep // getRemoteMarketplacePlugin returns plugin from marketplace-server. // // If version is empty, the latest compatible version is used. -func (ch *Channels) getRemoteMarketplacePlugin(pluginID, version string) (*model.BaseMarketplacePlugin, *model.AppError) { +func (s *PluginService) getRemoteMarketplacePlugin(pluginID, version string) (*model.BaseMarketplacePlugin, *model.AppError) { marketplaceClient, err := marketplace.NewClient( - *ch.cfgSvc.Config().PluginSettings.MarketplaceURL, - ch.srv.HTTPService(), + *s.platform.Config().PluginSettings.MarketplaceURL, + s.httpService, ) if err != nil { return nil, model.NewAppError("GetMarketplacePlugin", "app.plugin.marketplace_client.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - filter := ch.getBaseMarketplaceFilter() + filter := s.getBaseMarketplaceFilter() filter.PluginId = pluginID var plugin *model.BaseMarketplacePlugin @@ -791,15 +885,15 @@ func (a *App) mergeLocalPlugins(remoteMarketplacePlugins map[string]*model.Marke } func (a *App) getBaseMarketplaceFilter() *model.MarketplacePluginFilter { - return a.ch.getBaseMarketplaceFilter() + return a.ch.srv.pluginService.getBaseMarketplaceFilter() } -func (ch *Channels) getBaseMarketplaceFilter() *model.MarketplacePluginFilter { +func (s *PluginService) getBaseMarketplaceFilter() *model.MarketplacePluginFilter { filter := &model.MarketplacePluginFilter{ ServerVersion: model.CurrentVersion, } - license := ch.srv.License() + license := s.platform.License() if license != nil && license.HasEnterpriseMarketplacePlugins() { filter.EnterprisePlugins = true } @@ -846,8 +940,8 @@ func pluginMatchesFilter(manifest *model.Manifest, filter string) bool { // it will notify all connected websocket clients (across all peers) to trigger the (re-)installation. // There is a small chance that this never occurs, because the last server to finish installing dies before it can announce. // There is also a chance that multiple servers notify, but the webapp handles this idempotently. -func (ch *Channels) notifyPluginEnabled(manifest *model.Manifest) error { - pluginsEnvironment := ch.GetPluginsEnvironment() +func (s *PluginService) notifyPluginEnabled(manifest *model.Manifest) error { + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return errors.New("pluginsEnvironment is nil") } @@ -857,15 +951,15 @@ func (ch *Channels) notifyPluginEnabled(manifest *model.Manifest) error { var statuses model.PluginStatuses - if ch.srv.platform.Cluster() != nil { + if s.platform.Cluster() != nil { var err *model.AppError - statuses, err = ch.srv.platform.Cluster().GetPluginStatuses() + statuses, err = s.platform.Cluster().GetPluginStatuses() if err != nil { return err } } - localStatus, err := ch.GetPluginStatus(manifest.Id) + localStatus, err := s.GetPluginStatus(manifest.Id) if err != nil { return err } @@ -885,26 +979,26 @@ func (ch *Channels) notifyPluginEnabled(manifest *model.Manifest) error { // Notify all cluster peer clients. message := model.NewWebSocketEvent(model.WebsocketEventPluginEnabled, "", "", "", nil, "") message.Add("manifest", manifest.ClientManifest()) - ch.srv.platform.Publish(message) + s.platform.Publish(message) return nil } -func (ch *Channels) getPluginsFromFolder() (map[string]*pluginSignaturePath, *model.AppError) { - fileStorePaths, appErr := ch.srv.listDirectory(fileStorePluginFolder, false) +func (s *PluginService) getPluginsFromFolder() (map[string]*pluginSignaturePath, *model.AppError) { + fileStorePaths, appErr := s.fileStore.ListDirectory(fileStorePluginFolder) if appErr != nil { return nil, model.NewAppError("getPluginsFromDir", "app.plugin.sync.list_filestore.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) } - return ch.getPluginsFromFilePaths(fileStorePaths), nil + return s.getPluginsFromFilePaths(fileStorePaths), nil } -func (ch *Channels) getPluginsFromFilePaths(fileStorePaths []string) map[string]*pluginSignaturePath { +func (s *PluginService) getPluginsFromFilePaths(fileStorePaths []string) map[string]*pluginSignaturePath { pluginSignaturePathMap := make(map[string]*pluginSignaturePath) fsPrefix := "" - if *ch.cfgSvc.Config().FileSettings.DriverName == model.ImageDriverS3 { - ptr := ch.cfgSvc.Config().FileSettings.AmazonS3PathPrefix + if *s.platform.Config().FileSettings.DriverName == model.ImageDriverS3 { + ptr := s.platform.Config().FileSettings.AmazonS3PathPrefix if ptr != nil && *ptr != "" { fsPrefix = *ptr + "/" } @@ -937,7 +1031,7 @@ func (ch *Channels) getPluginsFromFilePaths(fileStorePaths []string) map[string] return pluginSignaturePathMap } -func (ch *Channels) processPrepackagedPlugins(pluginsDir string) []*plugin.PrepackagedPlugin { +func (s *PluginService) processPrepackagedPlugins(pluginsDir string) []*plugin.PrepackagedPlugin { prepackagedPluginsDir, found := fileutils.FindDir(pluginsDir) if !found { return nil @@ -953,7 +1047,7 @@ func (ch *Channels) processPrepackagedPlugins(pluginsDir string) []*plugin.Prepa return nil } - pluginSignaturePathMap := ch.getPluginsFromFilePaths(fileStorePaths) + pluginSignaturePathMap := s.getPluginsFromFilePaths(fileStorePaths) plugins := make([]*plugin.PrepackagedPlugin, 0, len(pluginSignaturePathMap)) prepackagedPlugins := make(chan *plugin.PrepackagedPlugin, len(pluginSignaturePathMap)) @@ -962,7 +1056,7 @@ func (ch *Channels) processPrepackagedPlugins(pluginsDir string) []*plugin.Prepa wg.Add(1) go func(psPath *pluginSignaturePath) { defer wg.Done() - p, err := ch.processPrepackagedPlugin(psPath) + p, err := s.processPrepackagedPlugin(psPath) if err != nil { mlog.Error("Failed to install prepackaged plugin", mlog.String("path", psPath.path), mlog.Err(err)) return @@ -983,7 +1077,7 @@ func (ch *Channels) processPrepackagedPlugins(pluginsDir string) []*plugin.Prepa // processPrepackagedPlugin will return the prepackaged plugin metadata and will also // install the prepackaged plugin if it had been previously enabled and AutomaticPrepackagedPlugins is true. -func (ch *Channels) processPrepackagedPlugin(pluginPath *pluginSignaturePath) (*plugin.PrepackagedPlugin, error) { +func (s *PluginService) processPrepackagedPlugin(pluginPath *pluginSignaturePath) (*plugin.PrepackagedPlugin, error) { mlog.Debug("Processing prepackaged plugin", mlog.String("path", pluginPath.path)) fileReader, err := os.Open(pluginPath.path) @@ -1004,18 +1098,18 @@ func (ch *Channels) processPrepackagedPlugin(pluginPath *pluginSignaturePath) (* } // Skip installing the plugin at all if automatic prepackaged plugins is disabled - if !*ch.cfgSvc.Config().PluginSettings.AutomaticPrepackagedPlugins { + if !*s.platform.Config().PluginSettings.AutomaticPrepackagedPlugins { return plugin, nil } // Skip installing if the plugin is has not been previously enabled. - pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[plugin.Manifest.Id] + pluginState := s.platform.Config().PluginSettings.PluginStates[plugin.Manifest.Id] if pluginState == nil || !pluginState.Enable { return plugin, nil } mlog.Debug("Installing prepackaged plugin", mlog.String("path", pluginPath.path)) - if _, err := ch.installExtractedPlugin(plugin.Manifest, pluginDir, installPluginLocallyOnlyIfNewOrUpgrade); err != nil { + if _, err := s.installExtractedPlugin(plugin.Manifest, pluginDir, installPluginLocallyOnlyIfNewOrUpgrade); err != nil { return nil, errors.Wrapf(err, "Failed to install extracted prepackaged plugin %s", pluginPath.path) } @@ -1023,24 +1117,24 @@ func (ch *Channels) processPrepackagedPlugin(pluginPath *pluginSignaturePath) (* } // installFeatureFlagPlugins handles the automatic installation/upgrade of plugins from feature flags -func (ch *Channels) installFeatureFlagPlugins() { - ffControledPlugins := ch.cfgSvc.Config().FeatureFlags.Plugins() +func (s *PluginService) installFeatureFlagPlugins() { + ffControledPlugins := s.platform.Config().FeatureFlags.Plugins() // Respect the automatic prepackaged disable setting - if !*ch.cfgSvc.Config().PluginSettings.AutomaticPrepackagedPlugins { + if !*s.platform.Config().PluginSettings.AutomaticPrepackagedPlugins { return } for pluginID, version := range ffControledPlugins { // Skip installing if the plugin has been previously disabled. - pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[pluginID] + pluginState := s.platform.Config().PluginSettings.PluginStates[pluginID] if pluginState != nil && !pluginState.Enable { - ch.srv.Log().Debug("Not auto installing/upgrade because plugin was disabled", mlog.String("plugin_id", pluginID), mlog.String("version", version)) + s.platform.Log().Debug("Not auto installing/upgrade because plugin was disabled", mlog.String("plugin_id", pluginID), mlog.String("version", version)) continue } // Check if we already installed this version as InstallMarketplacePlugin can't handle re-installs well. - pluginStatus, err := ch.GetPluginStatus(pluginID) + pluginStatus, err := s.GetPluginStatus(pluginID) pluginExists := err == nil if pluginExists && pluginStatus.Version == version { continue @@ -1048,37 +1142,37 @@ func (ch *Channels) installFeatureFlagPlugins() { if version != "" && version != "control" { // If we are on-prem skip installation if this is a downgrade - license := ch.srv.License() + license := s.platform.License() inCloud := license != nil && *license.Features.Cloud if !inCloud && pluginExists { parsedVersion, err := semver.Parse(version) if err != nil { - ch.srv.Log().Debug("Bad version from feature flag", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) + s.platform.Log().Debug("Bad version from feature flag", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) return } parsedExistingVersion, err := semver.Parse(pluginStatus.Version) if err != nil { - ch.srv.Log().Debug("Bad version from plugin manifest", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", pluginStatus.Version)) + s.platform.Log().Debug("Bad version from plugin manifest", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", pluginStatus.Version)) return } if parsedVersion.LTE(parsedExistingVersion) { - ch.srv.Log().Debug("Skip installation because given version was a downgrade and on-prem installations should not downgrade.", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", pluginStatus.Version)) + s.platform.Log().Debug("Skip installation because given version was a downgrade and on-prem installations should not downgrade.", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", pluginStatus.Version)) return } } - _, err := ch.InstallMarketplacePlugin(&model.InstallMarketplacePluginRequest{ + _, err := s.InstallMarketplacePlugin(&model.InstallMarketplacePluginRequest{ Id: pluginID, Version: version, }) if err != nil { - ch.srv.Log().Debug("Unable to install plugin from FF manifest", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) + s.platform.Log().Debug("Unable to install plugin from FF manifest", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) } else { - if err := ch.enablePlugin(pluginID); err != nil { - ch.srv.Log().Debug("Unable to enable plugin installed from feature flag.", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) + if err := s.enablePlugin(pluginID); err != nil { + s.platform.Log().Debug("Unable to enable plugin installed from feature flag.", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) } else { - ch.srv.Log().Debug("Installed and enabled plugin.", mlog.String("plugin_id", pluginID), mlog.String("version", version)) + s.platform.Log().Debug("Installed and enabled plugin.", mlog.String("plugin_id", pluginID), mlog.String("version", version)) } } } @@ -1133,15 +1227,15 @@ func getIcon(iconPath string) (string, error) { return fmt.Sprintf("data:image/svg+xml;base64,%s", base64.StdEncoding.EncodeToString(icon)), nil } -func (ch *Channels) getPluginStateOverride(pluginID string) (bool, bool) { +func (s *PluginService) getPluginStateOverride(pluginID string) (bool, bool) { switch pluginID { case model.PluginIdApps: // Tie Apps proxy disabled status to the feature flag. - if !ch.cfgSvc.Config().FeatureFlags.AppsEnabled { + if !s.platform.Config().FeatureFlags.AppsEnabled { return true, false } case model.PluginIdCalls: - if !ch.cfgSvc.Config().FeatureFlags.CallsEnabled { + if !s.platform.Config().FeatureFlags.CallsEnabled { return true, false } } diff --git a/app/plugin_api.go b/app/plugin_api.go index accedf37ba..d4aa7c7deb 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -886,7 +886,7 @@ func (api *PluginAPI) DisablePlugin(id string) *model.AppError { } func (api *PluginAPI) RemovePlugin(id string) *model.AppError { - return api.app.Channels().RemovePlugin(id) + return api.app.Srv().pluginService.RemovePlugin(id) } func (api *PluginAPI) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) { @@ -1235,7 +1235,7 @@ func (api *PluginAPI) GetCloudLimits() (*model.ProductLimits, error) { // RegisterCollectionAndTopic informs the server that this plugin handles // the given collection and topic types. func (api *PluginAPI) RegisterCollectionAndTopic(collectionType, topicType string) error { - return api.app.registerCollectionAndTopic(api.id, collectionType, topicType) + return api.app.Srv().pluginService.registerCollectionAndTopic(api.id, collectionType, topicType) } func (api *PluginAPI) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index 9f864109bf..dc51f58421 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -92,7 +92,7 @@ func setupMultiPluginAPITest(t *testing.T, pluginCodes []string, pluginManifests return app.NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv().Platform()), pluginDir, webappPluginDir, app.Log(), nil) require.NoError(t, err) require.Equal(t, len(pluginCodes), len(pluginIDs)) @@ -119,7 +119,7 @@ func setupMultiPluginAPITest(t *testing.T, pluginCodes []string, pluginManifests }) } - app.ch.SetPluginsEnvironment(env) + app.PluginService().SetPluginsEnvironment(env) return pluginDir } @@ -849,7 +849,7 @@ func TestPluginAPIGetPlugins(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server.Platform()), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) pluginIDs := []string{"pluginid1", "pluginid2", "pluginid3"} @@ -866,7 +866,7 @@ func TestPluginAPIGetPlugins(t *testing.T) { require.True(t, activated) pluginManifests = append(pluginManifests, manifest) } - th.App.ch.SetPluginsEnvironment(env) + th.App.PluginService().SetPluginsEnvironment(env) // Deactivate the last one for testing success := env.Deactivate(pluginIDs[len(pluginIDs)-1]) @@ -937,10 +937,10 @@ func TestInstallPlugin(t *testing.T) { return app.NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv().Platform()), pluginDir, webappPluginDir, app.Log(), nil) require.NoError(t, err) - app.ch.SetPluginsEnvironment(env) + app.PluginService().SetPluginsEnvironment(env) backend := filepath.Join(pluginDir, pluginID, "backend.exe") utils.CompileGo(t, pluginCode, backend) @@ -1632,10 +1632,10 @@ func TestAPIMetrics(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server.Platform()), pluginDir, webappPluginDir, th.App.Log(), metricsMock) require.NoError(t, err) - th.App.ch.SetPluginsEnvironment(env) + th.App.PluginService().SetPluginsEnvironment(env) pluginID := model.NewId() backend := filepath.Join(pluginDir, pluginID, "backend.exe") @@ -2079,10 +2079,10 @@ func TestRegisterCollectionAndTopic(t *testing.T) { return th.App.NewPluginAPI(th.Context, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv().Platform()), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) - th.App.ch.SetPluginsEnvironment(env) + th.App.PluginService().SetPluginsEnvironment(env) pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` @@ -2179,10 +2179,10 @@ func TestPluginUploadsAPI(t *testing.T) { newPluginAPI := func(manifest *model.Manifest) plugin.API { return th.App.NewPluginAPI(th.Context, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv().Platform()), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) - th.App.ch.SetPluginsEnvironment(env) + th.App.PluginService().SetPluginsEnvironment(env) pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` diff --git a/app/plugin_commands.go b/app/plugin_commands.go index e70c69a38a..8d034931e9 100644 --- a/app/plugin_commands.go +++ b/app/plugin_commands.go @@ -22,6 +22,10 @@ type PluginCommand struct { } func (a *App) RegisterPluginCommand(pluginID string, command *model.Command) error { + return a.Srv().pluginService.registerPluginCommand(pluginID, command) +} + +func (s *PluginService) registerPluginCommand(pluginID string, command *model.Command) error { if command.Trigger == "" { return errors.New("invalid command") } @@ -55,10 +59,10 @@ func (a *App) RegisterPluginCommand(pluginID string, command *model.Command) err AutocompleteIconData: command.AutocompleteIconData, } - a.ch.pluginCommandsLock.Lock() - defer a.ch.pluginCommandsLock.Unlock() + s.pluginCommandsLock.Lock() + defer s.pluginCommandsLock.Unlock() - for _, pc := range a.ch.pluginCommands { + for _, pc := range s.pluginCommands { if pc.Command.Trigger == command.Trigger && pc.Command.TeamId == command.TeamId { if pc.PluginId == pluginID { pc.Command = command @@ -67,7 +71,7 @@ func (a *App) RegisterPluginCommand(pluginID string, command *model.Command) err } } - a.ch.pluginCommands = append(a.ch.pluginCommands, &PluginCommand{ + s.pluginCommands = append(s.pluginCommands, &PluginCommand{ Command: command, PluginId: pluginID, }) @@ -75,39 +79,47 @@ func (a *App) RegisterPluginCommand(pluginID string, command *model.Command) err } func (a *App) UnregisterPluginCommand(pluginID, teamID, trigger string) { + a.Srv().pluginService.unregisterPluginCommand(pluginID, teamID, trigger) +} + +func (s *PluginService) unregisterPluginCommand(pluginID, teamID, trigger string) { trigger = strings.ToLower(trigger) - a.ch.pluginCommandsLock.Lock() - defer a.ch.pluginCommandsLock.Unlock() + s.pluginCommandsLock.Lock() + defer s.pluginCommandsLock.Unlock() var remaining []*PluginCommand - for _, pc := range a.ch.pluginCommands { + for _, pc := range s.pluginCommands { if pc.Command.TeamId != teamID || pc.Command.Trigger != trigger { remaining = append(remaining, pc) } } - a.ch.pluginCommands = remaining + s.pluginCommands = remaining } -func (ch *Channels) unregisterPluginCommands(pluginID string) { - ch.pluginCommandsLock.Lock() - defer ch.pluginCommandsLock.Unlock() +func (s *PluginService) unregisterPluginCommands(pluginID string) { + s.pluginCommandsLock.Lock() + defer s.pluginCommandsLock.Unlock() var remaining []*PluginCommand - for _, pc := range ch.pluginCommands { + for _, pc := range s.pluginCommands { if pc.PluginId != pluginID { remaining = append(remaining, pc) } } - ch.pluginCommands = remaining + s.pluginCommands = remaining } func (a *App) PluginCommandsForTeam(teamID string) []*model.Command { - a.ch.pluginCommandsLock.RLock() - defer a.ch.pluginCommandsLock.RUnlock() + return a.Srv().pluginService.PluginCommandsForTeam(teamID) +} + +func (s *PluginService) PluginCommandsForTeam(teamID string) []*model.Command { + s.pluginCommandsLock.RLock() + defer s.pluginCommandsLock.RUnlock() var commands []*model.Command - for _, pc := range a.ch.pluginCommands { + for _, pc := range s.pluginCommands { if pc.Command.TeamId == "" || pc.Command.TeamId == teamID { commands = append(commands, pc.Command) } @@ -115,6 +127,24 @@ func (a *App) PluginCommandsForTeam(teamID string) []*model.Command { return commands } +func (s *PluginService) getPluginCommandFromArgs(args *model.CommandArgs) *PluginCommand { + parts := strings.Split(args.Command, " ") + trigger := parts[0][1:] + trigger = strings.ToLower(trigger) + + var matched *PluginCommand + s.pluginCommandsLock.RLock() + for _, pc := range s.pluginCommands { + if (pc.Command.TeamId == "" || pc.Command.TeamId == args.TeamId) && pc.Command.Trigger == trigger { + matched = pc + break + } + } + s.pluginCommandsLock.RUnlock() + + return matched +} + // tryExecutePluginCommand attempts to run a command provided by a plugin based on the given arguments. If no such // command can be found, returns nil for all arguments. func (a *App) tryExecutePluginCommand(c request.CTX, args *model.CommandArgs) (*model.Command, *model.CommandResponse, *model.AppError) { @@ -122,15 +152,7 @@ func (a *App) tryExecutePluginCommand(c request.CTX, args *model.CommandArgs) (* trigger := parts[0][1:] trigger = strings.ToLower(trigger) - var matched *PluginCommand - a.ch.pluginCommandsLock.RLock() - for _, pc := range a.ch.pluginCommands { - if (pc.Command.TeamId == "" || pc.Command.TeamId == args.TeamId) && pc.Command.Trigger == trigger { - matched = pc - break - } - } - a.ch.pluginCommandsLock.RUnlock() + matched := a.Srv().pluginService.getPluginCommandFromArgs(args) if matched == nil { return nil, nil, nil } diff --git a/app/plugin_commands_test.go b/app/plugin_commands_test.go index e56cf74718..1cf9751d9a 100644 --- a/app/plugin_commands_test.go +++ b/app/plugin_commands_test.go @@ -106,7 +106,7 @@ func TestPluginCommand(t *testing.T) { require.NotEqual(t, "plugin", commands.Trigger) } - th.App.ch.RemovePlugin(pluginIDs[0]) + th.App.PluginService().RemovePlugin(pluginIDs[0]) }) t.Run("re-entrant command registration on config change", func(t *testing.T) { @@ -207,7 +207,7 @@ func TestPluginCommand(t *testing.T) { killed = true } - th.App.ch.RemovePlugin(pluginIDs[0]) + th.App.PluginService().RemovePlugin(pluginIDs[0]) require.False(t, killed, "execute command appears to have deadlocked") }) @@ -285,7 +285,7 @@ func TestPluginCommand(t *testing.T) { require.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) require.Equal(t, "text", resp.Text) - th.App.ch.RemovePlugin(pluginIDs[0]) + th.App.PluginService().RemovePlugin(pluginIDs[0]) }) t.Run("plugin has crashed before execution of command", func(t *testing.T) { tearDown, pluginIDs, activationErrors := SetAppEnvironmentWithPlugins(t, []string{` @@ -329,7 +329,7 @@ func TestPluginCommand(t *testing.T) { require.Nil(t, resp) require.NotNil(t, err) require.Equal(t, err.Id, "model.plugin_command_error.error.app_error") - th.App.ch.RemovePlugin(pluginIDs[0]) + th.App.PluginService().RemovePlugin(pluginIDs[0]) }) t.Run("plugin has crashed due to the execution of the command", func(t *testing.T) { @@ -374,7 +374,7 @@ func TestPluginCommand(t *testing.T) { require.Nil(t, resp) require.NotNil(t, err) require.Equal(t, err.Id, "model.plugin_command_crash.error.app_error") - th.App.ch.RemovePlugin(pluginIDs[0]) + th.App.PluginService().RemovePlugin(pluginIDs[0]) }) t.Run("plugin returning status code 0", func(t *testing.T) { diff --git a/app/plugin_db_driver.go b/app/plugin_db_driver.go index 753bd0671c..1d47ac3457 100644 --- a/app/plugin_db_driver.go +++ b/app/plugin_db_driver.go @@ -9,6 +9,7 @@ import ( "database/sql/driver" "sync" + "github.com/mattermost/mattermost-server/v6/app/platform" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" ) @@ -18,7 +19,7 @@ import ( // a new entry tracked centrally in a map. Further requests operate on the // object ID. type DriverImpl struct { - s *Server + ps *platform.PlatformService connMut sync.RWMutex connMap map[string]*sql.Conn txMut sync.Mutex @@ -29,9 +30,9 @@ type DriverImpl struct { rowsMap map[string]driver.Rows } -func NewDriverImpl(s *Server) *DriverImpl { +func NewDriverImpl(s *platform.PlatformService) *DriverImpl { return &DriverImpl{ - s: s, + ps: s, connMap: make(map[string]*sql.Conn), txMap: make(map[string]driver.Tx), stMap: make(map[string]driver.Stmt), @@ -40,9 +41,9 @@ func NewDriverImpl(s *Server) *DriverImpl { } func (d *DriverImpl) Conn(isMaster bool) (string, error) { - dbFunc := d.s.Platform().Store.GetInternalMasterDB + dbFunc := d.ps.Store.GetInternalMasterDB if !isMaster { - dbFunc = d.s.Platform().Store.GetInternalReplicaDB + dbFunc = d.ps.Store.GetInternalReplicaDB } conn, err := dbFunc().Conn(context.Background()) if err != nil { diff --git a/app/plugin_event.go b/app/plugin_event.go index c30e2d1af5..19d9f1dabc 100644 --- a/app/plugin_event.go +++ b/app/plugin_event.go @@ -9,10 +9,10 @@ import ( "github.com/mattermost/mattermost-server/v6/model" ) -func (ch *Channels) notifyClusterPluginEvent(event model.ClusterEvent, data model.PluginEventData) { +func (s *PluginService) notifyClusterPluginEvent(event model.ClusterEvent, data model.PluginEventData) { buf, _ := json.Marshal(data) - if ch.srv.platform.Cluster() != nil { - ch.srv.platform.Cluster().SendClusterMessage(&model.ClusterMessage{ + if s.platform.Cluster() != nil { + s.platform.Cluster().SendClusterMessage(&model.ClusterMessage{ Event: event, SendType: model.ClusterSendReliable, WaitForAllToSend: true, diff --git a/app/plugin_hooks_test.go b/app/plugin_hooks_test.go index 1b1673be93..64665f2419 100644 --- a/app/plugin_hooks_test.go +++ b/app/plugin_hooks_test.go @@ -33,10 +33,10 @@ func SetAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, a webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) - env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv().Platform()), pluginDir, webappPluginDir, app.Log(), nil) require.NoError(t, err) - app.ch.SetPluginsEnvironment(env) + app.PluginService().SetPluginsEnvironment(env) pluginIDs := []string{} activationErrors := []error{} for _, code := range pluginCode { @@ -1030,10 +1030,10 @@ func TestHookMetrics(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server.Platform()), pluginDir, webappPluginDir, th.App.Log(), metricsMock) require.NoError(t, err) - th.App.ch.SetPluginsEnvironment(env) + th.App.PluginService().SetPluginsEnvironment(env) pluginID := model.NewId() backend := filepath.Join(pluginDir, pluginID, "backend.exe") diff --git a/app/plugin_install.go b/app/plugin_install.go index 431c3abebe..f0c54a0da4 100644 --- a/app/plugin_install.go +++ b/app/plugin_install.go @@ -58,10 +58,10 @@ const managedPluginFileName = ".filestore" // fileStorePluginFolder is the folder name in the file store of the plugin bundles installed. const fileStorePluginFolder = "plugins" -func (ch *Channels) installPluginFromData(data model.PluginEventData) { +func (s *PluginService) installPluginFromData(data model.PluginEventData) { mlog.Debug("Installing plugin as per cluster message", mlog.String("plugin_id", data.Id)) - pluginSignaturePathMap, appErr := ch.getPluginsFromFolder() + pluginSignaturePathMap, appErr := s.getPluginsFromFolder() if appErr != nil { mlog.Error("Failed to get plugin signatures from filestore. Can't install plugin from data.", mlog.Err(appErr)) return @@ -72,53 +72,53 @@ func (ch *Channels) installPluginFromData(data model.PluginEventData) { return } - reader, appErr := ch.srv.fileReader(plugin.path) - if appErr != nil { - mlog.Error("Failed to open plugin bundle from file store.", mlog.String("bundle", plugin.path), mlog.Err(appErr)) + reader, err := s.fileStore.Reader(plugin.path) + if err != nil { + mlog.Error("Failed to open plugin bundle from file store.", mlog.String("bundle", plugin.path), mlog.Err(err)) return } defer reader.Close() var signature filestore.ReadCloseSeeker - if *ch.cfgSvc.Config().PluginSettings.RequirePluginSignature { - signature, appErr = ch.srv.fileReader(plugin.signaturePath) - if appErr != nil { - mlog.Error("Failed to open plugin signature from file store.", mlog.Err(appErr)) + if *s.platform.Config().PluginSettings.RequirePluginSignature { + signature, err = s.fileStore.Reader(plugin.signaturePath) + if err != nil { + mlog.Error("Failed to open plugin signature from file store.", mlog.Err(err)) return } defer signature.Close() } - manifest, appErr := ch.installPluginLocally(reader, signature, installPluginLocallyAlways) + manifest, appErr := s.installPluginLocally(reader, signature, installPluginLocallyAlways) if appErr != nil { mlog.Error("Failed to sync plugin from file store", mlog.String("bundle", plugin.path), mlog.Err(appErr)) return } - if err := ch.notifyPluginEnabled(manifest); err != nil { - mlog.Error("Failed notify plugin enabled", mlog.Err(err)) + if err2 := s.notifyPluginEnabled(manifest); err2 != nil { + mlog.Error("Failed notify plugin enabled", mlog.Err(err2)) } - if err := ch.notifyPluginStatusesChanged(); err != nil { - mlog.Error("Failed to notify plugin status changed", mlog.Err(err)) + if err2 := s.notifyPluginStatusesChanged(); err2 != nil { + mlog.Error("Failed to notify plugin status changed", mlog.Err(err2)) } } -func (ch *Channels) removePluginFromData(data model.PluginEventData) { +func (s *PluginService) removePluginFromData(data model.PluginEventData) { mlog.Debug("Removing plugin as per cluster message", mlog.String("plugin_id", data.Id)) - if err := ch.removePluginLocally(data.Id); err != nil { + if err := s.removePluginLocally(data.Id); err != nil { mlog.Warn("Failed to remove plugin locally", mlog.Err(err), mlog.String("id", data.Id)) } - if err := ch.notifyPluginStatusesChanged(); err != nil { + if err := s.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } } // InstallPluginWithSignature verifies and installs plugin. -func (ch *Channels) installPluginWithSignature(pluginFile, signature io.ReadSeeker) (*model.Manifest, *model.AppError) { - return ch.installPlugin(pluginFile, signature, installPluginLocallyAlways) +func (s *PluginService) installPluginWithSignature(pluginFile, signature io.ReadSeeker) (*model.Manifest, *model.AppError) { + return s.installPlugin(pluginFile, signature, installPluginLocallyAlways) } // InstallPlugin unpacks and installs a plugin but does not enable or activate it. @@ -132,40 +132,40 @@ func (a *App) InstallPlugin(pluginFile io.ReadSeeker, replace bool) (*model.Mani } func (a *App) installPlugin(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - return a.ch.installPlugin(pluginFile, signature, installationStrategy) + return a.ch.srv.pluginService.installPlugin(pluginFile, signature, installationStrategy) } -func (ch *Channels) installPlugin(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - manifest, appErr := ch.installPluginLocally(pluginFile, signature, installationStrategy) +func (s *PluginService) installPlugin(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { + manifest, appErr := s.installPluginLocally(pluginFile, signature, installationStrategy) if appErr != nil { return nil, appErr } if signature != nil { signature.Seek(0, 0) - if _, appErr = ch.srv.writeFile(signature, getSignatureStorePath(manifest.Id)); appErr != nil { - return nil, model.NewAppError("saveSignature", "app.plugin.store_signature.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) + if _, err := s.fileStore.WriteFile(signature, getSignatureStorePath(manifest.Id)); err != nil { + return nil, model.NewAppError("saveSignature", "app.plugin.store_signature.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } } // Store bundle in the file store to allow access from other servers. pluginFile.Seek(0, 0) - if _, appErr := ch.srv.writeFile(pluginFile, getBundleStorePath(manifest.Id)); appErr != nil { + if _, appErr := s.fileStore.WriteFile(pluginFile, getBundleStorePath(manifest.Id)); appErr != nil { return nil, model.NewAppError("uploadPlugin", "app.plugin.store_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) } - ch.notifyClusterPluginEvent( + s.notifyClusterPluginEvent( model.ClusterEventInstallPlugin, model.PluginEventData{ Id: manifest.Id, }, ) - if err := ch.notifyPluginEnabled(manifest); err != nil { + if err := s.notifyPluginEnabled(manifest); err != nil { mlog.Warn("Failed notify plugin enabled", mlog.Err(err)) } - if err := ch.notifyPluginStatusesChanged(); err != nil { + if err := s.notifyPluginStatusesChanged(); err != nil { mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } @@ -174,10 +174,10 @@ func (ch *Channels) installPlugin(pluginFile, signature io.ReadSeeker, installat // InstallMarketplacePlugin installs a plugin listed in the marketplace server. It will get the plugin bundle // from the prepackaged folder, if available, or remotely if EnableRemoteMarketplace is true. -func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePluginRequest) (*model.Manifest, *model.AppError) { +func (s *PluginService) InstallMarketplacePlugin(request *model.InstallMarketplacePluginRequest) (*model.Manifest, *model.AppError) { var pluginFile, signatureFile io.ReadSeeker - prepackagedPlugin, appErr := ch.getPrepackagedPlugin(request.Id, request.Version) + prepackagedPlugin, appErr := s.getPrepackagedPlugin(request.Id, request.Version) if appErr != nil && appErr.Id != "app.plugin.marketplace_plugins.not_found.app_error" { return nil, appErr } @@ -192,9 +192,9 @@ func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePl signatureFile = bytes.NewReader(prepackagedPlugin.Signature) } - if *ch.cfgSvc.Config().PluginSettings.EnableRemoteMarketplace { + if *s.platform.Config().PluginSettings.EnableRemoteMarketplace { var plugin *model.BaseMarketplacePlugin - plugin, appErr = ch.getRemoteMarketplacePlugin(request.Id, request.Version) + plugin, appErr = s.getRemoteMarketplacePlugin(request.Id, request.Version) if appErr != nil { return nil, appErr } @@ -214,7 +214,7 @@ func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePl } if prepackagedVersion.LT(marketplaceVersion) { // Always true if no prepackaged plugin was found - downloadedPluginBytes, err := ch.srv.downloadFromURL(plugin.DownloadURL) + downloadedPluginBytes, err := s.downloadFromURL(plugin.DownloadURL) if err != nil { return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -234,7 +234,7 @@ func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePl return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.marketplace_plugins.signature_not_found.app_error", nil, "", http.StatusInternalServerError) } - manifest, appErr := ch.installPluginWithSignature(pluginFile, signatureFile) + manifest, appErr := s.installPluginWithSignature(pluginFile, signatureFile) if appErr != nil { return nil, appErr } @@ -253,15 +253,15 @@ const ( installPluginLocallyAlways ) -func (ch *Channels) installPluginLocally(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - pluginsEnvironment := ch.GetPluginsEnvironment() +func (s *PluginService) installPluginLocally(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("installPluginLocally", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } // verify signature if signature != nil { - if err := ch.verifyPlugin(pluginFile, signature); err != nil { + if err := s.verifyPlugin(pluginFile, signature); err != nil { return nil, err } } @@ -277,7 +277,7 @@ func (ch *Channels) installPluginLocally(pluginFile, signature io.ReadSeeker, in return nil, appErr } - manifest, appErr = ch.installExtractedPlugin(manifest, pluginDir, installationStrategy) + manifest, appErr = s.installExtractedPlugin(manifest, pluginDir, installationStrategy) if appErr != nil { return nil, appErr } @@ -312,8 +312,8 @@ func extractPlugin(pluginFile io.ReadSeeker, extractDir string) (*model.Manifest return manifest, extractDir, nil } -func (ch *Channels) installExtractedPlugin(manifest *model.Manifest, fromPluginDir string, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - pluginsEnvironment := ch.GetPluginsEnvironment() +func (s *PluginService) installExtractedPlugin(manifest *model.Manifest, fromPluginDir string, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("installExtractedPlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -360,12 +360,12 @@ func (ch *Channels) installExtractedPlugin(manifest *model.Manifest, fromPluginD // Otherwise remove the existing installation prior to install below. mlog.Debug("Removing existing installation of plugin before local install", mlog.String("plugin_id", existingManifest.Id), mlog.String("version", existingManifest.Version)) - if err := ch.removePluginLocally(existingManifest.Id); err != nil { + if err := s.removePluginLocally(existingManifest.Id); err != nil { return nil, model.NewAppError("installExtractedPlugin", "app.plugin.install_id_failed_remove.app_error", nil, "", http.StatusBadRequest) } } - pluginPath := filepath.Join(*ch.cfgSvc.Config().PluginSettings.Directory, manifest.Id) + pluginPath := filepath.Join(*s.platform.Config().PluginSettings.Directory, manifest.Id) err = utils.CopyDir(fromPluginDir, pluginPath) if err != nil { return nil, model.NewAppError("installExtractedPlugin", "app.plugin.mvdir.app_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -387,9 +387,9 @@ func (ch *Channels) installExtractedPlugin(manifest *model.Manifest, fromPluginD } // Activate the plugin if enabled. - pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[manifest.Id] + pluginState := s.platform.Config().PluginSettings.PluginStates[manifest.Id] if pluginState != nil && pluginState.Enable { - if hasOverride, enabled := ch.getPluginStateOverride(manifest.Id); hasOverride && !enabled { + if hasOverride, enabled := s.getPluginStateOverride(manifest.Id); hasOverride && !enabled { return manifest, nil } @@ -405,49 +405,49 @@ func (ch *Channels) installExtractedPlugin(manifest *model.Manifest, fromPluginD return manifest, nil } -func (ch *Channels) RemovePlugin(id string) *model.AppError { +func (s *PluginService) RemovePlugin(id string) *model.AppError { // Disable plugin before removal to make sure this // plugin remains disabled on re-install. - if err := ch.disablePlugin(id); err != nil { + if err := s.disablePlugin(id); err != nil { return err } - if err := ch.removePluginLocally(id); err != nil { + if err := s.removePluginLocally(id); err != nil { return err } // Remove bundle from the file store. storePluginFileName := getBundleStorePath(id) - bundleExist, err := ch.srv.fileExists(storePluginFileName) + bundleExist, err := s.fileStore.FileExists(storePluginFileName) if err != nil { return model.NewAppError("removePlugin", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } if !bundleExist { return nil } - if err = ch.srv.removeFile(storePluginFileName); err != nil { + if err = s.fileStore.RemoveFile(storePluginFileName); err != nil { return model.NewAppError("removePlugin", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - if err = ch.removeSignature(id); err != nil { - mlog.Warn("Can't remove signature", mlog.Err(err)) + if err2 := s.removeSignature(id); err2 != nil { + mlog.Warn("Can't remove signature", mlog.Err(err2)) } - ch.notifyClusterPluginEvent( + s.notifyClusterPluginEvent( model.ClusterEventRemovePlugin, model.PluginEventData{ Id: id, }, ) - if err := ch.notifyPluginStatusesChanged(); err != nil { + if err := s.notifyPluginStatusesChanged(); err != nil { mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } return nil } -func (ch *Channels) removePluginLocally(id string) *model.AppError { - pluginsEnvironment := ch.GetPluginsEnvironment() +func (s *PluginService) removePluginLocally(id string) *model.AppError { + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return model.NewAppError("removePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -473,7 +473,7 @@ func (ch *Channels) removePluginLocally(id string) *model.AppError { pluginsEnvironment.Deactivate(id) pluginsEnvironment.RemovePlugin(id) - ch.unregisterPluginCommands(id) + s.unregisterPluginCommands(id) if err := os.RemoveAll(pluginPath); err != nil { return model.NewAppError("removePlugin", "app.plugin.remove.app_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -482,9 +482,9 @@ func (ch *Channels) removePluginLocally(id string) *model.AppError { return nil } -func (ch *Channels) removeSignature(pluginID string) *model.AppError { +func (s *PluginService) removeSignature(pluginID string) *model.AppError { filePath := getSignatureStorePath(pluginID) - exists, err := ch.srv.fileExists(filePath) + exists, err := s.fileStore.FileExists(filePath) if err != nil { return model.NewAppError("removeSignature", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -492,7 +492,7 @@ func (ch *Channels) removeSignature(pluginID string) *model.AppError { mlog.Debug("no plugin signature to remove", mlog.String("plugin_id", pluginID)) return nil } - if err = ch.srv.removeFile(filePath); err != nil { + if err = s.fileStore.RemoveFile(filePath); err != nil { return model.NewAppError("removeSignature", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } return nil diff --git a/app/plugin_install_test.go b/app/plugin_install_test.go index a3eb6cfb2d..ca15747865 100644 --- a/app/plugin_install_test.go +++ b/app/plugin_install_test.go @@ -73,7 +73,7 @@ func TestInstallPluginLocally(t *testing.T) { th := Setup(t) defer th.TearDown() - actualManifest, appErr := th.App.ch.installPluginLocally(&nilReadSeeker{}, nil, installPluginLocallyOnlyIfNew) + actualManifest, appErr := th.App.PluginService().installPluginLocally(&nilReadSeeker{}, nil, installPluginLocallyOnlyIfNew) require.NotNil(t, appErr) assert.Equal(t, "app.plugin.extract.app_error", appErr.Id, appErr.Error()) require.Nil(t, actualManifest) @@ -87,7 +87,7 @@ func TestInstallPluginLocally(t *testing.T) { {"test", "test file"}, }) - actualManifest, appErr := th.App.ch.installPluginLocally(reader, nil, installPluginLocallyOnlyIfNew) + actualManifest, appErr := th.App.PluginService().installPluginLocally(reader, nil, installPluginLocallyOnlyIfNew) require.NotNil(t, appErr) assert.Equal(t, "app.plugin.manifest.app_error", appErr.Id, appErr.Error()) require.Nil(t, actualManifest) @@ -106,7 +106,7 @@ func TestInstallPluginLocally(t *testing.T) { {"plugin.json", string(manifestJSON)}, }) - actualManifest, appError := th.App.ch.installPluginLocally(reader, nil, installationStrategy) + actualManifest, appError := th.App.PluginService().installPluginLocally(reader, nil, installationStrategy) if actualManifest != nil { require.Equal(t, manifest, actualManifest) } @@ -134,7 +134,7 @@ func TestInstallPluginLocally(t *testing.T) { require.NoError(t, err) for _, bundleInfo := range bundleInfos { - err := th.App.ch.removePluginLocally(bundleInfo.Manifest.Id) + err := th.App.PluginService().removePluginLocally(bundleInfo.Manifest.Id) require.Nilf(t, err, "failed to remove existing plugin %s", bundleInfo.Manifest.Id) } } diff --git a/app/plugin_requests.go b/app/plugin_requests.go index 1ccc966822..208adb11f4 100644 --- a/app/plugin_requests.go +++ b/app/plugin_requests.go @@ -20,16 +20,16 @@ import ( "github.com/mattermost/mattermost-server/v6/utils" ) -func (ch *Channels) ServePluginRequest(w http.ResponseWriter, r *http.Request) { +func (s *PluginService) ServePluginRequest(w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) - if handler, ok := ch.routerSvc.getHandler(params["plugin_id"]); ok { - ch.servePluginRequest(w, r, func(*plugin.Context, http.ResponseWriter, *http.Request) { + if handler, ok := s.channels.routerSvc.getHandler(params["plugin_id"]); ok { + s.servePluginRequest(w, r, func(*plugin.Context, http.ResponseWriter, *http.Request) { handler.ServeHTTP(w, r) }) return } - pluginsEnvironment := ch.GetPluginsEnvironment() + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { err := model.NewAppError("ServePluginRequest", "app.plugin.disabled.app_error", nil, "Enable plugins to serve plugin requests", http.StatusNotImplemented) mlog.Error(err.Error()) @@ -49,11 +49,11 @@ func (ch *Channels) ServePluginRequest(w http.ResponseWriter, r *http.Request) { return } - ch.servePluginRequest(w, r, hooks.ServeHTTP) + s.servePluginRequest(w, r, hooks.ServeHTTP) } func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) { - pluginsEnvironment := a.ch.GetPluginsEnvironment() + pluginsEnvironment := a.ch.srv.pluginService.GetPluginsEnvironment() if pluginsEnvironment == nil { err := model.NewAppError("ServeInterPluginRequest", "app.plugin.disabled.app_error", nil, "Plugin environment not found.", http.StatusNotImplemented) a.Log().Error(err.Error()) @@ -87,7 +87,7 @@ func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, so // ServePluginPublicRequest serves public plugin files // at the URL http(s)://$SITE_URL/plugins/$PLUGIN_ID/public/{anything} -func (ch *Channels) ServePluginPublicRequest(w http.ResponseWriter, r *http.Request) { +func (s *PluginService) ServePluginPublicRequest(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/") { http.NotFound(w, r) return @@ -97,7 +97,7 @@ func (ch *Channels) ServePluginPublicRequest(w http.ResponseWriter, r *http.Requ vars := mux.Vars(r) pluginID := vars["plugin_id"] - pluginsEnv := ch.GetPluginsEnvironment() + pluginsEnv := s.GetPluginsEnvironment() // Check if someone has nullified the pluginsEnv in the meantime if pluginsEnv == nil { @@ -121,11 +121,11 @@ func (ch *Channels) ServePluginPublicRequest(w http.ResponseWriter, r *http.Requ http.ServeFile(w, r, publicFile) } -func (ch *Channels) servePluginRequest(w http.ResponseWriter, r *http.Request, handler func(*plugin.Context, http.ResponseWriter, *http.Request)) { +func (s *PluginService) servePluginRequest(w http.ResponseWriter, r *http.Request, handler func(*plugin.Context, http.ResponseWriter, *http.Request)) { token := "" context := &plugin.Context{ RequestId: model.NewId(), - IPAddress: utils.GetIPAddress(r, ch.cfgSvc.Config().ServiceSettings.TrustedProxyIPHeader), + IPAddress: utils.GetIPAddress(r, s.platform.Config().ServiceSettings.TrustedProxyIPHeader), AcceptLanguage: r.Header.Get("Accept-Language"), UserAgent: r.UserAgent(), } @@ -148,8 +148,8 @@ func (ch *Channels) servePluginRequest(w http.ResponseWriter, r *http.Request, h r.Header.Del("Mattermost-User-Id") if token != "" { - session, err := New(ServerConnector(ch)).GetSession(token) - defer ch.srv.platform.ReturnSessionToPool(session) + session, err := New(ServerConnector(s.channels)).GetSession(token) + defer s.platform.ReturnSessionToPool(session) csrfCheckPassed := false @@ -190,7 +190,7 @@ func (ch *Channels) servePluginRequest(w http.ResponseWriter, r *http.Request, h mlog.String("user_id", userID), } - if *ch.cfgSvc.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement { + if *s.platform.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement { mlog.Warn(csrfErrorMessage, fields...) } else { mlog.Debug(csrfErrorMessage, fields...) @@ -219,7 +219,7 @@ func (ch *Channels) servePluginRequest(w http.ResponseWriter, r *http.Request, h params := mux.Vars(r) - subpath, _ := utils.GetSubpathFromConfig(ch.cfgSvc.Config()) + subpath, _ := utils.GetSubpathFromConfig(s.platform.Config()) newQuery := r.URL.Query() newQuery.Del("access_token") diff --git a/app/plugin_requests_test.go b/app/plugin_requests_test.go index c41c70be6d..e457d8e5f1 100644 --- a/app/plugin_requests_test.go +++ b/app/plugin_requests_test.go @@ -24,7 +24,7 @@ func TestServePluginPublicRequest(t *testing.T) { require.NoError(t, err) rr := httptest.NewRecorder() - handler := http.HandlerFunc(th.App.ch.ServePluginPublicRequest) + handler := http.HandlerFunc(th.App.PluginService().ServePluginPublicRequest) handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) diff --git a/app/plugin_shutdown_test.go b/app/plugin_shutdown_test.go index 293d882f1f..1c77fc3814 100644 --- a/app/plugin_shutdown_test.go +++ b/app/plugin_shutdown_test.go @@ -63,7 +63,7 @@ func TestPluginShutdownTest(t *testing.T) { done := make(chan bool) go func() { defer close(done) - th.App.ch.ShutDownPlugins() + th.App.PluginService().ShutDownPlugins() }() select { diff --git a/app/plugin_signature.go b/app/plugin_signature.go index 0903aa08fc..928a9687b8 100644 --- a/app/plugin_signature.go +++ b/app/plugin_signature.go @@ -73,16 +73,16 @@ func (a *App) DeletePublicKey(name string) *model.AppError { // VerifyPlugin checks that the given signature corresponds to the given plugin and matches a trusted certificate. func (a *App) VerifyPlugin(plugin, signature io.ReadSeeker) *model.AppError { - return a.ch.verifyPlugin(plugin, signature) + return a.ch.srv.pluginService.verifyPlugin(plugin, signature) } -func (ch *Channels) verifyPlugin(plugin, signature io.ReadSeeker) *model.AppError { +func (s *PluginService) verifyPlugin(plugin, signature io.ReadSeeker) *model.AppError { if err := verifySignature(bytes.NewReader(mattermostPluginPublicKey), plugin, signature); err == nil { return nil } - publicKeys := ch.cfgSvc.Config().PluginSettings.SignaturePublicKeyFiles + publicKeys := s.platform.Config().PluginSettings.SignaturePublicKeyFiles for _, pk := range publicKeys { - pkBytes, appErr := ch.srv.getPublicKey(pk) + pkBytes, appErr := s.platform.GetConfigFile(pk) if appErr != nil { mlog.Warn("Unable to get public key for ", mlog.String("filename", pk)) continue diff --git a/app/plugin_statuses.go b/app/plugin_statuses.go index 399d58e5b2..2b27d7520c 100644 --- a/app/plugin_statuses.go +++ b/app/plugin_statuses.go @@ -10,8 +10,8 @@ import ( ) // GetPluginStatus returns the status for a plugin installed on this server. -func (ch *Channels) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) { - pluginsEnvironment := ch.GetPluginsEnvironment() +func (s *PluginService) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) { + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("GetPluginStatus", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -24,8 +24,8 @@ func (ch *Channels) GetPluginStatus(id string) (*model.PluginStatus, *model.AppE for _, status := range pluginStatuses { if status.PluginId == id { // Add our cluster ID - if ch.srv.platform.Cluster() != nil { - status.ClusterId = ch.srv.platform.Cluster().GetClusterId() + if s.platform.Cluster() != nil { + status.ClusterId = s.platform.Cluster().GetClusterId() } return status, nil @@ -37,12 +37,12 @@ func (ch *Channels) GetPluginStatus(id string) (*model.PluginStatus, *model.AppE // GetPluginStatus returns the status for a plugin installed on this server. func (a *App) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) { - return a.ch.GetPluginStatus(id) + return a.ch.srv.pluginService.GetPluginStatus(id) } // GetPluginStatuses returns the status for plugins installed on this server. -func (ch *Channels) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { - pluginsEnvironment := ch.GetPluginsEnvironment() +func (s *PluginService) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { + pluginsEnvironment := s.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("GetPluginStatuses", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -54,8 +54,8 @@ func (ch *Channels) GetPluginStatuses() (model.PluginStatuses, *model.AppError) // Add our cluster ID for _, status := range pluginStatuses { - if ch.srv.platform.Cluster() != nil { - status.ClusterId = ch.srv.platform.Cluster().GetClusterId() + if s.platform.Cluster() != nil { + status.ClusterId = s.platform.Cluster().GetClusterId() } else { status.ClusterId = "" } @@ -66,22 +66,22 @@ func (ch *Channels) GetPluginStatuses() (model.PluginStatuses, *model.AppError) // GetPluginStatuses returns the status for plugins installed on this server. func (a *App) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { - return a.ch.GetPluginStatuses() + return a.ch.srv.pluginService.GetPluginStatuses() } // GetClusterPluginStatuses returns the status for plugins installed anywhere in the cluster. func (a *App) GetClusterPluginStatuses() (model.PluginStatuses, *model.AppError) { - return a.ch.getClusterPluginStatuses() + return a.ch.srv.pluginService.getClusterPluginStatuses() } -func (ch *Channels) getClusterPluginStatuses() (model.PluginStatuses, *model.AppError) { - pluginStatuses, err := ch.GetPluginStatuses() +func (s *PluginService) getClusterPluginStatuses() (model.PluginStatuses, *model.AppError) { + pluginStatuses, err := s.GetPluginStatuses() if err != nil { return nil, err } - if ch.srv.platform.Cluster() != nil && *ch.cfgSvc.Config().ClusterSettings.Enable { - clusterPluginStatuses, err := ch.srv.platform.Cluster().GetPluginStatuses() + if s.platform.Cluster() != nil && *s.platform.Config().ClusterSettings.Enable { + clusterPluginStatuses, err := s.platform.Cluster().GetPluginStatuses() if err != nil { return nil, model.NewAppError("GetClusterPluginStatuses", "app.plugin.get_cluster_plugin_statuses.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -92,8 +92,8 @@ func (ch *Channels) getClusterPluginStatuses() (model.PluginStatuses, *model.App return pluginStatuses, nil } -func (ch *Channels) notifyPluginStatusesChanged() error { - pluginStatuses, err := ch.getClusterPluginStatuses() +func (s *PluginService) notifyPluginStatusesChanged() error { + pluginStatuses, err := s.getClusterPluginStatuses() if err != nil { return err } @@ -102,7 +102,7 @@ func (ch *Channels) notifyPluginStatusesChanged() error { message := model.NewWebSocketEvent(model.WebsocketEventPluginStatusesChanged, "", "", "", nil, "") message.Add("plugin_statuses", pluginStatuses) message.GetBroadcast().ContainsSensitiveData = true - ch.srv.platform.Publish(message) + s.platform.Publish(message) return nil } diff --git a/app/plugin_test.go b/app/plugin_test.go index 57802c67ac..0d3ec65431 100644 --- a/app/plugin_test.go +++ b/app/plugin_test.go @@ -346,7 +346,7 @@ func TestServePluginRequest(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/plugins/foo/bar", nil) - th.App.ch.ServePluginRequest(w, r) + th.App.PluginService().ServePluginRequest(w, r) assert.Equal(t, http.StatusNotImplemented, w.Result().StatusCode) } @@ -390,7 +390,7 @@ func TestPrivateServePluginRequest(t *testing.T) { request = mux.SetURLVars(request, map[string]string{"plugin_id": "id"}) - th.App.ch.servePluginRequest(recorder, request, handler) + th.App.PluginService().servePluginRequest(recorder, request, handler) }) } @@ -413,7 +413,7 @@ func TestHandlePluginRequest(t *testing.T) { var assertions func(*http.Request) router := mux.NewRouter() router.HandleFunc("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}/{anything:.*}", func(_ http.ResponseWriter, r *http.Request) { - th.App.ch.servePluginRequest(nil, r, func(_ *plugin.Context, _ http.ResponseWriter, r *http.Request) { + th.App.PluginService().servePluginRequest(nil, r, func(_ *plugin.Context, _ http.ResponseWriter, r *http.Request) { assertions(r) }) }) @@ -625,7 +625,7 @@ func TestPluginSync(t *testing.T) { appErr = th.App.DeletePublicKey("pub_key") checkNoError(t, appErr) - appErr = th.App.ch.RemovePlugin("testplugin") + appErr = th.App.PluginService().RemovePlugin("testplugin") checkNoError(t, appErr) }) }) @@ -642,7 +642,7 @@ func TestChannelsPluginsInit(t *testing.T) { path, _ := fileutils.FindDir("tests") require.NotPanics(t, func() { - th.Server.Channels().initPlugins(ctx, path, path) + th.Server.pluginService.initPlugins(ctx, path, path) }) } @@ -763,7 +763,7 @@ func TestPluginPanicLogs(t *testing.T) { th.TestLogger.Flush() // We shutdown plugins first so that the read on the log buffer is race-free. - th.App.ch.ShutDownPlugins() + th.App.PluginService().ShutDownPlugins() tearDown() testlib.AssertLog(t, th.LogBuffer, mlog.LvlDebug.Name, "panic: some text from panic") @@ -831,7 +831,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { require.NoError(t, err) require.NotNil(t, pluginBytes) - manifest, appErr := th.App.ch.installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) + manifest, appErr := th.App.PluginService().installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) require.Nil(t, appErr) require.Equal(t, "testplugin", manifest.Id) @@ -848,7 +848,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { *cfg.PluginSettings.EnableRemoteMarketplace = false }) - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 1) require.Equal(t, plugins[0].Manifest.Id, "testplugin") require.Empty(t, plugins[0].Signature, 0) @@ -858,7 +858,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { require.Len(t, pluginStatus, 1) require.Equal(t, pluginStatus[0].PluginId, "testplugin") - appErr = th.App.ch.RemovePlugin("testplugin") + appErr = th.App.PluginService().RemovePlugin("testplugin") checkNoError(t, appErr) pluginStatus, err = env.Statuses() @@ -875,7 +875,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { env := th.App.GetPluginsEnvironment() - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 1) require.Equal(t, plugins[0].Manifest.Id, "testplugin") require.Empty(t, plugins[0].Signature, 0) @@ -908,7 +908,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) require.NoError(t, err) - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 2) require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) require.NotEmpty(t, plugins[0].Signature) @@ -939,7 +939,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { require.NoError(t, err) require.NotNil(t, pluginBytes) - manifest, appErr := th.App.ch.installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) + manifest, appErr := th.App.PluginService().installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) require.Nil(t, appErr) require.Equal(t, "testplugin", manifest.Id) @@ -957,7 +957,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) require.NoError(t, err) - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 2) require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) require.NotEmpty(t, plugins[0].Signature) @@ -969,7 +969,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { require.Len(t, pluginStatus, 1) require.Equal(t, pluginStatus[0].PluginId, "testplugin") - appErr = th.App.ch.RemovePlugin("testplugin") + appErr = th.App.PluginService().RemovePlugin("testplugin") checkNoError(t, appErr) pluginStatus, err = env.Statuses() @@ -994,7 +994,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) require.NoError(t, err) - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 2) require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) require.NotEmpty(t, plugins[0].Signature) @@ -1071,14 +1071,14 @@ func TestGetPluginStateOverride(t *testing.T) { defer th.TearDown() t.Run("no override", func(t *testing.T) { - overrides, value := th.App.ch.getPluginStateOverride("focalboard") + overrides, value := th.App.PluginService().getPluginStateOverride("focalboard") require.False(t, overrides) require.False(t, value) }) t.Run("calls override", func(t *testing.T) { t.Run("on-prem", func(t *testing.T) { - overrides, value := th.App.ch.getPluginStateOverride("com.mattermost.calls") + overrides, value := th.App.PluginService().getPluginStateOverride("com.mattermost.calls") require.False(t, overrides) require.False(t, value) }) @@ -1086,7 +1086,7 @@ func TestGetPluginStateOverride(t *testing.T) { t.Run("Cloud, without enabled flag", func(t *testing.T) { os.Setenv("MM_CLOUD_INSTALLATION_ID", "test") defer os.Unsetenv("MM_CLOUD_INSTALLATION_ID") - overrides, value := th.App.ch.getPluginStateOverride("com.mattermost.calls") + overrides, value := th.App.PluginService().getPluginStateOverride("com.mattermost.calls") require.False(t, overrides) require.False(t, value) }) @@ -1100,7 +1100,7 @@ func TestGetPluginStateOverride(t *testing.T) { th2 := Setup(t) defer th2.TearDown() - overrides, value := th2.App.ch.getPluginStateOverride("com.mattermost.calls") + overrides, value := th2.App.PluginService().getPluginStateOverride("com.mattermost.calls") require.False(t, overrides) require.False(t, value) }) @@ -1114,7 +1114,7 @@ func TestGetPluginStateOverride(t *testing.T) { th2 := Setup(t) defer th2.TearDown() - overrides, value := th2.App.ch.getPluginStateOverride("com.mattermost.calls") + overrides, value := th2.App.PluginService().getPluginStateOverride("com.mattermost.calls") require.True(t, overrides) require.False(t, value) }) @@ -1126,7 +1126,7 @@ func TestGetPluginStateOverride(t *testing.T) { th2 := Setup(t) defer th2.TearDown() - overrides, value := th2.App.ch.getPluginStateOverride("com.mattermost.calls") + overrides, value := th2.App.PluginService().getPluginStateOverride("com.mattermost.calls") require.True(t, overrides) require.False(t, value) }) @@ -1134,7 +1134,7 @@ func TestGetPluginStateOverride(t *testing.T) { t.Run("apps override", func(t *testing.T) { t.Run("without enabled flag", func(t *testing.T) { - overrides, value := th.App.ch.getPluginStateOverride("com.mattermost.apps") + overrides, value := th.App.PluginService().getPluginStateOverride("com.mattermost.apps") require.False(t, overrides) require.False(t, value) }) @@ -1146,7 +1146,7 @@ func TestGetPluginStateOverride(t *testing.T) { th2 := Setup(t) defer th2.TearDown() - overrides, value := th2.App.ch.getPluginStateOverride("com.mattermost.apps") + overrides, value := th2.App.PluginService().getPluginStateOverride("com.mattermost.apps") require.True(t, overrides) require.False(t, value) }) diff --git a/app/server.go b/app/server.go index 8699a013c6..c15689e7b7 100644 --- a/app/server.go +++ b/app/server.go @@ -143,6 +143,7 @@ type Server struct { telemetryService *telemetry.TelemetryService userService *users.UserService teamService *teams.TeamService + pluginService *PluginService serviceMux sync.RWMutex remoteClusterService remotecluster.RemoteClusterServiceIFace @@ -737,6 +738,10 @@ func (s *Server) Shutdown() { } } + // Stop the plugin service, we need to stop plugin service before stopping the + // product as products are being consumed by this service. + s.pluginService.ShutDownPlugins() + // Stop products. // This needs to happen last because products are dependent // on parent services. @@ -843,11 +848,18 @@ func stripPort(hostport string) string { func (s *Server) Start() error { // Start products. // This needs to happen before because products are dependent on the HTTP server. - // make sure channels starts first if err := s.products["channels"].Start(); err != nil { return errors.Wrap(err, "Unable to start channels") } + + // This should actually be started after products, but we have a product hooks + // dependency for now, once that get sorted out, this should be moved to the appropriate + // order. + if err := s.InitializePluginService(); err != nil { + return errors.Wrap(err, "Unable to start plugin service") + } + for name, product := range s.products { if name == "channels" { continue diff --git a/app/web_conn.go b/app/web_conn.go index 0edc3504ae..ba78d179fe 100644 --- a/app/web_conn.go +++ b/app/web_conn.go @@ -16,5 +16,5 @@ func (a *App) PopulateWebConnConfig(s *model.Session, cfg *platform.WebConnConfi // NewWebConn returns a new WebConn instance. func (a *App) NewWebConn(cfg *platform.WebConnConfig) *platform.WebConn { - return a.Srv().Platform().NewWebConn(cfg, a, a.ch.GetPluginsEnvironment) + return a.Srv().Platform().NewWebConn(cfg, a, a.Srv().pluginService.GetPluginsEnvironment) } diff --git a/cmd/mattermost/commands/init.go b/cmd/mattermost/commands/init.go index e93d9640fe..9ff238029e 100644 --- a/cmd/mattermost/commands/init.go +++ b/cmd/mattermost/commands/init.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/mattermost/mattermost-server/v6/app" - "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/config" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/i18n" @@ -21,7 +20,7 @@ func initDBCommandContextCobra(command *cobra.Command, readOnlyConfigStore bool) panic(err) } - a.InitPlugins(request.EmptyContext(a.Log()), *a.Config().PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory) + a.Srv().InitializePluginService() a.DoAppMigrations() return a, nil diff --git a/web/web_test.go b/web/web_test.go index 139753675d..84ffd798dd 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -280,7 +280,7 @@ func TestPublicFilesRequest(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server.Platform()), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) pluginID := "com.mattermost.sample" @@ -327,7 +327,7 @@ func TestPublicFilesRequest(t *testing.T) { require.NotNil(t, manifest) require.True(t, activated) - th.App.Channels().SetPluginsEnvironment(env) + th.App.PluginService().SetPluginsEnvironment(env) req, _ := http.NewRequest("GET", "/plugins/com.mattermost.sample/public/hello.html", nil) res := httptest.NewRecorder() From 6d7e9af0816911f7d6f69aec4c3528caf25f4e1c Mon Sep 17 00:00:00 2001 From: Daniel Schalla Date: Thu, 24 Nov 2022 11:46:39 +0100 Subject: [PATCH 26/80] [CLD-4570] Disable CodeQL Auto Build (#21716) * Disable CodeQL Auto Build * Update codeql-analysis.yml * Separate Linux Build Targets --- .github/workflows/codeql-analysis.yml | 7 ++++--- build/release.mk | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 791a45ee95..98d195af40 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -34,9 +34,10 @@ jobs: debug: false config-file: ./.github/codeql/codeql-config.yml - # Autobuild attempts to build any compiled languages - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + - name: Build + run: | + make setup-go-work + make build-linux-amd64 # Perform Analysis - name: Perform CodeQL Analysis diff --git a/build/release.mk b/build/release.mk index e48c3d70b1..46b5c5fcb9 100644 --- a/build/release.mk +++ b/build/release.mk @@ -1,6 +1,8 @@ dist: | check-style test package -build-linux: +build-linux: build-linux-amd64 build-linux-arm64 + +build-linux-amd64: @echo Build Linux amd64 ifeq ($(BUILDER_GOOS_GOARCH),"linux_amd64") env GOOS=linux GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -ldflags '$(LDFLAGS)' ./... @@ -8,6 +10,8 @@ else mkdir -p $(GOBIN)/linux_amd64 env GOOS=linux GOARCH=amd64 $(GO) build -o $(GOBIN)/linux_amd64 $(GOFLAGS) -trimpath -ldflags '$(LDFLAGS)' ./... endif + +build-linux-arm64: @echo Build Linux arm64 ifeq ($(BUILDER_GOOS_GOARCH),"linux_arm64") env GOOS=linux GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -ldflags '$(LDFLAGS)' ./... From d8dd862dec2e4f6175f4f1dab281acf5431fa255 Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Thu, 24 Nov 2022 13:01:29 +0100 Subject: [PATCH 27/80] Prepackage Boards v7.5.2 (#21713) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 01bfbbc6da..1eed321800 100644 --- a/Makefile +++ b/Makefile @@ -160,7 +160,7 @@ PLUGIN_PACKAGES += mattermost-plugin-jira-v2.4.0 PLUGIN_PACKAGES += mattermost-plugin-nps-v1.3.0 PLUGIN_PACKAGES += mattermost-plugin-welcomebot-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.6.0 -PLUGIN_PACKAGES += focalboard-v7.5.1 +PLUGIN_PACKAGES += focalboard-v7.5.2 PLUGIN_PACKAGES += mattermost-plugin-apps-v1.1.0 # Prepares the enterprise build if exists. The IGNORE stuff is a hack to get the Makefile to execute the commands outside a target From a05dd722edd0e5eaad6c5a3a90b92cfa113a3dc4 Mon Sep 17 00:00:00 2001 From: Martin Kraft Date: Thu, 24 Nov 2022 07:41:09 -0500 Subject: [PATCH 28/80] New isrole replacement (#21688) * Adds new function to use in place of isRole that includes team and channel schemes. * Adds tests. Fixes code. * Adds warning to isRole. * Fixes phrasing. * Added more docs. * Moved from strings to constants. * Added some spacing and improved some comments. * Renames some functions. * Rename isNotRole to isNotExactRole. Add a new isNotRole function. * Switch to only checking prefix. * Ignores unused function warning. * Lint fix. * Switch from unused to deadcode. * Adds test for isNotRole. --- app/permissions_migrations.go | 136 ++++++++++++++++++++--------- app/permissions_migrations_test.go | 76 +++++++++++++++- store/sqlstore/scheme_store.go | 34 ++++++-- 3 files changed, 195 insertions(+), 51 deletions(-) diff --git a/app/permissions_migrations.go b/app/permissions_migrations.go index 2b638be162..34661b5d11 100644 --- a/app/permissions_migrations.go +++ b/app/permissions_migrations.go @@ -10,6 +10,7 @@ import ( "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/store/sqlstore" ) type permissionTransformation struct { @@ -74,18 +75,73 @@ const ( PermissionManageRemoteClusters = "manage_remote_clusters" // deprecated; use `manage_secure_connections` ) -func isRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { +// Deprecated: This function should only be used if a case arises where team and/or channel scheme roles do not need to be migrated. +// Otherwise, use isRole. +func isExactRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { return role.Name == roleName } } -func isNotRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { +// isRole returns true if roleName matches a role's name field or if the a team +// or channel scheme role matches a "common name". A common name is one of the following role +// that is common among the system scheme and the team and/or channel schemes: +// +// TeamAdmin, +// TeamUser, +// TeamGuest, +// ChannelAdmin, +// ChannelUser, +// ChannelGuest, +// PlaybookAdmin, +// PlaybookMember, +// RunAdmin, +// RunMember +func isRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { + return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { + if role.Name == roleName { + return true + } + return isSchemeRoleAssociatedToCommonName(roleName, role) + } +} + +// Deprecated: use isNotRole instead. +func isNotExactRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { return role.Name != roleName } } +func isNotRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { + return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { + return role.Name != roleName && !isSchemeRoleAssociatedToCommonName(roleName, role) + } +} + +func isSchemeRoleAssociatedToCommonName(roleName string, role *model.Role) bool { + roleIDToSchemeRoleDisplayName := map[string]string{ + model.TeamAdminRoleId: sqlstore.SchemeRoleDisplayNameTeamAdmin, + model.TeamUserRoleId: sqlstore.SchemeRoleDisplayNameTeamUser, + model.TeamGuestRoleId: sqlstore.SchemeRoleDisplayNameTeamGuest, + + model.ChannelAdminRoleId: sqlstore.SchemeRoleDisplayNameChannelAdmin, + model.ChannelUserRoleId: sqlstore.SchemeRoleDisplayNameChannelUser, + model.ChannelGuestRoleId: sqlstore.SchemeRoleDisplayNameChannelGuest, + + model.PlaybookAdminRoleId: sqlstore.SchemeRoleDisplayNamePlaybookAdmin, + model.PlaybookMemberRoleId: sqlstore.SchemeRoleDisplayNamePlaybookMember, + + model.RunAdminRoleId: sqlstore.SchemeRoleDisplayNameRunAdmin, + model.RunMemberRoleId: sqlstore.SchemeRoleDisplayNameRunMember, + } + displayName, ok := roleIDToSchemeRoleDisplayName[roleName] + if !ok { + return false + } + return strings.HasPrefix(role.DisplayName, displayName) +} + func isNotSchemeRole(roleName string) func(*model.Role, map[string]map[string]bool) bool { return func(role *model.Role, permissionsMap map[string]map[string]bool) bool { return !strings.Contains(role.DisplayName, roleName) @@ -222,12 +278,12 @@ func (a *App) getWebhooksPermissionsSplitMigration() (permissionsMap, error) { func (a *App) getListJoinPublicPrivateTeamsPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionListPrivateTeams, PermissionJoinPrivateTeams}, Remove: []string{}, }, permissionTransformation{ - On: isRole(model.SystemUserRoleId), + On: isExactRole(model.SystemUserRoleId), Add: []string{PermissionListPublicTeams, PermissionJoinPublicTeams}, Remove: []string{}, }, @@ -246,7 +302,7 @@ func (a *App) removePermanentDeleteUserMigration() (permissionsMap, error) { func (a *App) getAddBotPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionCreateBot, PermissionReadBots, PermissionReadOthersBots, PermissionManageBots, PermissionManageOthersBots}, Remove: []string{}, }, @@ -256,19 +312,19 @@ func (a *App) getAddBotPermissionsMigration() (permissionsMap, error) { func (a *App) applyChannelManageDeleteToChannelUser() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: permissionAnd(isRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionManagePrivateChannelProperties))), + On: permissionAnd(isExactRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionManagePrivateChannelProperties))), Add: []string{PermissionManagePrivateChannelProperties}, }, permissionTransformation{ - On: permissionAnd(isRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionDeletePrivateChannel))), + On: permissionAnd(isExactRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionDeletePrivateChannel))), Add: []string{PermissionDeletePrivateChannel}, }, permissionTransformation{ - On: permissionAnd(isRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionManagePublicChannelProperties))), + On: permissionAnd(isExactRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionManagePublicChannelProperties))), Add: []string{PermissionManagePublicChannelProperties}, }, permissionTransformation{ - On: permissionAnd(isRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionDeletePublicChannel))), + On: permissionAnd(isExactRole(model.ChannelUserRoleId), onOtherRole(model.TeamUserRoleId, permissionExists(PermissionDeletePublicChannel))), Add: []string{PermissionDeletePublicChannel}, }, }, nil @@ -277,19 +333,19 @@ func (a *App) applyChannelManageDeleteToChannelUser() (permissionsMap, error) { func (a *App) removeChannelManageDeleteFromTeamUser() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: permissionAnd(isRole(model.TeamUserRoleId), permissionExists(PermissionManagePrivateChannelProperties)), + On: permissionAnd(isExactRole(model.TeamUserRoleId), permissionExists(PermissionManagePrivateChannelProperties)), Remove: []string{PermissionManagePrivateChannelProperties}, }, permissionTransformation{ - On: permissionAnd(isRole(model.TeamUserRoleId), permissionExists(PermissionDeletePrivateChannel)), + On: permissionAnd(isExactRole(model.TeamUserRoleId), permissionExists(PermissionDeletePrivateChannel)), Remove: []string{model.PermissionDeletePrivateChannel.Id}, }, permissionTransformation{ - On: permissionAnd(isRole(model.TeamUserRoleId), permissionExists(PermissionManagePublicChannelProperties)), + On: permissionAnd(isExactRole(model.TeamUserRoleId), permissionExists(PermissionManagePublicChannelProperties)), Remove: []string{PermissionManagePublicChannelProperties}, }, permissionTransformation{ - On: permissionAnd(isRole(model.TeamUserRoleId), permissionExists(PermissionDeletePublicChannel)), + On: permissionAnd(isExactRole(model.TeamUserRoleId), permissionExists(PermissionDeletePublicChannel)), Remove: []string{PermissionDeletePublicChannel}, }, }, nil @@ -298,11 +354,11 @@ func (a *App) removeChannelManageDeleteFromTeamUser() (permissionsMap, error) { func (a *App) getViewMembersPermissionMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemUserRoleId), + On: isExactRole(model.SystemUserRoleId), Add: []string{PermissionViewMembers}, }, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionViewMembers}, }, }, nil @@ -311,7 +367,7 @@ func (a *App) getViewMembersPermissionMigration() (permissionsMap, error) { func (a *App) getAddManageGuestsPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionPromoteGuest, PermissionDemoteToGuest, PermissionInviteGuest}, }, }, nil @@ -342,7 +398,7 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { // add each moderated permission to the channel admin if channel user or guest has the permission trans := permissionTransformation{ On: permissionAnd( - isRole(channelAdminID), + isExactRole(channelAdminID), permissionOr( onOtherRole(channelUserID, permissionExists(perm)), onOtherRole(channelGuestID, permissionExists(perm)), @@ -355,7 +411,7 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { // add each moderated permission to the team admin if channel admin, user, or guest has the permission trans = permissionTransformation{ On: permissionAnd( - isRole(teamAdminID), + isExactRole(teamAdminID), permissionOr( onOtherRole(channelAdminID, permissionExists(perm)), onOtherRole(channelUserID, permissionExists(perm)), @@ -373,14 +429,14 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { for _, ts := range allTeamSchemes { // ensure all team scheme channel admins have create_post because it's not exposed via the UI trans := permissionTransformation{ - On: isRole(ts.DefaultChannelAdminRole), + On: isExactRole(ts.DefaultChannelAdminRole), Add: []string{PermissionCreatePost}, } transformations = append(transformations, trans) // ensure all team scheme team admins have create_post because it's not exposed via the UI trans = permissionTransformation{ - On: isRole(ts.DefaultTeamAdminRole), + On: isExactRole(ts.DefaultTeamAdminRole), Add: []string{PermissionCreatePost}, } transformations = append(transformations, trans) @@ -396,13 +452,13 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { // ensure team admins have create_post transformations = append(transformations, permissionTransformation{ - On: isRole(model.TeamAdminRoleId), + On: isExactRole(model.TeamAdminRoleId), Add: []string{PermissionCreatePost}, }) // ensure channel admins have create_post transformations = append(transformations, permissionTransformation{ - On: isRole(model.ChannelAdminRoleId), + On: isExactRole(model.ChannelAdminRoleId), Add: []string{PermissionCreatePost}, }) @@ -416,7 +472,7 @@ func (a *App) channelModerationPermissionsMigration() (permissionsMap, error) { // ensure system admin has all of the moderated permissions transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: append(moderatedPermissionsMinusCreatePost, PermissionCreatePost), }) @@ -433,8 +489,8 @@ func (a *App) getAddUseGroupMentionsPermissionMigration() (permissionsMap, error return permissionsMap{ permissionTransformation{ On: permissionAnd( - isNotRole(model.ChannelGuestRoleId), - isNotSchemeRole("Channel Guest Role for Scheme"), + isNotExactRole(model.ChannelGuestRoleId), + isNotSchemeRole(sqlstore.SchemeRoleDisplayNameChannelGuest), permissionOr(permissionExists(PermissionCreatePost), permissionExists(PermissionCreatePost_PUBLIC)), ), Add: []string{PermissionUseGroupMentions}, @@ -453,7 +509,7 @@ func (a *App) getAddSystemConsolePermissionsMigration() (permissionsMap, error) // add the new permissions to system admin transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: permissionsToAdd, }) @@ -502,7 +558,7 @@ func (a *App) getAddConvertChannelPermissionsMigration() (permissionsMap, error) func (a *App) getSystemRolesPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{model.PermissionSysconsoleReadUserManagementSystemRoles.Id, model.PermissionSysconsoleWriteUserManagementSystemRoles.Id}, }, }, nil @@ -511,7 +567,7 @@ func (a *App) getSystemRolesPermissionsMigration() (permissionsMap, error) { func (a *App) getAddManageSharedChannelsPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionManageSharedChannels}, }, }, nil @@ -520,7 +576,7 @@ func (a *App) getAddManageSharedChannelsPermissionsMigration() (permissionsMap, func (a *App) getBillingPermissionsMigration() (permissionsMap, error) { return permissionsMap{ permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{model.PermissionSysconsoleReadBilling.Id, model.PermissionSysconsoleWriteBilling.Id}, }, }, nil @@ -532,14 +588,14 @@ func (a *App) getAddManageSecureConnectionsPermissionsMigration() (permissionsMa // add the new permission to system admin transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{PermissionManageSecureConnections}, }) // remote the deprecated permission from system admin transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Remove: []string{PermissionManageRemoteClusters}, }) @@ -555,7 +611,7 @@ func (a *App) getAddDownloadComplianceExportResult() (permissionsMap, error) { // add the new permissions to system admin transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{model.PermissionDownloadComplianceExportResult.Id}, }) @@ -926,12 +982,12 @@ func (a *App) getAddCustomUserGroupsPermissions() (permissionsMap, error) { } t = append(t, permissionTransformation{ - On: isRole(model.SystemUserRoleId), + On: isExactRole(model.SystemUserRoleId), Add: customGroupPermissions, }) t = append(t, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: customGroupPermissions, }) @@ -953,7 +1009,7 @@ func (a *App) getAddPlaybooksPermissions() (permissionsMap, error) { }) transformations = append(transformations, permissionTransformation{ - On: isRole(model.SystemAdminRoleId), + On: isExactRole(model.SystemAdminRoleId), Add: []string{ model.PermissionPublicPlaybookManageProperties.Id, model.PermissionPublicPlaybookManageMembers.Id, @@ -978,9 +1034,9 @@ func (a *App) getPlaybooksPermissionsAddManageRoles() (permissionsMap, error) { transformations = append(transformations, permissionTransformation{ On: permissionOr( - isRole(model.PlaybookAdminRoleId), - isRole(model.TeamAdminRoleId), - isRole(model.SystemAdminRoleId), + isExactRole(model.PlaybookAdminRoleId), + isExactRole(model.TeamAdminRoleId), + isExactRole(model.SystemAdminRoleId), ), Add: []string{ model.PermissionPublicPlaybookManageRoles.Id, @@ -999,13 +1055,13 @@ func (a *App) getProductsBoardsPermissions() (permissionsMap, error) { // Give the new subsection READ permissions to any user with SYSTEM_MANAGER transformations = append(transformations, permissionTransformation{ - On: permissionOr(isRole(model.SystemManagerRoleId)), + On: permissionOr(isExactRole(model.SystemManagerRoleId)), Add: permissionsProductsRead, }) // Give the new subsection WRITE permissions to any user with SYSTEM_ADMIN transformations = append(transformations, permissionTransformation{ - On: permissionOr(isRole(model.SystemAdminRoleId)), + On: permissionOr(isExactRole(model.SystemAdminRoleId)), Add: permissionsProductsWrite, }) diff --git a/app/permissions_migrations_test.go b/app/permissions_migrations_test.go index 1b20ddf1d4..29093c8cf9 100644 --- a/app/permissions_migrations_test.go +++ b/app/permissions_migrations_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store/sqlstore" ) func TestApplyPermissionsMap(t *testing.T) { @@ -137,7 +138,7 @@ func TestApplyPermissionsMap(t *testing.T) { }, }, permissionsMap{permissionTransformation{ - On: isRole("system_admin"), + On: isExactRole("system_admin"), Add: []string{"test4"}, }}, []string{"test1", "test2", "test3", "test4"}, @@ -152,7 +153,7 @@ func TestApplyPermissionsMap(t *testing.T) { }, }, permissionsMap{permissionTransformation{ - On: isRole("system_user"), + On: isExactRole("system_user"), Add: []string{"test4"}, }}, []string{"test1", "test2", "test3"}, @@ -203,3 +204,74 @@ func TestApplyPermissionsMap(t *testing.T) { }) } } + +func TestApplyPermissionsMapToSchemeRole(t *testing.T) { + schemeRoleName := model.NewId() + tt := []struct { + Name string + RoleMap map[string]map[string]bool + TranslationMap permissionsMap + ExpectedResult []string + }{ + { + "Adds a permission to a scheme role with a matching common name", + map[string]map[string]bool{ + schemeRoleName: { + "test1": true, + }, + }, + permissionsMap{permissionTransformation{ + On: isRole(model.TeamAdminRoleId), + Add: []string{"test2"}, + }}, + []string{"test1", "test2"}, + }, + { + "Doesn't add a permission to a scheme role with a different common name", + map[string]map[string]bool{ + schemeRoleName: { + "test1": true, + }, + }, + permissionsMap{permissionTransformation{ + On: isRole(model.ChannelAdminRoleId), + Add: []string{"test2"}, + }}, + []string{"test1"}, + }, + { + "Doesn't add a permission to a role with a the same exact name", + map[string]map[string]bool{ + schemeRoleName: { + "test1": true, + }, + }, + permissionsMap{permissionTransformation{ + On: isNotRole(schemeRoleName), + Add: []string{"test2"}, + }}, + []string{"test1"}, + }, + { + "Doesn't add a permission to a role with a different exact name but the same common name", + map[string]map[string]bool{ + schemeRoleName: { + "test1": true, + }, + }, + permissionsMap{permissionTransformation{ + On: isNotRole(model.TeamAdminRoleId), + Add: []string{"test2"}, + }}, + []string{"test1"}, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + result := applyPermissionsMap(&model.Role{Name: schemeRoleName, DisplayName: sqlstore.SchemeRoleDisplayNameTeamAdmin}, tc.RoleMap, tc.TranslationMap) + sort.Strings(result) + assert.Equal(t, tc.ExpectedResult, result) + }) + } +} diff --git a/store/sqlstore/scheme_store.go b/store/sqlstore/scheme_store.go index a31ad5a27c..e72591628d 100644 --- a/store/sqlstore/scheme_store.go +++ b/store/sqlstore/scheme_store.go @@ -14,6 +14,22 @@ import ( "github.com/mattermost/mattermost-server/v6/store" ) +const ( + SchemeRoleDisplayNameTeamAdmin = "Team Admin Role for Scheme" + SchemeRoleDisplayNameTeamUser = "Team User Role for Scheme" + SchemeRoleDisplayNameTeamGuest = "Team Guest Role for Scheme" + + SchemeRoleDisplayNameChannelAdmin = "Channel Admin Role for Scheme" + SchemeRoleDisplayNameChannelUser = "Channel User Role for Scheme" + SchemeRoleDisplayNameChannelGuest = "Channel Guest Role for Scheme" + + SchemeRoleDisplayNamePlaybookAdmin = "Playbook Admin Role for Scheme" + SchemeRoleDisplayNamePlaybookMember = "Playbook Member Role for Scheme" + + SchemeRoleDisplayNameRunAdmin = "Run Admin Role for Scheme" + SchemeRoleDisplayNameRunMember = "Run Member Role for Scheme" +) + type SqlSchemeStore struct { *SqlStore } @@ -50,7 +66,7 @@ func (s *SqlSchemeStore) Save(scheme *model.Scheme) (_ *model.Scheme, err error) SET UpdateAt=:UpdateAt, CreateAt=:CreateAt, DeleteAt=:DeleteAt, Name=:Name, DisplayName=:DisplayName, Description=:Description, Scope=:Scope, DefaultTeamAdminRole=:DefaultTeamAdminRole, DefaultTeamUserRole=:DefaultTeamUserRole, DefaultTeamGuestRole=:DefaultTeamGuestRole, DefaultChannelAdminRole=:DefaultChannelAdminRole, DefaultChannelUserRole=:DefaultChannelUserRole, DefaultChannelGuestRole=:DefaultChannelGuestRole, - DefaultPlaybookMemberRole=:DefaultPlaybookMemberRole, DefaultPlaybookAdminRole=:DefaultPlaybookAdminRole, DefaultRunMemberRole=:DefaultRunMemberRole, DefaultRunAdminRole=:DefaultRunAdminRole + DefaultPlaybookMemberRole=:DefaultPlaybookMemberRole, DefaultPlaybookAdminRole=:DefaultPlaybookAdminRole, DefaultRunMemberRole=:DefaultRunMemberRole, DefaultRunAdminRole=:DefaultRunAdminRole WHERE Id=:Id`, scheme) if err != nil { @@ -101,7 +117,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // Team Admin Role teamAdminRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Team Admin Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamAdmin, scheme.Name), Permissions: defaultRoles[model.TeamAdminRoleId].Permissions, SchemeManaged: true, } @@ -115,7 +131,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // Team User Role teamUserRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Team User Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamUser, scheme.Name), Permissions: defaultRoles[model.TeamUserRoleId].Permissions, SchemeManaged: true, } @@ -129,7 +145,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // Team Guest Role teamGuestRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Team Guest Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamGuest, scheme.Name), Permissions: defaultRoles[model.TeamGuestRoleId].Permissions, SchemeManaged: true, } @@ -143,7 +159,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // playbook admin role playbookAdminRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Playbook Admin Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookAdmin, scheme.Name), Permissions: defaultRoles[model.PlaybookAdminRoleId].Permissions, SchemeManaged: true, } @@ -156,7 +172,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // playbook member role playbookMemberRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Playbook Member Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookMember, scheme.Name), Permissions: defaultRoles[model.PlaybookMemberRoleId].Permissions, SchemeManaged: true, } @@ -169,7 +185,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // run admin role runAdminRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Run Admin Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunAdmin, scheme.Name), Permissions: defaultRoles[model.RunAdminRoleId].Permissions, SchemeManaged: true, } @@ -182,7 +198,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW // run member role runMemberRole := &model.Role{ Name: model.NewId(), - DisplayName: fmt.Sprintf("Run Member Role for Scheme %s", scheme.Name), + DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunMember, scheme.Name), Permissions: defaultRoles[model.RunMemberRoleId].Permissions, SchemeManaged: true, } @@ -369,7 +385,7 @@ func (s *SqlSchemeStore) Delete(schemeId string) (*model.Scheme, error) { res, err := s.GetMasterX().NamedExec(`UPDATE Schemes SET UpdateAt=:UpdateAt, DeleteAt=:DeleteAt, CreateAt=:CreateAt, Name=:Name, DisplayName=:DisplayName, Description=:Description, Scope=:Scope, DefaultTeamAdminRole=:DefaultTeamAdminRole, DefaultTeamUserRole=:DefaultTeamUserRole, DefaultTeamGuestRole=:DefaultTeamGuestRole, - DefaultChannelAdminRole=:DefaultChannelAdminRole, DefaultChannelUserRole=:DefaultChannelUserRole, DefaultChannelGuestRole=:DefaultChannelGuestRole + DefaultChannelAdminRole=:DefaultChannelAdminRole, DefaultChannelUserRole=:DefaultChannelUserRole, DefaultChannelGuestRole=:DefaultChannelGuestRole WHERE Id=:Id`, &scheme) if err != nil { From 372653a92136bc8c7450e1872777f22e16e7b7f9 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Thu, 24 Nov 2022 16:52:36 +0300 Subject: [PATCH 29/80] Revert "app/pluginservice: move plugin out from the Channels product (#21697)" (#21725) This reverts commit 6c55c4d35692c0eee48c042d6ffa87c822e120e2. --- api4/plugin.go | 4 +- api4/plugin_test.go | 2 +- api4/websocket.go | 2 +- app/app_iface.go | 1 - app/channels.go | 67 ++++- app/cluster_handlers.go | 6 +- app/collection.go | 16 +- app/download.go | 6 +- app/integration_action.go | 6 +- app/onboarding.go | 4 +- app/opentracing/opentracing_layer.go | 17 -- app/plugin.go | 368 ++++++++++----------------- app/plugin_api.go | 4 +- app/plugin_api_test.go | 24 +- app/plugin_commands.go | 72 ++---- app/plugin_commands_test.go | 10 +- app/plugin_db_driver.go | 11 +- app/plugin_event.go | 6 +- app/plugin_hooks_test.go | 8 +- app/plugin_install.go | 118 ++++----- app/plugin_install_test.go | 8 +- app/plugin_requests.go | 28 +- app/plugin_requests_test.go | 2 +- app/plugin_shutdown_test.go | 2 +- app/plugin_signature.go | 8 +- app/plugin_statuses.go | 36 +-- app/plugin_test.go | 46 ++-- app/server.go | 14 +- app/web_conn.go | 2 +- cmd/mattermost/commands/init.go | 3 +- web/web_test.go | 4 +- 31 files changed, 408 insertions(+), 497 deletions(-) diff --git a/api4/plugin.go b/api4/plugin.go index 475aa62f21..be5b298d02 100644 --- a/api4/plugin.go +++ b/api4/plugin.go @@ -155,7 +155,7 @@ func installMarketplacePlugin(c *Context, w http.ResponseWriter, r *http.Request // https://mattermost.atlassian.net/browse/MM-41981 pluginRequest.Version = "" - manifest, appErr := c.App.PluginService().InstallMarketplacePlugin(pluginRequest) + manifest, appErr := c.App.Channels().InstallMarketplacePlugin(pluginRequest) if appErr != nil { c.Err = appErr return @@ -235,7 +235,7 @@ func removePlugin(c *Context, w http.ResponseWriter, r *http.Request) { return } - err := c.App.PluginService().RemovePlugin(c.Params.PluginId) + err := c.App.Channels().RemovePlugin(c.Params.PluginId) if err != nil { c.Err = err return diff --git a/api4/plugin_test.go b/api4/plugin_test.go index 3b656c209a..1967f9a617 100644 --- a/api4/plugin_test.go +++ b/api4/plugin_test.go @@ -94,7 +94,7 @@ func TestPlugin(t *testing.T) { assert.Equal(t, "testplugin", manifest.Id) }) - th.App.PluginService().RemovePlugin(manifest.Id) + th.App.Channels().RemovePlugin(manifest.Id) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false }) diff --git a/api4/websocket.go b/api4/websocket.go index 6c1394050e..5f1cb2cdd3 100644 --- a/api4/websocket.go +++ b/api4/websocket.go @@ -61,7 +61,7 @@ func connectWebSocket(c *Context, w http.ResponseWriter, r *http.Request) { } } - wc := c.App.Srv().Platform().NewWebConn(cfg, c.App, c.App.PluginService().GetPluginsEnvironment) + wc := c.App.Srv().Platform().NewWebConn(cfg, c.App, c.App.Srv().Channels().GetPluginsEnvironment) if c.AppContext.Session().UserId != "" { c.App.Srv().Platform().HubRegister(wc) } diff --git a/app/app_iface.go b/app/app_iface.go index 90a3939e8c..e3c02a0d80 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -928,7 +928,6 @@ type AppIface interface { PermanentDeleteTeamId(c request.CTX, teamID string) *model.AppError PermanentDeleteUser(c *request.Context, user *model.User) *model.AppError PluginCommandsForTeam(teamID string) []*model.Command - PluginService() *PluginService PostActionCookieSecret() []byte PostAddToChannelMessage(c request.CTX, user *model.User, addedUser *model.User, channel *model.Channel, postRootId string) *model.AppError PostPatchWithProxyRemovedFromImageURLs(patch *model.PostPatch) *model.PostPatch diff --git a/app/channels.go b/app/channels.go index de4ea1b669..2c9f1cee51 100644 --- a/app/channels.go +++ b/app/channels.go @@ -6,13 +6,17 @@ package app import ( "fmt" "runtime" + "strings" "sync" "github.com/pkg/errors" "github.com/mattermost/mattermost-server/v6/app/imaging" + "github.com/mattermost/mattermost-server/v6/app/request" + "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/plugin" "github.com/mattermost/mattermost-server/v6/product" "github.com/mattermost/mattermost-server/v6/services/imageproxy" "github.com/mattermost/mattermost-server/v6/shared/filestore" @@ -36,6 +40,12 @@ type Channels struct { postActionCookieSecret []byte + pluginCommandsLock sync.RWMutex + pluginCommands []*PluginCommand + pluginsLock sync.RWMutex + pluginsEnvironment *plugin.Environment + pluginConfigListenerID string + imageProxy *imageproxy.ImageProxy // cached counts that are used during notice condition validation @@ -67,6 +77,12 @@ type Channels struct { postReminderMut sync.Mutex postReminderTask *model.ScheduledTask + + // collectionTypes maps from collection types to the registering plugin id + collectionTypes map[string]string + // topicTypes maps from topic types to collection types + topicTypes map[string]string + collectionAndTopicTypesMut sync.Mutex } func init() { @@ -84,9 +100,11 @@ func init() { func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { ch := &Channels{ - srv: s, - imageProxy: imageproxy.MakeImageProxy(s.platform, s.httpService, s.Log()), - uploadLockMap: map[string]bool{}, + srv: s, + imageProxy: imageproxy.MakeImageProxy(s.platform, s.httpService, s.Log()), + uploadLockMap: map[string]bool{}, + collectionTypes: map[string]string{}, + topicTypes: map[string]string{}, } // To get another service: @@ -183,6 +201,10 @@ func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { services[RouterKey] = ch.routerSvc // Setup routes. + pluginsRoute := ch.srv.Router.PathPrefix("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter() + pluginsRoute.HandleFunc("", ch.ServePluginRequest) + pluginsRoute.HandleFunc("/public/{public_file:.*}", ch.ServePluginPublicRequest) + pluginsRoute.HandleFunc("/{anything:.*}", ch.ServePluginRequest) services[PostKey] = &postServiceWrapper{ app: &App{ch: ch}, @@ -214,6 +236,39 @@ func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { } func (ch *Channels) Start() error { + // Start plugins + ctx := request.EmptyContext(ch.srv.Log()) + ch.initPlugins(ctx, *ch.cfgSvc.Config().PluginSettings.Directory, *ch.cfgSvc.Config().PluginSettings.ClientDirectory) + + ch.AddConfigListener(func(prevCfg, cfg *model.Config) { + // We compute the difference between configs + // to ensure we don't re-init plugins unnecessarily. + diffs, err := config.Diff(prevCfg, cfg) + if err != nil { + ch.srv.Log().Warn("Error in comparing configs", mlog.Err(err)) + return + } + + hasDiff := false + // TODO: This could be a method on ConfigDiffs itself + for _, diff := range diffs { + if strings.HasPrefix(diff.Path, "PluginSettings.") { + hasDiff = true + break + } + } + + // Do only if some plugin related settings has changed. + if hasDiff { + if *cfg.PluginSettings.Enable { + ch.initPlugins(ctx, *cfg.PluginSettings.Directory, *ch.cfgSvc.Config().PluginSettings.ClientDirectory) + } else { + ch.ShutDownPlugins() + } + } + + }) + // TODO: This should be moved to the platform service. if err := ch.srv.platform.EnsureAsymmetricSigningKey(); err != nil { return errors.Wrapf(err, "unable to ensure asymmetric signing key") @@ -227,6 +282,8 @@ func (ch *Channels) Start() error { } func (ch *Channels) Stop() error { + ch.ShutDownPlugins() + ch.dndTaskMut.Lock() if ch.dndTask != nil { ch.dndTask.Cancel() @@ -261,9 +318,9 @@ type hooksService struct { } func (s *hooksService) RegisterHooks(productID string, hooks any) error { - if s.ch.srv.pluginService.pluginsEnvironment == nil { + if s.ch.pluginsEnvironment == nil { return errors.New("could not find plugins environment") } - return s.ch.srv.pluginService.pluginsEnvironment.AddProduct(productID, hooks) + return s.ch.pluginsEnvironment.AddProduct(productID, hooks) } diff --git a/app/cluster_handlers.go b/app/cluster_handlers.go index f5e7c3811d..beb8f71c73 100644 --- a/app/cluster_handlers.go +++ b/app/cluster_handlers.go @@ -16,7 +16,7 @@ func (s *Server) clusterInstallPluginHandler(msg *model.ClusterMessage) { if jsonErr := json.Unmarshal(msg.Data, &data); jsonErr != nil { mlog.Warn("Failed to decode from JSON", mlog.Err(jsonErr)) } - s.pluginService.installPluginFromData(data) + s.Channels().installPluginFromData(data) } func (s *Server) clusterRemovePluginHandler(msg *model.ClusterMessage) { @@ -24,11 +24,11 @@ func (s *Server) clusterRemovePluginHandler(msg *model.ClusterMessage) { if jsonErr := json.Unmarshal(msg.Data, &data); jsonErr != nil { mlog.Warn("Failed to decode from JSON", mlog.Err(jsonErr)) } - s.pluginService.removePluginFromData(data) + s.Channels().removePluginFromData(data) } func (s *Server) clusterPluginEventHandler(msg *model.ClusterMessage) { - env := s.pluginService.GetPluginsEnvironment() + env := s.Channels().GetPluginsEnvironment() if env == nil { return } diff --git a/app/collection.go b/app/collection.go index ff489a18db..9b895e3bc0 100644 --- a/app/collection.go +++ b/app/collection.go @@ -10,26 +10,26 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mlog" ) -func (s *PluginService) registerCollectionAndTopic(pluginID, collectionType, topicType string) error { +func (a *App) registerCollectionAndTopic(pluginID, collectionType, topicType string) error { // we have a race condition due to multiple plugins calling this method - s.collectionAndTopicTypesMut.Lock() - defer s.collectionAndTopicTypesMut.Unlock() + a.ch.collectionAndTopicTypesMut.Lock() + defer a.ch.collectionAndTopicTypesMut.Unlock() // check if collectionType was already registered by other plugin - existingPluginID, ok := s.collectionTypes[collectionType] + existingPluginID, ok := a.ch.collectionTypes[collectionType] if ok && existingPluginID != pluginID { return model.NewAppError("registerCollectionAndTopic", "app.collection.add_collection.exists.app_error", nil, "", http.StatusBadRequest) } // check if topicType was already registered to other collection - existingCollectionType, ok := s.topicTypes[topicType] + existingCollectionType, ok := a.ch.topicTypes[topicType] if ok && existingCollectionType != collectionType { return model.NewAppError("registerCollectionAndTopic", "app.collection.add_topic.exists.app_error", nil, "", http.StatusBadRequest) } - s.collectionTypes[collectionType] = pluginID - s.topicTypes[topicType] = collectionType + a.ch.collectionTypes[collectionType] = pluginID + a.ch.topicTypes[topicType] = collectionType - s.platform.Log().Info("registered collection and topic type", mlog.String("plugin_id", pluginID), mlog.String("collection_type", collectionType), mlog.String("topic_type", topicType)) + a.ch.srv.Log().Info("registered collection and topic type", mlog.String("plugin_id", pluginID), mlog.String("collection_type", collectionType), mlog.String("topic_type", topicType)) return nil } diff --git a/app/download.go b/app/download.go index 56438507cc..449f787c46 100644 --- a/app/download.go +++ b/app/download.go @@ -22,10 +22,10 @@ const ( ) func (a *App) DownloadFromURL(downloadURL string) ([]byte, error) { - return a.Srv().pluginService.downloadFromURL(downloadURL) + return a.Srv().downloadFromURL(downloadURL) } -func (s *PluginService) downloadFromURL(downloadURL string) ([]byte, error) { +func (s *Server) downloadFromURL(downloadURL string) ([]byte, error) { if !model.IsValidHTTPURL(downloadURL) { return nil, errors.Errorf("invalid url %s", downloadURL) } @@ -38,7 +38,7 @@ func (s *PluginService) downloadFromURL(downloadURL string) ([]byte, error) { return nil, errors.Errorf("insecure url not allowed %s", downloadURL) } - client := s.httpService.MakeClient(true) + client := s.HTTPService().MakeClient(true) client.Timeout = HTTPRequestTimeout var resp *http.Response diff --git a/app/integration_action.go b/app/integration_action.go index 51bef2b86f..4ae7e97e07 100644 --- a/app/integration_action.go +++ b/app/integration_action.go @@ -375,10 +375,10 @@ func (w *LocalResponseWriter) WriteHeader(statusCode int) { } func (a *App) doPluginRequest(c *request.Context, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { - return a.ch.srv.pluginService.doPluginRequest(c, method, rawURL, values, body) + return a.ch.doPluginRequest(c, method, rawURL, values, body) } -func (s *PluginService) doPluginRequest(c *request.Context, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { +func (ch *Channels) doPluginRequest(c *request.Context, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { rawURL = strings.TrimPrefix(rawURL, "/") inURL, err := url.Parse(rawURL) if err != nil { @@ -427,7 +427,7 @@ func (s *PluginService) doPluginRequest(c *request.Context, method, rawURL strin params["plugin_id"] = pluginID r = mux.SetURLVars(r, params) - s.ServePluginRequest(w, r) + ch.ServePluginRequest(w, r) resp := &http.Response{ StatusCode: w.status, diff --git a/app/onboarding.go b/app/onboarding.go index 46ff65a23c..9a9e25739c 100644 --- a/app/onboarding.go +++ b/app/onboarding.go @@ -28,7 +28,7 @@ func (a *App) markAdminOnboardingComplete(c *request.Context) *model.AppError { } func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnboardingRequest) *model.AppError { - pluginsEnvironment := a.Srv().pluginService.GetPluginsEnvironment() + pluginsEnvironment := a.Channels().GetPluginsEnvironment() if pluginsEnvironment == nil { return a.markAdminOnboardingComplete(c) } @@ -41,7 +41,7 @@ func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnbo installRequest := &model.InstallMarketplacePluginRequest{ Id: id, } - _, appErr := a.Srv().pluginService.InstallMarketplacePlugin(installRequest) + _, appErr := a.Channels().InstallMarketplacePlugin(installRequest) if appErr != nil { mlog.Error("Failed to install plugin for onboarding", mlog.String("id", id), mlog.Err(appErr)) return diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 2b65286070..63a3389735 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -13021,23 +13021,6 @@ func (a *OpenTracingAppLayer) PluginCommandsForTeam(teamID string) []*model.Comm return resultVar0 } -func (a *OpenTracingAppLayer) PluginService() *app.PluginService { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PluginService") - - 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.PluginService() - - return resultVar0 -} - func (a *OpenTracingAppLayer) PopulateWebConnConfig(s *model.Session, cfg *platform.WebConnConfig, seqVal string) (*platform.WebConnConfig, error) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PopulateWebConnConfig") diff --git a/app/plugin.go b/app/plugin.go index b182af6cf5..b48503de5d 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -20,37 +20,16 @@ import ( svg "github.com/h2non/go-is-svg" "github.com/pkg/errors" - "github.com/mattermost/mattermost-server/v6/app/platform" "github.com/mattermost/mattermost-server/v6/app/request" - "github.com/mattermost/mattermost-server/v6/config" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/product" - "github.com/mattermost/mattermost-server/v6/services/httpservice" "github.com/mattermost/mattermost-server/v6/services/marketplace" "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/utils/fileutils" ) -type PluginService struct { - platform *platform.PlatformService - channels *Channels - fileStore filestore.FileBackend - httpService httpservice.HTTPService - - pluginCommandsLock sync.RWMutex - pluginCommands []*PluginCommand - pluginsLock sync.RWMutex - pluginsEnvironment *plugin.Environment - pluginConfigListenerID string - // collectionTypes maps from collection types to the registering plugin id - collectionTypes map[string]string - // topicTypes maps from topic types to collection types - topicTypes map[string]string - collectionAndTopicTypesMut sync.Mutex -} - const prepackagedPluginsDir = "prepackaged_plugins" type pluginSignaturePath struct { @@ -84,91 +63,20 @@ func (rs *routerService) getHandler(productID string) (http.Handler, bool) { return handler, ok } -func NewPluginService(platform *platform.PlatformService, channels *Channels, httpService httpservice.HTTPService, router *mux.Router) *PluginService { - ps := &PluginService{ - platform: platform, - channels: channels, - fileStore: platform.FileBackend(), - httpService: httpService, - collectionTypes: make(map[string]string), - topicTypes: make(map[string]string), - } - - pluginsRoute := router.PathPrefix("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter() - pluginsRoute.HandleFunc("", ps.ServePluginRequest) - pluginsRoute.HandleFunc("/public/{public_file:.*}", ps.ServePluginPublicRequest) - pluginsRoute.HandleFunc("/{anything:.*}", ps.ServePluginRequest) - - ps.initPlugins(request.EmptyContext(platform.Log()), *platform.Config().PluginSettings.Directory, *platform.Config().PluginSettings.ClientDirectory) - - return ps -} - -func (a *App) PluginService() *PluginService { - return a.ch.srv.pluginService -} - -func (s *Server) InitializePluginService() error { - product, ok := s.products["channels"] - if !ok { - return errors.New("unable to find channels product") - } - channels, ok := product.(*Channels) - if !ok { - return errors.New("unable to cast product to channels product") - } - s.pluginService = NewPluginService(s.platform, channels, s.httpService, s.Router) - - // Start plugins - ctx := request.EmptyContext(s.platform.Log()) - - // Add the config listener to enable/disable plugins - s.platform.AddConfigListener(func(prevCfg, cfg *model.Config) { - // We compute the difference between configs - // to ensure we don't re-init plugins unnecessarily. - diffs, err := config.Diff(prevCfg, cfg) - if err != nil { - s.platform.Log().Warn("Error in comparing configs", mlog.Err(err)) - return - } - - hasDiff := false - // TODO: This could be a method on ConfigDiffs itself - for _, diff := range diffs { - if strings.HasPrefix(diff.Path, "PluginSettings.") { - hasDiff = true - break - } - } - - // Do only if some plugin related settings has changed. - if hasDiff { - if *cfg.PluginSettings.Enable { - s.pluginService.initPlugins(ctx, *cfg.PluginSettings.Directory, *s.Config().PluginSettings.ClientDirectory) - } else { - s.pluginService.ShutDownPlugins() - } - } - - }) - - return nil -} - // GetPluginsEnvironment returns the plugin environment for use if plugins are enabled and // initialized. // // To get the plugins environment when the plugins are disabled, manually acquire the plugins // lock instead. -func (s *PluginService) GetPluginsEnvironment() *plugin.Environment { - if !*s.platform.Config().PluginSettings.Enable { +func (ch *Channels) GetPluginsEnvironment() *plugin.Environment { + if !*ch.cfgSvc.Config().PluginSettings.Enable { return nil } - s.pluginsLock.RLock() - defer s.pluginsLock.RUnlock() + ch.pluginsLock.RLock() + defer ch.pluginsLock.RUnlock() - return s.pluginsEnvironment + return ch.pluginsEnvironment } // GetPluginsEnvironment returns the plugin environment for use if plugins are enabled and @@ -177,33 +85,33 @@ func (s *PluginService) GetPluginsEnvironment() *plugin.Environment { // To get the plugins environment when the plugins are disabled, manually acquire the plugins // lock instead. func (a *App) GetPluginsEnvironment() *plugin.Environment { - return a.ch.srv.pluginService.GetPluginsEnvironment() + return a.ch.GetPluginsEnvironment() } -func (s *PluginService) SetPluginsEnvironment(pluginsEnvironment *plugin.Environment) { - s.pluginsLock.Lock() - defer s.pluginsLock.Unlock() +func (ch *Channels) SetPluginsEnvironment(pluginsEnvironment *plugin.Environment) { + ch.pluginsLock.Lock() + defer ch.pluginsLock.Unlock() - s.pluginsEnvironment = pluginsEnvironment - s.platform.SetPluginsEnvironment(pluginsEnvironment) + ch.pluginsEnvironment = pluginsEnvironment + ch.srv.Platform().SetPluginsEnvironment(pluginsEnvironment) } -func (s *PluginService) syncPluginsActiveState() { +func (ch *Channels) syncPluginsActiveState() { // Acquiring lock manually, as plugins might be disabled. See GetPluginsEnvironment. - s.pluginsLock.RLock() - pluginsEnvironment := s.pluginsEnvironment - s.pluginsLock.RUnlock() + ch.pluginsLock.RLock() + pluginsEnvironment := ch.pluginsEnvironment + ch.pluginsLock.RUnlock() if pluginsEnvironment == nil { return } - config := s.platform.Config().PluginSettings + config := ch.cfgSvc.Config().PluginSettings if *config.Enable { availablePlugins, err := pluginsEnvironment.Available() if err != nil { - s.platform.Log().Error("Unable to get available plugins", mlog.Err(err)) + ch.srv.Log().Error("Unable to get available plugins", mlog.Err(err)) return } @@ -217,24 +125,24 @@ func (s *PluginService) syncPluginsActiveState() { pluginEnabled = state.Enable } - if hasOverride, value := s.getPluginStateOverride(pluginID); hasOverride { + if hasOverride, value := ch.getPluginStateOverride(pluginID); hasOverride { pluginEnabled = value } if pluginEnabled { // Disable focalboard in product mode. - if pluginID == model.PluginIdFocalboard && s.platform.Config().FeatureFlags.BoardsProduct { + if pluginID == model.PluginIdFocalboard && ch.cfgSvc.Config().FeatureFlags.BoardsProduct { msg := "Plugin cannot run in product mode. Disabling." mlog.Warn(msg, mlog.String("plugin_id", model.PluginIdFocalboard)) // This is a mini-version of ch.disablePlugin. // We don't call that directly, because that will recursively call // this method. - s.platform.UpdateConfig(func(cfg *model.Config) { + ch.cfgSvc.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.PluginStates[pluginID] = &model.PluginState{Enable: false} }) pluginsEnvironment.SetPluginError(pluginID, msg) - s.unregisterPluginCommands(pluginID) + ch.unregisterPluginCommands(pluginID) disabledPlugins = append(disabledPlugins, plugin) continue } @@ -258,7 +166,7 @@ func (s *PluginService) syncPluginsActiveState() { if deactivated && plugin.Manifest.HasClient() { message := model.NewWebSocketEvent(model.WebsocketEventPluginDisabled, "", "", "", nil, "") message.Add("manifest", plugin.Manifest.ClientManifest()) - s.platform.Publish(message) + ch.srv.platform.Publish(message) } }(plugin) } @@ -272,14 +180,14 @@ func (s *PluginService) syncPluginsActiveState() { pluginID := plugin.Manifest.Id updatedManifest, activated, err := pluginsEnvironment.Activate(pluginID) if err != nil { - plugin.WrapLogger(s.platform.Log().(*mlog.Logger)).Error("Unable to activate plugin", mlog.Err(err)) + plugin.WrapLogger(ch.srv.Log()).Error("Unable to activate plugin", mlog.Err(err)) return } if activated { // Notify all cluster clients if ready - if err := s.notifyPluginEnabled(updatedManifest); err != nil { - s.platform.Log().Error("Failed to notify cluster on plugin enable", mlog.Err(err)) + if err := ch.notifyPluginEnabled(updatedManifest); err != nil { + ch.srv.Log().Error("Failed to notify cluster on plugin enable", mlog.Err(err)) } } }(plugin) @@ -289,7 +197,7 @@ func (s *PluginService) syncPluginsActiveState() { pluginsEnvironment.Shutdown() } - if err := s.notifyPluginStatusesChanged(); err != nil { + if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } } @@ -299,29 +207,27 @@ func (a *App) NewPluginAPI(c *request.Context, manifest *model.Manifest) plugin. } func (a *App) InitPlugins(c *request.Context, pluginDir, webappPluginDir string) { - a.ch.srv.pluginService.initPlugins(c, pluginDir, webappPluginDir) + a.ch.initPlugins(c, pluginDir, webappPluginDir) } -func (s *PluginService) initPlugins(c *request.Context, pluginDir, webappPluginDir string) { +func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir string) { // Acquiring lock manually, as plugins might be disabled. See GetPluginsEnvironment. defer func() { - // platform service requires plugins environment to be initialized - // so that it can use it in cluster service initialization - s.platform.SetPluginsEnvironment(s.pluginsEnvironment) + ch.srv.Platform().SetPluginsEnvironment(ch.pluginsEnvironment) }() - s.pluginsLock.RLock() - pluginsEnvironment := s.pluginsEnvironment - s.pluginsLock.RUnlock() - if pluginsEnvironment != nil || !*s.platform.Config().PluginSettings.Enable { - s.syncPluginsActiveState() + ch.pluginsLock.RLock() + pluginsEnvironment := ch.pluginsEnvironment + ch.pluginsLock.RUnlock() + if pluginsEnvironment != nil || !*ch.cfgSvc.Config().PluginSettings.Enable { + ch.syncPluginsActiveState() if pluginsEnvironment != nil { - pluginsEnvironment.TogglePluginHealthCheckJob(*s.platform.Config().PluginSettings.EnableHealthCheck) + pluginsEnvironment.TogglePluginHealthCheckJob(*ch.cfgSvc.Config().PluginSettings.EnableHealthCheck) } return } - s.platform.Log().Info("Starting up plugins") + ch.srv.Log().Info("Starting up plugins") if err := os.Mkdir(pluginDir, 0744); err != nil && !os.IsExist(err) { mlog.Error("Failed to start up plugins", mlog.Err(err)) @@ -334,70 +240,70 @@ func (s *PluginService) initPlugins(c *request.Context, pluginDir, webappPluginD } newAPIFunc := func(manifest *model.Manifest) plugin.API { - return New(ServerConnector(s.channels)).NewPluginAPI(c, manifest) + return New(ServerConnector(ch)).NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newAPIFunc, NewDriverImpl(s.platform), pluginDir, webappPluginDir, s.platform.Log().(*mlog.Logger), s.platform.Metrics()) + env, err := plugin.NewEnvironment(newAPIFunc, NewDriverImpl(ch.srv), pluginDir, webappPluginDir, ch.srv.Log(), ch.srv.GetMetrics()) if err != nil { mlog.Error("Failed to start up plugins", mlog.Err(err)) return } - s.pluginsLock.Lock() - s.pluginsEnvironment = env - s.pluginsLock.Unlock() + ch.pluginsLock.Lock() + ch.pluginsEnvironment = env + ch.pluginsLock.Unlock() - s.pluginsEnvironment.TogglePluginHealthCheckJob(*s.platform.Config().PluginSettings.EnableHealthCheck) + ch.pluginsEnvironment.TogglePluginHealthCheckJob(*ch.cfgSvc.Config().PluginSettings.EnableHealthCheck) - if err := s.syncPlugins(); err != nil { + if err := ch.syncPlugins(); err != nil { mlog.Error("Failed to sync plugins from the file store", mlog.Err(err)) } - plugins := s.processPrepackagedPlugins(prepackagedPluginsDir) - pluginsEnvironment = s.GetPluginsEnvironment() + plugins := ch.processPrepackagedPlugins(prepackagedPluginsDir) + pluginsEnvironment = ch.GetPluginsEnvironment() if pluginsEnvironment == nil { mlog.Info("Plugins environment not found, server is likely shutting down") return } pluginsEnvironment.SetPrepackagedPlugins(plugins) - s.installFeatureFlagPlugins() + ch.installFeatureFlagPlugins() // Sync plugin active state when config changes. Also notify plugins. - s.pluginsLock.Lock() - s.platform.RemoveConfigListener(s.pluginConfigListenerID) - s.pluginConfigListenerID = s.platform.AddConfigListener(func(old, new *model.Config) { + ch.pluginsLock.Lock() + ch.RemoveConfigListener(ch.pluginConfigListenerID) + ch.pluginConfigListenerID = ch.AddConfigListener(func(old, new *model.Config) { // If plugin status remains unchanged, only then run this. // Because (*App).InitPlugins is already run as a config change hook. if *old.PluginSettings.Enable == *new.PluginSettings.Enable { - s.installFeatureFlagPlugins() - s.syncPluginsActiveState() + ch.installFeatureFlagPlugins() + ch.syncPluginsActiveState() } - if pluginsEnvironment := s.GetPluginsEnvironment(); pluginsEnvironment != nil { + if pluginsEnvironment := ch.GetPluginsEnvironment(); pluginsEnvironment != nil { pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { if err := hooks.OnConfigurationChange(); err != nil { - s.platform.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) + ch.srv.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) } return true }, plugin.OnConfigurationChangeID) } }) - s.pluginsLock.Unlock() + ch.pluginsLock.Unlock() - s.syncPluginsActiveState() + ch.syncPluginsActiveState() } // SyncPlugins synchronizes the plugins installed locally // with the plugin bundles available in the file store. func (a *App) SyncPlugins() *model.AppError { - return a.ch.srv.pluginService.syncPlugins() + return a.ch.syncPlugins() } // SyncPlugins synchronizes the plugins installed locally // with the plugin bundles available in the file store. -func (s *PluginService) syncPlugins() *model.AppError { +func (ch *Channels) syncPlugins() *model.AppError { mlog.Info("Syncing plugins from the file store") - pluginsEnvironment := s.GetPluginsEnvironment() + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return model.NewAppError("SyncPlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -413,14 +319,14 @@ func (s *PluginService) syncPlugins() *model.AppError { go func(pluginID string) { defer wg.Done() // Only handle managed plugins with .filestore flag file. - _, err := os.Stat(filepath.Join(*s.platform.Config().PluginSettings.Directory, pluginID, managedPluginFileName)) + _, err := os.Stat(filepath.Join(*ch.cfgSvc.Config().PluginSettings.Directory, pluginID, managedPluginFileName)) if os.IsNotExist(err) { mlog.Warn("Skipping sync for unmanaged plugin", mlog.String("plugin_id", pluginID)) } else if err != nil { mlog.Error("Skipping sync for plugin after failure to check if managed", mlog.String("plugin_id", pluginID), mlog.Err(err)) } else { mlog.Debug("Removing local installation of managed plugin before sync", mlog.String("plugin_id", pluginID)) - if err := s.removePluginLocally(pluginID); err != nil { + if err := ch.removePluginLocally(pluginID); err != nil { mlog.Error("Failed to remove local installation of managed plugin before sync", mlog.String("plugin_id", pluginID), mlog.Err(err)) } } @@ -429,7 +335,7 @@ func (s *PluginService) syncPlugins() *model.AppError { wg.Wait() // Install plugins from the file store. - pluginSignaturePathMap, appErr := s.getPluginsFromFolder() + pluginSignaturePathMap, appErr := ch.getPluginsFromFolder() if appErr != nil { return appErr } @@ -438,7 +344,7 @@ func (s *PluginService) syncPlugins() *model.AppError { wg.Add(1) go func(plugin *pluginSignaturePath) { defer wg.Done() - reader, appErr := s.fileStore.Reader(plugin.path) + reader, appErr := ch.srv.fileReader(plugin.path) if appErr != nil { mlog.Error("Failed to open plugin bundle from file store.", mlog.String("bundle", plugin.path), mlog.Err(appErr)) return @@ -446,8 +352,8 @@ func (s *PluginService) syncPlugins() *model.AppError { defer reader.Close() var signature filestore.ReadCloseSeeker - if *s.platform.Config().PluginSettings.RequirePluginSignature { - signature, appErr = s.fileStore.Reader(plugin.signaturePath) + if *ch.cfgSvc.Config().PluginSettings.RequirePluginSignature { + signature, appErr = ch.srv.fileReader(plugin.signaturePath) if appErr != nil { mlog.Error("Failed to open plugin signature from file store.", mlog.Err(appErr)) return @@ -456,7 +362,7 @@ func (s *PluginService) syncPlugins() *model.AppError { } mlog.Info("Syncing plugin from file store", mlog.String("bundle", plugin.path)) - if _, err := s.installPluginLocally(reader, signature, installPluginLocallyAlways); err != nil { + if _, err := ch.installPluginLocally(reader, signature, installPluginLocallyAlways); err != nil { mlog.Error("Failed to sync plugin from file store", mlog.String("bundle", plugin.path), mlog.Err(err)) } }(plugin) @@ -466,11 +372,11 @@ func (s *PluginService) syncPlugins() *model.AppError { return nil } -func (s *PluginService) ShutDownPlugins() { +func (ch *Channels) ShutDownPlugins() { // Acquiring lock manually, as plugins might be disabled. See GetPluginsEnvironment. - s.pluginsLock.RLock() - pluginsEnvironment := s.pluginsEnvironment - s.pluginsLock.RUnlock() + ch.pluginsLock.RLock() + pluginsEnvironment := ch.pluginsEnvironment + ch.pluginsLock.RUnlock() if pluginsEnvironment == nil { return } @@ -479,14 +385,14 @@ func (s *PluginService) ShutDownPlugins() { pluginsEnvironment.Shutdown() - s.platform.RemoveConfigListener(s.pluginConfigListenerID) - s.pluginConfigListenerID = "" + ch.RemoveConfigListener(ch.pluginConfigListenerID) + ch.pluginConfigListenerID = "" // Acquiring lock manually before cleaning up PluginsEnvironment. - s.pluginsLock.Lock() - defer s.pluginsLock.Unlock() - if s.pluginsEnvironment == pluginsEnvironment { - s.pluginsEnvironment = nil + ch.pluginsLock.Lock() + defer ch.pluginsLock.Unlock() + if ch.pluginsEnvironment == pluginsEnvironment { + ch.pluginsEnvironment = nil } else { mlog.Warn("Another PluginsEnvironment detected while shutting down plugins.") } @@ -512,11 +418,11 @@ func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { // activation if inactive anywhere in the cluster. // Notifies cluster peers through config change. func (a *App) EnablePlugin(id string) *model.AppError { - return a.PluginService().enablePlugin(id) + return a.ch.enablePlugin(id) } -func (s *PluginService) enablePlugin(id string) *model.AppError { - pluginsEnvironment := s.GetPluginsEnvironment() +func (ch *Channels) enablePlugin(id string) *model.AppError { + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return model.NewAppError("EnablePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -540,16 +446,16 @@ func (s *PluginService) enablePlugin(id string) *model.AppError { return model.NewAppError("EnablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusNotFound) } - if id == model.PluginIdFocalboard && s.platform.Config().FeatureFlags.BoardsProduct { + if id == model.PluginIdFocalboard && ch.cfgSvc.Config().FeatureFlags.BoardsProduct { return model.NewAppError("EnablePlugin", "app.plugin.product_mode.app_error", map[string]any{"Name": model.PluginIdFocalboard}, "", http.StatusBadRequest) } - s.platform.UpdateConfig(func(cfg *model.Config) { + ch.cfgSvc.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: true} }) // This call will implicitly invoke SyncPluginsActiveState which will activate enabled plugins. - if _, _, err := s.platform.SaveConfig(s.platform.Config(), true); err != nil { + if _, _, err := ch.cfgSvc.SaveConfig(ch.cfgSvc.Config(), true); err != nil { if err.Id == "ent.cluster.save_config.error" { return model.NewAppError("EnablePlugin", "app.plugin.cluster.save_config.app_error", nil, "", http.StatusInternalServerError) } @@ -562,7 +468,7 @@ func (s *PluginService) enablePlugin(id string) *model.AppError { // DisablePlugin will set the config for an installed plugin to disabled, triggering deactivation if active. // Notifies cluster peers through config change. func (a *App) DisablePlugin(id string) *model.AppError { - appErr := a.ch.srv.pluginService.disablePlugin(id) + appErr := a.ch.disablePlugin(id) if appErr != nil { return appErr } @@ -570,22 +476,22 @@ func (a *App) DisablePlugin(id string) *model.AppError { return nil } -func (s *PluginService) disablePlugin(id string) *model.AppError { +func (ch *Channels) disablePlugin(id string) *model.AppError { // find all collectionTypes registered by plugin - for collectionTypeToRemove, existingPluginId := range s.collectionTypes { + for collectionTypeToRemove, existingPluginId := range ch.collectionTypes { if existingPluginId != id { continue } // find all topicTypes for existing collectionType - for topicTypeToRemove, existingCollectionType := range s.topicTypes { + for topicTypeToRemove, existingCollectionType := range ch.topicTypes { if existingCollectionType == collectionTypeToRemove { - delete(s.topicTypes, topicTypeToRemove) + delete(ch.topicTypes, topicTypeToRemove) } } - delete(s.collectionTypes, collectionTypeToRemove) + delete(ch.collectionTypes, collectionTypeToRemove) } - pluginsEnvironment := s.GetPluginsEnvironment() + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return model.NewAppError("DisablePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -609,13 +515,13 @@ func (s *PluginService) disablePlugin(id string) *model.AppError { return model.NewAppError("DisablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusNotFound) } - s.platform.UpdateConfig(func(cfg *model.Config) { + ch.cfgSvc.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: false} }) - s.unregisterPluginCommands(id) + ch.unregisterPluginCommands(id) // This call will implicitly invoke SyncPluginsActiveState which will deactivate disabled plugins. - if _, _, err := s.platform.SaveConfig(s.platform.Config(), true); err != nil { + if _, _, err := ch.cfgSvc.SaveConfig(ch.cfgSvc.Config(), true); err != nil { return model.NewAppError("DisablePlugin", "app.plugin.config.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -702,8 +608,8 @@ func (a *App) GetMarketplacePlugins(filter *model.MarketplacePluginFilter) ([]*m // getPrepackagedPlugin returns a pre-packaged plugin. // // If version is empty, the first matching plugin is returned. -func (s *PluginService) getPrepackagedPlugin(pluginID, version string) (*plugin.PrepackagedPlugin, *model.AppError) { - pluginsEnvironment := s.GetPluginsEnvironment() +func (ch *Channels) getPrepackagedPlugin(pluginID, version string) (*plugin.PrepackagedPlugin, *model.AppError) { + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("getPrepackagedPlugin", "app.plugin.config.app_error", nil, "plugin environment is nil", http.StatusInternalServerError) } @@ -721,16 +627,16 @@ func (s *PluginService) getPrepackagedPlugin(pluginID, version string) (*plugin. // getRemoteMarketplacePlugin returns plugin from marketplace-server. // // If version is empty, the latest compatible version is used. -func (s *PluginService) getRemoteMarketplacePlugin(pluginID, version string) (*model.BaseMarketplacePlugin, *model.AppError) { +func (ch *Channels) getRemoteMarketplacePlugin(pluginID, version string) (*model.BaseMarketplacePlugin, *model.AppError) { marketplaceClient, err := marketplace.NewClient( - *s.platform.Config().PluginSettings.MarketplaceURL, - s.httpService, + *ch.cfgSvc.Config().PluginSettings.MarketplaceURL, + ch.srv.HTTPService(), ) if err != nil { return nil, model.NewAppError("GetMarketplacePlugin", "app.plugin.marketplace_client.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - filter := s.getBaseMarketplaceFilter() + filter := ch.getBaseMarketplaceFilter() filter.PluginId = pluginID var plugin *model.BaseMarketplacePlugin @@ -885,15 +791,15 @@ func (a *App) mergeLocalPlugins(remoteMarketplacePlugins map[string]*model.Marke } func (a *App) getBaseMarketplaceFilter() *model.MarketplacePluginFilter { - return a.ch.srv.pluginService.getBaseMarketplaceFilter() + return a.ch.getBaseMarketplaceFilter() } -func (s *PluginService) getBaseMarketplaceFilter() *model.MarketplacePluginFilter { +func (ch *Channels) getBaseMarketplaceFilter() *model.MarketplacePluginFilter { filter := &model.MarketplacePluginFilter{ ServerVersion: model.CurrentVersion, } - license := s.platform.License() + license := ch.srv.License() if license != nil && license.HasEnterpriseMarketplacePlugins() { filter.EnterprisePlugins = true } @@ -940,8 +846,8 @@ func pluginMatchesFilter(manifest *model.Manifest, filter string) bool { // it will notify all connected websocket clients (across all peers) to trigger the (re-)installation. // There is a small chance that this never occurs, because the last server to finish installing dies before it can announce. // There is also a chance that multiple servers notify, but the webapp handles this idempotently. -func (s *PluginService) notifyPluginEnabled(manifest *model.Manifest) error { - pluginsEnvironment := s.GetPluginsEnvironment() +func (ch *Channels) notifyPluginEnabled(manifest *model.Manifest) error { + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return errors.New("pluginsEnvironment is nil") } @@ -951,15 +857,15 @@ func (s *PluginService) notifyPluginEnabled(manifest *model.Manifest) error { var statuses model.PluginStatuses - if s.platform.Cluster() != nil { + if ch.srv.platform.Cluster() != nil { var err *model.AppError - statuses, err = s.platform.Cluster().GetPluginStatuses() + statuses, err = ch.srv.platform.Cluster().GetPluginStatuses() if err != nil { return err } } - localStatus, err := s.GetPluginStatus(manifest.Id) + localStatus, err := ch.GetPluginStatus(manifest.Id) if err != nil { return err } @@ -979,26 +885,26 @@ func (s *PluginService) notifyPluginEnabled(manifest *model.Manifest) error { // Notify all cluster peer clients. message := model.NewWebSocketEvent(model.WebsocketEventPluginEnabled, "", "", "", nil, "") message.Add("manifest", manifest.ClientManifest()) - s.platform.Publish(message) + ch.srv.platform.Publish(message) return nil } -func (s *PluginService) getPluginsFromFolder() (map[string]*pluginSignaturePath, *model.AppError) { - fileStorePaths, appErr := s.fileStore.ListDirectory(fileStorePluginFolder) +func (ch *Channels) getPluginsFromFolder() (map[string]*pluginSignaturePath, *model.AppError) { + fileStorePaths, appErr := ch.srv.listDirectory(fileStorePluginFolder, false) if appErr != nil { return nil, model.NewAppError("getPluginsFromDir", "app.plugin.sync.list_filestore.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) } - return s.getPluginsFromFilePaths(fileStorePaths), nil + return ch.getPluginsFromFilePaths(fileStorePaths), nil } -func (s *PluginService) getPluginsFromFilePaths(fileStorePaths []string) map[string]*pluginSignaturePath { +func (ch *Channels) getPluginsFromFilePaths(fileStorePaths []string) map[string]*pluginSignaturePath { pluginSignaturePathMap := make(map[string]*pluginSignaturePath) fsPrefix := "" - if *s.platform.Config().FileSettings.DriverName == model.ImageDriverS3 { - ptr := s.platform.Config().FileSettings.AmazonS3PathPrefix + if *ch.cfgSvc.Config().FileSettings.DriverName == model.ImageDriverS3 { + ptr := ch.cfgSvc.Config().FileSettings.AmazonS3PathPrefix if ptr != nil && *ptr != "" { fsPrefix = *ptr + "/" } @@ -1031,7 +937,7 @@ func (s *PluginService) getPluginsFromFilePaths(fileStorePaths []string) map[str return pluginSignaturePathMap } -func (s *PluginService) processPrepackagedPlugins(pluginsDir string) []*plugin.PrepackagedPlugin { +func (ch *Channels) processPrepackagedPlugins(pluginsDir string) []*plugin.PrepackagedPlugin { prepackagedPluginsDir, found := fileutils.FindDir(pluginsDir) if !found { return nil @@ -1047,7 +953,7 @@ func (s *PluginService) processPrepackagedPlugins(pluginsDir string) []*plugin.P return nil } - pluginSignaturePathMap := s.getPluginsFromFilePaths(fileStorePaths) + pluginSignaturePathMap := ch.getPluginsFromFilePaths(fileStorePaths) plugins := make([]*plugin.PrepackagedPlugin, 0, len(pluginSignaturePathMap)) prepackagedPlugins := make(chan *plugin.PrepackagedPlugin, len(pluginSignaturePathMap)) @@ -1056,7 +962,7 @@ func (s *PluginService) processPrepackagedPlugins(pluginsDir string) []*plugin.P wg.Add(1) go func(psPath *pluginSignaturePath) { defer wg.Done() - p, err := s.processPrepackagedPlugin(psPath) + p, err := ch.processPrepackagedPlugin(psPath) if err != nil { mlog.Error("Failed to install prepackaged plugin", mlog.String("path", psPath.path), mlog.Err(err)) return @@ -1077,7 +983,7 @@ func (s *PluginService) processPrepackagedPlugins(pluginsDir string) []*plugin.P // processPrepackagedPlugin will return the prepackaged plugin metadata and will also // install the prepackaged plugin if it had been previously enabled and AutomaticPrepackagedPlugins is true. -func (s *PluginService) processPrepackagedPlugin(pluginPath *pluginSignaturePath) (*plugin.PrepackagedPlugin, error) { +func (ch *Channels) processPrepackagedPlugin(pluginPath *pluginSignaturePath) (*plugin.PrepackagedPlugin, error) { mlog.Debug("Processing prepackaged plugin", mlog.String("path", pluginPath.path)) fileReader, err := os.Open(pluginPath.path) @@ -1098,18 +1004,18 @@ func (s *PluginService) processPrepackagedPlugin(pluginPath *pluginSignaturePath } // Skip installing the plugin at all if automatic prepackaged plugins is disabled - if !*s.platform.Config().PluginSettings.AutomaticPrepackagedPlugins { + if !*ch.cfgSvc.Config().PluginSettings.AutomaticPrepackagedPlugins { return plugin, nil } // Skip installing if the plugin is has not been previously enabled. - pluginState := s.platform.Config().PluginSettings.PluginStates[plugin.Manifest.Id] + pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[plugin.Manifest.Id] if pluginState == nil || !pluginState.Enable { return plugin, nil } mlog.Debug("Installing prepackaged plugin", mlog.String("path", pluginPath.path)) - if _, err := s.installExtractedPlugin(plugin.Manifest, pluginDir, installPluginLocallyOnlyIfNewOrUpgrade); err != nil { + if _, err := ch.installExtractedPlugin(plugin.Manifest, pluginDir, installPluginLocallyOnlyIfNewOrUpgrade); err != nil { return nil, errors.Wrapf(err, "Failed to install extracted prepackaged plugin %s", pluginPath.path) } @@ -1117,24 +1023,24 @@ func (s *PluginService) processPrepackagedPlugin(pluginPath *pluginSignaturePath } // installFeatureFlagPlugins handles the automatic installation/upgrade of plugins from feature flags -func (s *PluginService) installFeatureFlagPlugins() { - ffControledPlugins := s.platform.Config().FeatureFlags.Plugins() +func (ch *Channels) installFeatureFlagPlugins() { + ffControledPlugins := ch.cfgSvc.Config().FeatureFlags.Plugins() // Respect the automatic prepackaged disable setting - if !*s.platform.Config().PluginSettings.AutomaticPrepackagedPlugins { + if !*ch.cfgSvc.Config().PluginSettings.AutomaticPrepackagedPlugins { return } for pluginID, version := range ffControledPlugins { // Skip installing if the plugin has been previously disabled. - pluginState := s.platform.Config().PluginSettings.PluginStates[pluginID] + pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[pluginID] if pluginState != nil && !pluginState.Enable { - s.platform.Log().Debug("Not auto installing/upgrade because plugin was disabled", mlog.String("plugin_id", pluginID), mlog.String("version", version)) + ch.srv.Log().Debug("Not auto installing/upgrade because plugin was disabled", mlog.String("plugin_id", pluginID), mlog.String("version", version)) continue } // Check if we already installed this version as InstallMarketplacePlugin can't handle re-installs well. - pluginStatus, err := s.GetPluginStatus(pluginID) + pluginStatus, err := ch.GetPluginStatus(pluginID) pluginExists := err == nil if pluginExists && pluginStatus.Version == version { continue @@ -1142,37 +1048,37 @@ func (s *PluginService) installFeatureFlagPlugins() { if version != "" && version != "control" { // If we are on-prem skip installation if this is a downgrade - license := s.platform.License() + license := ch.srv.License() inCloud := license != nil && *license.Features.Cloud if !inCloud && pluginExists { parsedVersion, err := semver.Parse(version) if err != nil { - s.platform.Log().Debug("Bad version from feature flag", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) + ch.srv.Log().Debug("Bad version from feature flag", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) return } parsedExistingVersion, err := semver.Parse(pluginStatus.Version) if err != nil { - s.platform.Log().Debug("Bad version from plugin manifest", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", pluginStatus.Version)) + ch.srv.Log().Debug("Bad version from plugin manifest", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", pluginStatus.Version)) return } if parsedVersion.LTE(parsedExistingVersion) { - s.platform.Log().Debug("Skip installation because given version was a downgrade and on-prem installations should not downgrade.", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", pluginStatus.Version)) + ch.srv.Log().Debug("Skip installation because given version was a downgrade and on-prem installations should not downgrade.", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", pluginStatus.Version)) return } } - _, err := s.InstallMarketplacePlugin(&model.InstallMarketplacePluginRequest{ + _, err := ch.InstallMarketplacePlugin(&model.InstallMarketplacePluginRequest{ Id: pluginID, Version: version, }) if err != nil { - s.platform.Log().Debug("Unable to install plugin from FF manifest", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) + ch.srv.Log().Debug("Unable to install plugin from FF manifest", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) } else { - if err := s.enablePlugin(pluginID); err != nil { - s.platform.Log().Debug("Unable to enable plugin installed from feature flag.", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) + if err := ch.enablePlugin(pluginID); err != nil { + ch.srv.Log().Debug("Unable to enable plugin installed from feature flag.", mlog.String("plugin_id", pluginID), mlog.Err(err), mlog.String("version", version)) } else { - s.platform.Log().Debug("Installed and enabled plugin.", mlog.String("plugin_id", pluginID), mlog.String("version", version)) + ch.srv.Log().Debug("Installed and enabled plugin.", mlog.String("plugin_id", pluginID), mlog.String("version", version)) } } } @@ -1227,15 +1133,15 @@ func getIcon(iconPath string) (string, error) { return fmt.Sprintf("data:image/svg+xml;base64,%s", base64.StdEncoding.EncodeToString(icon)), nil } -func (s *PluginService) getPluginStateOverride(pluginID string) (bool, bool) { +func (ch *Channels) getPluginStateOverride(pluginID string) (bool, bool) { switch pluginID { case model.PluginIdApps: // Tie Apps proxy disabled status to the feature flag. - if !s.platform.Config().FeatureFlags.AppsEnabled { + if !ch.cfgSvc.Config().FeatureFlags.AppsEnabled { return true, false } case model.PluginIdCalls: - if !s.platform.Config().FeatureFlags.CallsEnabled { + if !ch.cfgSvc.Config().FeatureFlags.CallsEnabled { return true, false } } diff --git a/app/plugin_api.go b/app/plugin_api.go index d4aa7c7deb..accedf37ba 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -886,7 +886,7 @@ func (api *PluginAPI) DisablePlugin(id string) *model.AppError { } func (api *PluginAPI) RemovePlugin(id string) *model.AppError { - return api.app.Srv().pluginService.RemovePlugin(id) + return api.app.Channels().RemovePlugin(id) } func (api *PluginAPI) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) { @@ -1235,7 +1235,7 @@ func (api *PluginAPI) GetCloudLimits() (*model.ProductLimits, error) { // RegisterCollectionAndTopic informs the server that this plugin handles // the given collection and topic types. func (api *PluginAPI) RegisterCollectionAndTopic(collectionType, topicType string) error { - return api.app.Srv().pluginService.registerCollectionAndTopic(api.id, collectionType, topicType) + return api.app.registerCollectionAndTopic(api.id, collectionType, topicType) } func (api *PluginAPI) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index dc51f58421..9f864109bf 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -92,7 +92,7 @@ func setupMultiPluginAPITest(t *testing.T, pluginCodes []string, pluginManifests return app.NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv().Platform()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) require.NoError(t, err) require.Equal(t, len(pluginCodes), len(pluginIDs)) @@ -119,7 +119,7 @@ func setupMultiPluginAPITest(t *testing.T, pluginCodes []string, pluginManifests }) } - app.PluginService().SetPluginsEnvironment(env) + app.ch.SetPluginsEnvironment(env) return pluginDir } @@ -849,7 +849,7 @@ func TestPluginAPIGetPlugins(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server.Platform()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) pluginIDs := []string{"pluginid1", "pluginid2", "pluginid3"} @@ -866,7 +866,7 @@ func TestPluginAPIGetPlugins(t *testing.T) { require.True(t, activated) pluginManifests = append(pluginManifests, manifest) } - th.App.PluginService().SetPluginsEnvironment(env) + th.App.ch.SetPluginsEnvironment(env) // Deactivate the last one for testing success := env.Deactivate(pluginIDs[len(pluginIDs)-1]) @@ -937,10 +937,10 @@ func TestInstallPlugin(t *testing.T) { return app.NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv().Platform()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) require.NoError(t, err) - app.PluginService().SetPluginsEnvironment(env) + app.ch.SetPluginsEnvironment(env) backend := filepath.Join(pluginDir, pluginID, "backend.exe") utils.CompileGo(t, pluginCode, backend) @@ -1632,10 +1632,10 @@ func TestAPIMetrics(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server.Platform()), pluginDir, webappPluginDir, th.App.Log(), metricsMock) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) require.NoError(t, err) - th.App.PluginService().SetPluginsEnvironment(env) + th.App.ch.SetPluginsEnvironment(env) pluginID := model.NewId() backend := filepath.Join(pluginDir, pluginID, "backend.exe") @@ -2079,10 +2079,10 @@ func TestRegisterCollectionAndTopic(t *testing.T) { return th.App.NewPluginAPI(th.Context, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv().Platform()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) - th.App.PluginService().SetPluginsEnvironment(env) + th.App.ch.SetPluginsEnvironment(env) pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` @@ -2179,10 +2179,10 @@ func TestPluginUploadsAPI(t *testing.T) { newPluginAPI := func(manifest *model.Manifest) plugin.API { return th.App.NewPluginAPI(th.Context, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv().Platform()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) - th.App.PluginService().SetPluginsEnvironment(env) + th.App.ch.SetPluginsEnvironment(env) pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` diff --git a/app/plugin_commands.go b/app/plugin_commands.go index 8d034931e9..e70c69a38a 100644 --- a/app/plugin_commands.go +++ b/app/plugin_commands.go @@ -22,10 +22,6 @@ type PluginCommand struct { } func (a *App) RegisterPluginCommand(pluginID string, command *model.Command) error { - return a.Srv().pluginService.registerPluginCommand(pluginID, command) -} - -func (s *PluginService) registerPluginCommand(pluginID string, command *model.Command) error { if command.Trigger == "" { return errors.New("invalid command") } @@ -59,10 +55,10 @@ func (s *PluginService) registerPluginCommand(pluginID string, command *model.Co AutocompleteIconData: command.AutocompleteIconData, } - s.pluginCommandsLock.Lock() - defer s.pluginCommandsLock.Unlock() + a.ch.pluginCommandsLock.Lock() + defer a.ch.pluginCommandsLock.Unlock() - for _, pc := range s.pluginCommands { + for _, pc := range a.ch.pluginCommands { if pc.Command.Trigger == command.Trigger && pc.Command.TeamId == command.TeamId { if pc.PluginId == pluginID { pc.Command = command @@ -71,7 +67,7 @@ func (s *PluginService) registerPluginCommand(pluginID string, command *model.Co } } - s.pluginCommands = append(s.pluginCommands, &PluginCommand{ + a.ch.pluginCommands = append(a.ch.pluginCommands, &PluginCommand{ Command: command, PluginId: pluginID, }) @@ -79,47 +75,39 @@ func (s *PluginService) registerPluginCommand(pluginID string, command *model.Co } func (a *App) UnregisterPluginCommand(pluginID, teamID, trigger string) { - a.Srv().pluginService.unregisterPluginCommand(pluginID, teamID, trigger) -} - -func (s *PluginService) unregisterPluginCommand(pluginID, teamID, trigger string) { trigger = strings.ToLower(trigger) - s.pluginCommandsLock.Lock() - defer s.pluginCommandsLock.Unlock() + a.ch.pluginCommandsLock.Lock() + defer a.ch.pluginCommandsLock.Unlock() var remaining []*PluginCommand - for _, pc := range s.pluginCommands { + for _, pc := range a.ch.pluginCommands { if pc.Command.TeamId != teamID || pc.Command.Trigger != trigger { remaining = append(remaining, pc) } } - s.pluginCommands = remaining + a.ch.pluginCommands = remaining } -func (s *PluginService) unregisterPluginCommands(pluginID string) { - s.pluginCommandsLock.Lock() - defer s.pluginCommandsLock.Unlock() +func (ch *Channels) unregisterPluginCommands(pluginID string) { + ch.pluginCommandsLock.Lock() + defer ch.pluginCommandsLock.Unlock() var remaining []*PluginCommand - for _, pc := range s.pluginCommands { + for _, pc := range ch.pluginCommands { if pc.PluginId != pluginID { remaining = append(remaining, pc) } } - s.pluginCommands = remaining + ch.pluginCommands = remaining } func (a *App) PluginCommandsForTeam(teamID string) []*model.Command { - return a.Srv().pluginService.PluginCommandsForTeam(teamID) -} - -func (s *PluginService) PluginCommandsForTeam(teamID string) []*model.Command { - s.pluginCommandsLock.RLock() - defer s.pluginCommandsLock.RUnlock() + a.ch.pluginCommandsLock.RLock() + defer a.ch.pluginCommandsLock.RUnlock() var commands []*model.Command - for _, pc := range s.pluginCommands { + for _, pc := range a.ch.pluginCommands { if pc.Command.TeamId == "" || pc.Command.TeamId == teamID { commands = append(commands, pc.Command) } @@ -127,24 +115,6 @@ func (s *PluginService) PluginCommandsForTeam(teamID string) []*model.Command { return commands } -func (s *PluginService) getPluginCommandFromArgs(args *model.CommandArgs) *PluginCommand { - parts := strings.Split(args.Command, " ") - trigger := parts[0][1:] - trigger = strings.ToLower(trigger) - - var matched *PluginCommand - s.pluginCommandsLock.RLock() - for _, pc := range s.pluginCommands { - if (pc.Command.TeamId == "" || pc.Command.TeamId == args.TeamId) && pc.Command.Trigger == trigger { - matched = pc - break - } - } - s.pluginCommandsLock.RUnlock() - - return matched -} - // tryExecutePluginCommand attempts to run a command provided by a plugin based on the given arguments. If no such // command can be found, returns nil for all arguments. func (a *App) tryExecutePluginCommand(c request.CTX, args *model.CommandArgs) (*model.Command, *model.CommandResponse, *model.AppError) { @@ -152,7 +122,15 @@ func (a *App) tryExecutePluginCommand(c request.CTX, args *model.CommandArgs) (* trigger := parts[0][1:] trigger = strings.ToLower(trigger) - matched := a.Srv().pluginService.getPluginCommandFromArgs(args) + var matched *PluginCommand + a.ch.pluginCommandsLock.RLock() + for _, pc := range a.ch.pluginCommands { + if (pc.Command.TeamId == "" || pc.Command.TeamId == args.TeamId) && pc.Command.Trigger == trigger { + matched = pc + break + } + } + a.ch.pluginCommandsLock.RUnlock() if matched == nil { return nil, nil, nil } diff --git a/app/plugin_commands_test.go b/app/plugin_commands_test.go index 1cf9751d9a..e56cf74718 100644 --- a/app/plugin_commands_test.go +++ b/app/plugin_commands_test.go @@ -106,7 +106,7 @@ func TestPluginCommand(t *testing.T) { require.NotEqual(t, "plugin", commands.Trigger) } - th.App.PluginService().RemovePlugin(pluginIDs[0]) + th.App.ch.RemovePlugin(pluginIDs[0]) }) t.Run("re-entrant command registration on config change", func(t *testing.T) { @@ -207,7 +207,7 @@ func TestPluginCommand(t *testing.T) { killed = true } - th.App.PluginService().RemovePlugin(pluginIDs[0]) + th.App.ch.RemovePlugin(pluginIDs[0]) require.False(t, killed, "execute command appears to have deadlocked") }) @@ -285,7 +285,7 @@ func TestPluginCommand(t *testing.T) { require.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) require.Equal(t, "text", resp.Text) - th.App.PluginService().RemovePlugin(pluginIDs[0]) + th.App.ch.RemovePlugin(pluginIDs[0]) }) t.Run("plugin has crashed before execution of command", func(t *testing.T) { tearDown, pluginIDs, activationErrors := SetAppEnvironmentWithPlugins(t, []string{` @@ -329,7 +329,7 @@ func TestPluginCommand(t *testing.T) { require.Nil(t, resp) require.NotNil(t, err) require.Equal(t, err.Id, "model.plugin_command_error.error.app_error") - th.App.PluginService().RemovePlugin(pluginIDs[0]) + th.App.ch.RemovePlugin(pluginIDs[0]) }) t.Run("plugin has crashed due to the execution of the command", func(t *testing.T) { @@ -374,7 +374,7 @@ func TestPluginCommand(t *testing.T) { require.Nil(t, resp) require.NotNil(t, err) require.Equal(t, err.Id, "model.plugin_command_crash.error.app_error") - th.App.PluginService().RemovePlugin(pluginIDs[0]) + th.App.ch.RemovePlugin(pluginIDs[0]) }) t.Run("plugin returning status code 0", func(t *testing.T) { diff --git a/app/plugin_db_driver.go b/app/plugin_db_driver.go index 1d47ac3457..753bd0671c 100644 --- a/app/plugin_db_driver.go +++ b/app/plugin_db_driver.go @@ -9,7 +9,6 @@ import ( "database/sql/driver" "sync" - "github.com/mattermost/mattermost-server/v6/app/platform" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" ) @@ -19,7 +18,7 @@ import ( // a new entry tracked centrally in a map. Further requests operate on the // object ID. type DriverImpl struct { - ps *platform.PlatformService + s *Server connMut sync.RWMutex connMap map[string]*sql.Conn txMut sync.Mutex @@ -30,9 +29,9 @@ type DriverImpl struct { rowsMap map[string]driver.Rows } -func NewDriverImpl(s *platform.PlatformService) *DriverImpl { +func NewDriverImpl(s *Server) *DriverImpl { return &DriverImpl{ - ps: s, + s: s, connMap: make(map[string]*sql.Conn), txMap: make(map[string]driver.Tx), stMap: make(map[string]driver.Stmt), @@ -41,9 +40,9 @@ func NewDriverImpl(s *platform.PlatformService) *DriverImpl { } func (d *DriverImpl) Conn(isMaster bool) (string, error) { - dbFunc := d.ps.Store.GetInternalMasterDB + dbFunc := d.s.Platform().Store.GetInternalMasterDB if !isMaster { - dbFunc = d.ps.Store.GetInternalReplicaDB + dbFunc = d.s.Platform().Store.GetInternalReplicaDB } conn, err := dbFunc().Conn(context.Background()) if err != nil { diff --git a/app/plugin_event.go b/app/plugin_event.go index 19d9f1dabc..c30e2d1af5 100644 --- a/app/plugin_event.go +++ b/app/plugin_event.go @@ -9,10 +9,10 @@ import ( "github.com/mattermost/mattermost-server/v6/model" ) -func (s *PluginService) notifyClusterPluginEvent(event model.ClusterEvent, data model.PluginEventData) { +func (ch *Channels) notifyClusterPluginEvent(event model.ClusterEvent, data model.PluginEventData) { buf, _ := json.Marshal(data) - if s.platform.Cluster() != nil { - s.platform.Cluster().SendClusterMessage(&model.ClusterMessage{ + if ch.srv.platform.Cluster() != nil { + ch.srv.platform.Cluster().SendClusterMessage(&model.ClusterMessage{ Event: event, SendType: model.ClusterSendReliable, WaitForAllToSend: true, diff --git a/app/plugin_hooks_test.go b/app/plugin_hooks_test.go index 64665f2419..1b1673be93 100644 --- a/app/plugin_hooks_test.go +++ b/app/plugin_hooks_test.go @@ -33,10 +33,10 @@ func SetAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, a webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) - env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv().Platform()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) require.NoError(t, err) - app.PluginService().SetPluginsEnvironment(env) + app.ch.SetPluginsEnvironment(env) pluginIDs := []string{} activationErrors := []error{} for _, code := range pluginCode { @@ -1030,10 +1030,10 @@ func TestHookMetrics(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server.Platform()), pluginDir, webappPluginDir, th.App.Log(), metricsMock) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) require.NoError(t, err) - th.App.PluginService().SetPluginsEnvironment(env) + th.App.ch.SetPluginsEnvironment(env) pluginID := model.NewId() backend := filepath.Join(pluginDir, pluginID, "backend.exe") diff --git a/app/plugin_install.go b/app/plugin_install.go index f0c54a0da4..431c3abebe 100644 --- a/app/plugin_install.go +++ b/app/plugin_install.go @@ -58,10 +58,10 @@ const managedPluginFileName = ".filestore" // fileStorePluginFolder is the folder name in the file store of the plugin bundles installed. const fileStorePluginFolder = "plugins" -func (s *PluginService) installPluginFromData(data model.PluginEventData) { +func (ch *Channels) installPluginFromData(data model.PluginEventData) { mlog.Debug("Installing plugin as per cluster message", mlog.String("plugin_id", data.Id)) - pluginSignaturePathMap, appErr := s.getPluginsFromFolder() + pluginSignaturePathMap, appErr := ch.getPluginsFromFolder() if appErr != nil { mlog.Error("Failed to get plugin signatures from filestore. Can't install plugin from data.", mlog.Err(appErr)) return @@ -72,53 +72,53 @@ func (s *PluginService) installPluginFromData(data model.PluginEventData) { return } - reader, err := s.fileStore.Reader(plugin.path) - if err != nil { - mlog.Error("Failed to open plugin bundle from file store.", mlog.String("bundle", plugin.path), mlog.Err(err)) + reader, appErr := ch.srv.fileReader(plugin.path) + if appErr != nil { + mlog.Error("Failed to open plugin bundle from file store.", mlog.String("bundle", plugin.path), mlog.Err(appErr)) return } defer reader.Close() var signature filestore.ReadCloseSeeker - if *s.platform.Config().PluginSettings.RequirePluginSignature { - signature, err = s.fileStore.Reader(plugin.signaturePath) - if err != nil { - mlog.Error("Failed to open plugin signature from file store.", mlog.Err(err)) + if *ch.cfgSvc.Config().PluginSettings.RequirePluginSignature { + signature, appErr = ch.srv.fileReader(plugin.signaturePath) + if appErr != nil { + mlog.Error("Failed to open plugin signature from file store.", mlog.Err(appErr)) return } defer signature.Close() } - manifest, appErr := s.installPluginLocally(reader, signature, installPluginLocallyAlways) + manifest, appErr := ch.installPluginLocally(reader, signature, installPluginLocallyAlways) if appErr != nil { mlog.Error("Failed to sync plugin from file store", mlog.String("bundle", plugin.path), mlog.Err(appErr)) return } - if err2 := s.notifyPluginEnabled(manifest); err2 != nil { - mlog.Error("Failed notify plugin enabled", mlog.Err(err2)) + if err := ch.notifyPluginEnabled(manifest); err != nil { + mlog.Error("Failed notify plugin enabled", mlog.Err(err)) } - if err2 := s.notifyPluginStatusesChanged(); err2 != nil { - mlog.Error("Failed to notify plugin status changed", mlog.Err(err2)) + if err := ch.notifyPluginStatusesChanged(); err != nil { + mlog.Error("Failed to notify plugin status changed", mlog.Err(err)) } } -func (s *PluginService) removePluginFromData(data model.PluginEventData) { +func (ch *Channels) removePluginFromData(data model.PluginEventData) { mlog.Debug("Removing plugin as per cluster message", mlog.String("plugin_id", data.Id)) - if err := s.removePluginLocally(data.Id); err != nil { + if err := ch.removePluginLocally(data.Id); err != nil { mlog.Warn("Failed to remove plugin locally", mlog.Err(err), mlog.String("id", data.Id)) } - if err := s.notifyPluginStatusesChanged(); err != nil { + if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } } // InstallPluginWithSignature verifies and installs plugin. -func (s *PluginService) installPluginWithSignature(pluginFile, signature io.ReadSeeker) (*model.Manifest, *model.AppError) { - return s.installPlugin(pluginFile, signature, installPluginLocallyAlways) +func (ch *Channels) installPluginWithSignature(pluginFile, signature io.ReadSeeker) (*model.Manifest, *model.AppError) { + return ch.installPlugin(pluginFile, signature, installPluginLocallyAlways) } // InstallPlugin unpacks and installs a plugin but does not enable or activate it. @@ -132,40 +132,40 @@ func (a *App) InstallPlugin(pluginFile io.ReadSeeker, replace bool) (*model.Mani } func (a *App) installPlugin(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - return a.ch.srv.pluginService.installPlugin(pluginFile, signature, installationStrategy) + return a.ch.installPlugin(pluginFile, signature, installationStrategy) } -func (s *PluginService) installPlugin(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - manifest, appErr := s.installPluginLocally(pluginFile, signature, installationStrategy) +func (ch *Channels) installPlugin(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { + manifest, appErr := ch.installPluginLocally(pluginFile, signature, installationStrategy) if appErr != nil { return nil, appErr } if signature != nil { signature.Seek(0, 0) - if _, err := s.fileStore.WriteFile(signature, getSignatureStorePath(manifest.Id)); err != nil { - return nil, model.NewAppError("saveSignature", "app.plugin.store_signature.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + if _, appErr = ch.srv.writeFile(signature, getSignatureStorePath(manifest.Id)); appErr != nil { + return nil, model.NewAppError("saveSignature", "app.plugin.store_signature.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) } } // Store bundle in the file store to allow access from other servers. pluginFile.Seek(0, 0) - if _, appErr := s.fileStore.WriteFile(pluginFile, getBundleStorePath(manifest.Id)); appErr != nil { + if _, appErr := ch.srv.writeFile(pluginFile, getBundleStorePath(manifest.Id)); appErr != nil { return nil, model.NewAppError("uploadPlugin", "app.plugin.store_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) } - s.notifyClusterPluginEvent( + ch.notifyClusterPluginEvent( model.ClusterEventInstallPlugin, model.PluginEventData{ Id: manifest.Id, }, ) - if err := s.notifyPluginEnabled(manifest); err != nil { + if err := ch.notifyPluginEnabled(manifest); err != nil { mlog.Warn("Failed notify plugin enabled", mlog.Err(err)) } - if err := s.notifyPluginStatusesChanged(); err != nil { + if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } @@ -174,10 +174,10 @@ func (s *PluginService) installPlugin(pluginFile, signature io.ReadSeeker, insta // InstallMarketplacePlugin installs a plugin listed in the marketplace server. It will get the plugin bundle // from the prepackaged folder, if available, or remotely if EnableRemoteMarketplace is true. -func (s *PluginService) InstallMarketplacePlugin(request *model.InstallMarketplacePluginRequest) (*model.Manifest, *model.AppError) { +func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePluginRequest) (*model.Manifest, *model.AppError) { var pluginFile, signatureFile io.ReadSeeker - prepackagedPlugin, appErr := s.getPrepackagedPlugin(request.Id, request.Version) + prepackagedPlugin, appErr := ch.getPrepackagedPlugin(request.Id, request.Version) if appErr != nil && appErr.Id != "app.plugin.marketplace_plugins.not_found.app_error" { return nil, appErr } @@ -192,9 +192,9 @@ func (s *PluginService) InstallMarketplacePlugin(request *model.InstallMarketpla signatureFile = bytes.NewReader(prepackagedPlugin.Signature) } - if *s.platform.Config().PluginSettings.EnableRemoteMarketplace { + if *ch.cfgSvc.Config().PluginSettings.EnableRemoteMarketplace { var plugin *model.BaseMarketplacePlugin - plugin, appErr = s.getRemoteMarketplacePlugin(request.Id, request.Version) + plugin, appErr = ch.getRemoteMarketplacePlugin(request.Id, request.Version) if appErr != nil { return nil, appErr } @@ -214,7 +214,7 @@ func (s *PluginService) InstallMarketplacePlugin(request *model.InstallMarketpla } if prepackagedVersion.LT(marketplaceVersion) { // Always true if no prepackaged plugin was found - downloadedPluginBytes, err := s.downloadFromURL(plugin.DownloadURL) + downloadedPluginBytes, err := ch.srv.downloadFromURL(plugin.DownloadURL) if err != nil { return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -234,7 +234,7 @@ func (s *PluginService) InstallMarketplacePlugin(request *model.InstallMarketpla return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.marketplace_plugins.signature_not_found.app_error", nil, "", http.StatusInternalServerError) } - manifest, appErr := s.installPluginWithSignature(pluginFile, signatureFile) + manifest, appErr := ch.installPluginWithSignature(pluginFile, signatureFile) if appErr != nil { return nil, appErr } @@ -253,15 +253,15 @@ const ( installPluginLocallyAlways ) -func (s *PluginService) installPluginLocally(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - pluginsEnvironment := s.GetPluginsEnvironment() +func (ch *Channels) installPluginLocally(pluginFile, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("installPluginLocally", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } // verify signature if signature != nil { - if err := s.verifyPlugin(pluginFile, signature); err != nil { + if err := ch.verifyPlugin(pluginFile, signature); err != nil { return nil, err } } @@ -277,7 +277,7 @@ func (s *PluginService) installPluginLocally(pluginFile, signature io.ReadSeeker return nil, appErr } - manifest, appErr = s.installExtractedPlugin(manifest, pluginDir, installationStrategy) + manifest, appErr = ch.installExtractedPlugin(manifest, pluginDir, installationStrategy) if appErr != nil { return nil, appErr } @@ -312,8 +312,8 @@ func extractPlugin(pluginFile io.ReadSeeker, extractDir string) (*model.Manifest return manifest, extractDir, nil } -func (s *PluginService) installExtractedPlugin(manifest *model.Manifest, fromPluginDir string, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - pluginsEnvironment := s.GetPluginsEnvironment() +func (ch *Channels) installExtractedPlugin(manifest *model.Manifest, fromPluginDir string, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("installExtractedPlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -360,12 +360,12 @@ func (s *PluginService) installExtractedPlugin(manifest *model.Manifest, fromPlu // Otherwise remove the existing installation prior to install below. mlog.Debug("Removing existing installation of plugin before local install", mlog.String("plugin_id", existingManifest.Id), mlog.String("version", existingManifest.Version)) - if err := s.removePluginLocally(existingManifest.Id); err != nil { + if err := ch.removePluginLocally(existingManifest.Id); err != nil { return nil, model.NewAppError("installExtractedPlugin", "app.plugin.install_id_failed_remove.app_error", nil, "", http.StatusBadRequest) } } - pluginPath := filepath.Join(*s.platform.Config().PluginSettings.Directory, manifest.Id) + pluginPath := filepath.Join(*ch.cfgSvc.Config().PluginSettings.Directory, manifest.Id) err = utils.CopyDir(fromPluginDir, pluginPath) if err != nil { return nil, model.NewAppError("installExtractedPlugin", "app.plugin.mvdir.app_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -387,9 +387,9 @@ func (s *PluginService) installExtractedPlugin(manifest *model.Manifest, fromPlu } // Activate the plugin if enabled. - pluginState := s.platform.Config().PluginSettings.PluginStates[manifest.Id] + pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[manifest.Id] if pluginState != nil && pluginState.Enable { - if hasOverride, enabled := s.getPluginStateOverride(manifest.Id); hasOverride && !enabled { + if hasOverride, enabled := ch.getPluginStateOverride(manifest.Id); hasOverride && !enabled { return manifest, nil } @@ -405,49 +405,49 @@ func (s *PluginService) installExtractedPlugin(manifest *model.Manifest, fromPlu return manifest, nil } -func (s *PluginService) RemovePlugin(id string) *model.AppError { +func (ch *Channels) RemovePlugin(id string) *model.AppError { // Disable plugin before removal to make sure this // plugin remains disabled on re-install. - if err := s.disablePlugin(id); err != nil { + if err := ch.disablePlugin(id); err != nil { return err } - if err := s.removePluginLocally(id); err != nil { + if err := ch.removePluginLocally(id); err != nil { return err } // Remove bundle from the file store. storePluginFileName := getBundleStorePath(id) - bundleExist, err := s.fileStore.FileExists(storePluginFileName) + bundleExist, err := ch.srv.fileExists(storePluginFileName) if err != nil { return model.NewAppError("removePlugin", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } if !bundleExist { return nil } - if err = s.fileStore.RemoveFile(storePluginFileName); err != nil { + if err = ch.srv.removeFile(storePluginFileName); err != nil { return model.NewAppError("removePlugin", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - if err2 := s.removeSignature(id); err2 != nil { - mlog.Warn("Can't remove signature", mlog.Err(err2)) + if err = ch.removeSignature(id); err != nil { + mlog.Warn("Can't remove signature", mlog.Err(err)) } - s.notifyClusterPluginEvent( + ch.notifyClusterPluginEvent( model.ClusterEventRemovePlugin, model.PluginEventData{ Id: id, }, ) - if err := s.notifyPluginStatusesChanged(); err != nil { + if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } return nil } -func (s *PluginService) removePluginLocally(id string) *model.AppError { - pluginsEnvironment := s.GetPluginsEnvironment() +func (ch *Channels) removePluginLocally(id string) *model.AppError { + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return model.NewAppError("removePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -473,7 +473,7 @@ func (s *PluginService) removePluginLocally(id string) *model.AppError { pluginsEnvironment.Deactivate(id) pluginsEnvironment.RemovePlugin(id) - s.unregisterPluginCommands(id) + ch.unregisterPluginCommands(id) if err := os.RemoveAll(pluginPath); err != nil { return model.NewAppError("removePlugin", "app.plugin.remove.app_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -482,9 +482,9 @@ func (s *PluginService) removePluginLocally(id string) *model.AppError { return nil } -func (s *PluginService) removeSignature(pluginID string) *model.AppError { +func (ch *Channels) removeSignature(pluginID string) *model.AppError { filePath := getSignatureStorePath(pluginID) - exists, err := s.fileStore.FileExists(filePath) + exists, err := ch.srv.fileExists(filePath) if err != nil { return model.NewAppError("removeSignature", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -492,7 +492,7 @@ func (s *PluginService) removeSignature(pluginID string) *model.AppError { mlog.Debug("no plugin signature to remove", mlog.String("plugin_id", pluginID)) return nil } - if err = s.fileStore.RemoveFile(filePath); err != nil { + if err = ch.srv.removeFile(filePath); err != nil { return model.NewAppError("removeSignature", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } return nil diff --git a/app/plugin_install_test.go b/app/plugin_install_test.go index ca15747865..a3eb6cfb2d 100644 --- a/app/plugin_install_test.go +++ b/app/plugin_install_test.go @@ -73,7 +73,7 @@ func TestInstallPluginLocally(t *testing.T) { th := Setup(t) defer th.TearDown() - actualManifest, appErr := th.App.PluginService().installPluginLocally(&nilReadSeeker{}, nil, installPluginLocallyOnlyIfNew) + actualManifest, appErr := th.App.ch.installPluginLocally(&nilReadSeeker{}, nil, installPluginLocallyOnlyIfNew) require.NotNil(t, appErr) assert.Equal(t, "app.plugin.extract.app_error", appErr.Id, appErr.Error()) require.Nil(t, actualManifest) @@ -87,7 +87,7 @@ func TestInstallPluginLocally(t *testing.T) { {"test", "test file"}, }) - actualManifest, appErr := th.App.PluginService().installPluginLocally(reader, nil, installPluginLocallyOnlyIfNew) + actualManifest, appErr := th.App.ch.installPluginLocally(reader, nil, installPluginLocallyOnlyIfNew) require.NotNil(t, appErr) assert.Equal(t, "app.plugin.manifest.app_error", appErr.Id, appErr.Error()) require.Nil(t, actualManifest) @@ -106,7 +106,7 @@ func TestInstallPluginLocally(t *testing.T) { {"plugin.json", string(manifestJSON)}, }) - actualManifest, appError := th.App.PluginService().installPluginLocally(reader, nil, installationStrategy) + actualManifest, appError := th.App.ch.installPluginLocally(reader, nil, installationStrategy) if actualManifest != nil { require.Equal(t, manifest, actualManifest) } @@ -134,7 +134,7 @@ func TestInstallPluginLocally(t *testing.T) { require.NoError(t, err) for _, bundleInfo := range bundleInfos { - err := th.App.PluginService().removePluginLocally(bundleInfo.Manifest.Id) + err := th.App.ch.removePluginLocally(bundleInfo.Manifest.Id) require.Nilf(t, err, "failed to remove existing plugin %s", bundleInfo.Manifest.Id) } } diff --git a/app/plugin_requests.go b/app/plugin_requests.go index 208adb11f4..1ccc966822 100644 --- a/app/plugin_requests.go +++ b/app/plugin_requests.go @@ -20,16 +20,16 @@ import ( "github.com/mattermost/mattermost-server/v6/utils" ) -func (s *PluginService) ServePluginRequest(w http.ResponseWriter, r *http.Request) { +func (ch *Channels) ServePluginRequest(w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) - if handler, ok := s.channels.routerSvc.getHandler(params["plugin_id"]); ok { - s.servePluginRequest(w, r, func(*plugin.Context, http.ResponseWriter, *http.Request) { + if handler, ok := ch.routerSvc.getHandler(params["plugin_id"]); ok { + ch.servePluginRequest(w, r, func(*plugin.Context, http.ResponseWriter, *http.Request) { handler.ServeHTTP(w, r) }) return } - pluginsEnvironment := s.GetPluginsEnvironment() + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { err := model.NewAppError("ServePluginRequest", "app.plugin.disabled.app_error", nil, "Enable plugins to serve plugin requests", http.StatusNotImplemented) mlog.Error(err.Error()) @@ -49,11 +49,11 @@ func (s *PluginService) ServePluginRequest(w http.ResponseWriter, r *http.Reques return } - s.servePluginRequest(w, r, hooks.ServeHTTP) + ch.servePluginRequest(w, r, hooks.ServeHTTP) } func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) { - pluginsEnvironment := a.ch.srv.pluginService.GetPluginsEnvironment() + pluginsEnvironment := a.ch.GetPluginsEnvironment() if pluginsEnvironment == nil { err := model.NewAppError("ServeInterPluginRequest", "app.plugin.disabled.app_error", nil, "Plugin environment not found.", http.StatusNotImplemented) a.Log().Error(err.Error()) @@ -87,7 +87,7 @@ func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, so // ServePluginPublicRequest serves public plugin files // at the URL http(s)://$SITE_URL/plugins/$PLUGIN_ID/public/{anything} -func (s *PluginService) ServePluginPublicRequest(w http.ResponseWriter, r *http.Request) { +func (ch *Channels) ServePluginPublicRequest(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/") { http.NotFound(w, r) return @@ -97,7 +97,7 @@ func (s *PluginService) ServePluginPublicRequest(w http.ResponseWriter, r *http. vars := mux.Vars(r) pluginID := vars["plugin_id"] - pluginsEnv := s.GetPluginsEnvironment() + pluginsEnv := ch.GetPluginsEnvironment() // Check if someone has nullified the pluginsEnv in the meantime if pluginsEnv == nil { @@ -121,11 +121,11 @@ func (s *PluginService) ServePluginPublicRequest(w http.ResponseWriter, r *http. http.ServeFile(w, r, publicFile) } -func (s *PluginService) servePluginRequest(w http.ResponseWriter, r *http.Request, handler func(*plugin.Context, http.ResponseWriter, *http.Request)) { +func (ch *Channels) servePluginRequest(w http.ResponseWriter, r *http.Request, handler func(*plugin.Context, http.ResponseWriter, *http.Request)) { token := "" context := &plugin.Context{ RequestId: model.NewId(), - IPAddress: utils.GetIPAddress(r, s.platform.Config().ServiceSettings.TrustedProxyIPHeader), + IPAddress: utils.GetIPAddress(r, ch.cfgSvc.Config().ServiceSettings.TrustedProxyIPHeader), AcceptLanguage: r.Header.Get("Accept-Language"), UserAgent: r.UserAgent(), } @@ -148,8 +148,8 @@ func (s *PluginService) servePluginRequest(w http.ResponseWriter, r *http.Reques r.Header.Del("Mattermost-User-Id") if token != "" { - session, err := New(ServerConnector(s.channels)).GetSession(token) - defer s.platform.ReturnSessionToPool(session) + session, err := New(ServerConnector(ch)).GetSession(token) + defer ch.srv.platform.ReturnSessionToPool(session) csrfCheckPassed := false @@ -190,7 +190,7 @@ func (s *PluginService) servePluginRequest(w http.ResponseWriter, r *http.Reques mlog.String("user_id", userID), } - if *s.platform.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement { + if *ch.cfgSvc.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement { mlog.Warn(csrfErrorMessage, fields...) } else { mlog.Debug(csrfErrorMessage, fields...) @@ -219,7 +219,7 @@ func (s *PluginService) servePluginRequest(w http.ResponseWriter, r *http.Reques params := mux.Vars(r) - subpath, _ := utils.GetSubpathFromConfig(s.platform.Config()) + subpath, _ := utils.GetSubpathFromConfig(ch.cfgSvc.Config()) newQuery := r.URL.Query() newQuery.Del("access_token") diff --git a/app/plugin_requests_test.go b/app/plugin_requests_test.go index e457d8e5f1..c41c70be6d 100644 --- a/app/plugin_requests_test.go +++ b/app/plugin_requests_test.go @@ -24,7 +24,7 @@ func TestServePluginPublicRequest(t *testing.T) { require.NoError(t, err) rr := httptest.NewRecorder() - handler := http.HandlerFunc(th.App.PluginService().ServePluginPublicRequest) + handler := http.HandlerFunc(th.App.ch.ServePluginPublicRequest) handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) diff --git a/app/plugin_shutdown_test.go b/app/plugin_shutdown_test.go index 1c77fc3814..293d882f1f 100644 --- a/app/plugin_shutdown_test.go +++ b/app/plugin_shutdown_test.go @@ -63,7 +63,7 @@ func TestPluginShutdownTest(t *testing.T) { done := make(chan bool) go func() { defer close(done) - th.App.PluginService().ShutDownPlugins() + th.App.ch.ShutDownPlugins() }() select { diff --git a/app/plugin_signature.go b/app/plugin_signature.go index 928a9687b8..0903aa08fc 100644 --- a/app/plugin_signature.go +++ b/app/plugin_signature.go @@ -73,16 +73,16 @@ func (a *App) DeletePublicKey(name string) *model.AppError { // VerifyPlugin checks that the given signature corresponds to the given plugin and matches a trusted certificate. func (a *App) VerifyPlugin(plugin, signature io.ReadSeeker) *model.AppError { - return a.ch.srv.pluginService.verifyPlugin(plugin, signature) + return a.ch.verifyPlugin(plugin, signature) } -func (s *PluginService) verifyPlugin(plugin, signature io.ReadSeeker) *model.AppError { +func (ch *Channels) verifyPlugin(plugin, signature io.ReadSeeker) *model.AppError { if err := verifySignature(bytes.NewReader(mattermostPluginPublicKey), plugin, signature); err == nil { return nil } - publicKeys := s.platform.Config().PluginSettings.SignaturePublicKeyFiles + publicKeys := ch.cfgSvc.Config().PluginSettings.SignaturePublicKeyFiles for _, pk := range publicKeys { - pkBytes, appErr := s.platform.GetConfigFile(pk) + pkBytes, appErr := ch.srv.getPublicKey(pk) if appErr != nil { mlog.Warn("Unable to get public key for ", mlog.String("filename", pk)) continue diff --git a/app/plugin_statuses.go b/app/plugin_statuses.go index 2b27d7520c..399d58e5b2 100644 --- a/app/plugin_statuses.go +++ b/app/plugin_statuses.go @@ -10,8 +10,8 @@ import ( ) // GetPluginStatus returns the status for a plugin installed on this server. -func (s *PluginService) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) { - pluginsEnvironment := s.GetPluginsEnvironment() +func (ch *Channels) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) { + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("GetPluginStatus", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -24,8 +24,8 @@ func (s *PluginService) GetPluginStatus(id string) (*model.PluginStatus, *model. for _, status := range pluginStatuses { if status.PluginId == id { // Add our cluster ID - if s.platform.Cluster() != nil { - status.ClusterId = s.platform.Cluster().GetClusterId() + if ch.srv.platform.Cluster() != nil { + status.ClusterId = ch.srv.platform.Cluster().GetClusterId() } return status, nil @@ -37,12 +37,12 @@ func (s *PluginService) GetPluginStatus(id string) (*model.PluginStatus, *model. // GetPluginStatus returns the status for a plugin installed on this server. func (a *App) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) { - return a.ch.srv.pluginService.GetPluginStatus(id) + return a.ch.GetPluginStatus(id) } // GetPluginStatuses returns the status for plugins installed on this server. -func (s *PluginService) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { - pluginsEnvironment := s.GetPluginsEnvironment() +func (ch *Channels) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { + pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("GetPluginStatuses", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -54,8 +54,8 @@ func (s *PluginService) GetPluginStatuses() (model.PluginStatuses, *model.AppErr // Add our cluster ID for _, status := range pluginStatuses { - if s.platform.Cluster() != nil { - status.ClusterId = s.platform.Cluster().GetClusterId() + if ch.srv.platform.Cluster() != nil { + status.ClusterId = ch.srv.platform.Cluster().GetClusterId() } else { status.ClusterId = "" } @@ -66,22 +66,22 @@ func (s *PluginService) GetPluginStatuses() (model.PluginStatuses, *model.AppErr // GetPluginStatuses returns the status for plugins installed on this server. func (a *App) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { - return a.ch.srv.pluginService.GetPluginStatuses() + return a.ch.GetPluginStatuses() } // GetClusterPluginStatuses returns the status for plugins installed anywhere in the cluster. func (a *App) GetClusterPluginStatuses() (model.PluginStatuses, *model.AppError) { - return a.ch.srv.pluginService.getClusterPluginStatuses() + return a.ch.getClusterPluginStatuses() } -func (s *PluginService) getClusterPluginStatuses() (model.PluginStatuses, *model.AppError) { - pluginStatuses, err := s.GetPluginStatuses() +func (ch *Channels) getClusterPluginStatuses() (model.PluginStatuses, *model.AppError) { + pluginStatuses, err := ch.GetPluginStatuses() if err != nil { return nil, err } - if s.platform.Cluster() != nil && *s.platform.Config().ClusterSettings.Enable { - clusterPluginStatuses, err := s.platform.Cluster().GetPluginStatuses() + if ch.srv.platform.Cluster() != nil && *ch.cfgSvc.Config().ClusterSettings.Enable { + clusterPluginStatuses, err := ch.srv.platform.Cluster().GetPluginStatuses() if err != nil { return nil, model.NewAppError("GetClusterPluginStatuses", "app.plugin.get_cluster_plugin_statuses.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -92,8 +92,8 @@ func (s *PluginService) getClusterPluginStatuses() (model.PluginStatuses, *model return pluginStatuses, nil } -func (s *PluginService) notifyPluginStatusesChanged() error { - pluginStatuses, err := s.getClusterPluginStatuses() +func (ch *Channels) notifyPluginStatusesChanged() error { + pluginStatuses, err := ch.getClusterPluginStatuses() if err != nil { return err } @@ -102,7 +102,7 @@ func (s *PluginService) notifyPluginStatusesChanged() error { message := model.NewWebSocketEvent(model.WebsocketEventPluginStatusesChanged, "", "", "", nil, "") message.Add("plugin_statuses", pluginStatuses) message.GetBroadcast().ContainsSensitiveData = true - s.platform.Publish(message) + ch.srv.platform.Publish(message) return nil } diff --git a/app/plugin_test.go b/app/plugin_test.go index 0d3ec65431..57802c67ac 100644 --- a/app/plugin_test.go +++ b/app/plugin_test.go @@ -346,7 +346,7 @@ func TestServePluginRequest(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/plugins/foo/bar", nil) - th.App.PluginService().ServePluginRequest(w, r) + th.App.ch.ServePluginRequest(w, r) assert.Equal(t, http.StatusNotImplemented, w.Result().StatusCode) } @@ -390,7 +390,7 @@ func TestPrivateServePluginRequest(t *testing.T) { request = mux.SetURLVars(request, map[string]string{"plugin_id": "id"}) - th.App.PluginService().servePluginRequest(recorder, request, handler) + th.App.ch.servePluginRequest(recorder, request, handler) }) } @@ -413,7 +413,7 @@ func TestHandlePluginRequest(t *testing.T) { var assertions func(*http.Request) router := mux.NewRouter() router.HandleFunc("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}/{anything:.*}", func(_ http.ResponseWriter, r *http.Request) { - th.App.PluginService().servePluginRequest(nil, r, func(_ *plugin.Context, _ http.ResponseWriter, r *http.Request) { + th.App.ch.servePluginRequest(nil, r, func(_ *plugin.Context, _ http.ResponseWriter, r *http.Request) { assertions(r) }) }) @@ -625,7 +625,7 @@ func TestPluginSync(t *testing.T) { appErr = th.App.DeletePublicKey("pub_key") checkNoError(t, appErr) - appErr = th.App.PluginService().RemovePlugin("testplugin") + appErr = th.App.ch.RemovePlugin("testplugin") checkNoError(t, appErr) }) }) @@ -642,7 +642,7 @@ func TestChannelsPluginsInit(t *testing.T) { path, _ := fileutils.FindDir("tests") require.NotPanics(t, func() { - th.Server.pluginService.initPlugins(ctx, path, path) + th.Server.Channels().initPlugins(ctx, path, path) }) } @@ -763,7 +763,7 @@ func TestPluginPanicLogs(t *testing.T) { th.TestLogger.Flush() // We shutdown plugins first so that the read on the log buffer is race-free. - th.App.PluginService().ShutDownPlugins() + th.App.ch.ShutDownPlugins() tearDown() testlib.AssertLog(t, th.LogBuffer, mlog.LvlDebug.Name, "panic: some text from panic") @@ -831,7 +831,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { require.NoError(t, err) require.NotNil(t, pluginBytes) - manifest, appErr := th.App.PluginService().installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) + manifest, appErr := th.App.ch.installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) require.Nil(t, appErr) require.Equal(t, "testplugin", manifest.Id) @@ -848,7 +848,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { *cfg.PluginSettings.EnableRemoteMarketplace = false }) - plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 1) require.Equal(t, plugins[0].Manifest.Id, "testplugin") require.Empty(t, plugins[0].Signature, 0) @@ -858,7 +858,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { require.Len(t, pluginStatus, 1) require.Equal(t, pluginStatus[0].PluginId, "testplugin") - appErr = th.App.PluginService().RemovePlugin("testplugin") + appErr = th.App.ch.RemovePlugin("testplugin") checkNoError(t, appErr) pluginStatus, err = env.Statuses() @@ -875,7 +875,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { env := th.App.GetPluginsEnvironment() - plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 1) require.Equal(t, plugins[0].Manifest.Id, "testplugin") require.Empty(t, plugins[0].Signature, 0) @@ -908,7 +908,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) require.NoError(t, err) - plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 2) require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) require.NotEmpty(t, plugins[0].Signature) @@ -939,7 +939,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { require.NoError(t, err) require.NotNil(t, pluginBytes) - manifest, appErr := th.App.PluginService().installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) + manifest, appErr := th.App.ch.installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) require.Nil(t, appErr) require.Equal(t, "testplugin", manifest.Id) @@ -957,7 +957,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) require.NoError(t, err) - plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 2) require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) require.NotEmpty(t, plugins[0].Signature) @@ -969,7 +969,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { require.Len(t, pluginStatus, 1) require.Equal(t, pluginStatus[0].PluginId, "testplugin") - appErr = th.App.PluginService().RemovePlugin("testplugin") + appErr = th.App.ch.RemovePlugin("testplugin") checkNoError(t, appErr) pluginStatus, err = env.Statuses() @@ -994,7 +994,7 @@ func TestProcessPrepackagedPlugins(t *testing.T) { err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) require.NoError(t, err) - plugins := th.App.PluginService().processPrepackagedPlugins(prepackagedPluginsDir) + plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) require.Len(t, plugins, 2) require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) require.NotEmpty(t, plugins[0].Signature) @@ -1071,14 +1071,14 @@ func TestGetPluginStateOverride(t *testing.T) { defer th.TearDown() t.Run("no override", func(t *testing.T) { - overrides, value := th.App.PluginService().getPluginStateOverride("focalboard") + overrides, value := th.App.ch.getPluginStateOverride("focalboard") require.False(t, overrides) require.False(t, value) }) t.Run("calls override", func(t *testing.T) { t.Run("on-prem", func(t *testing.T) { - overrides, value := th.App.PluginService().getPluginStateOverride("com.mattermost.calls") + overrides, value := th.App.ch.getPluginStateOverride("com.mattermost.calls") require.False(t, overrides) require.False(t, value) }) @@ -1086,7 +1086,7 @@ func TestGetPluginStateOverride(t *testing.T) { t.Run("Cloud, without enabled flag", func(t *testing.T) { os.Setenv("MM_CLOUD_INSTALLATION_ID", "test") defer os.Unsetenv("MM_CLOUD_INSTALLATION_ID") - overrides, value := th.App.PluginService().getPluginStateOverride("com.mattermost.calls") + overrides, value := th.App.ch.getPluginStateOverride("com.mattermost.calls") require.False(t, overrides) require.False(t, value) }) @@ -1100,7 +1100,7 @@ func TestGetPluginStateOverride(t *testing.T) { th2 := Setup(t) defer th2.TearDown() - overrides, value := th2.App.PluginService().getPluginStateOverride("com.mattermost.calls") + overrides, value := th2.App.ch.getPluginStateOverride("com.mattermost.calls") require.False(t, overrides) require.False(t, value) }) @@ -1114,7 +1114,7 @@ func TestGetPluginStateOverride(t *testing.T) { th2 := Setup(t) defer th2.TearDown() - overrides, value := th2.App.PluginService().getPluginStateOverride("com.mattermost.calls") + overrides, value := th2.App.ch.getPluginStateOverride("com.mattermost.calls") require.True(t, overrides) require.False(t, value) }) @@ -1126,7 +1126,7 @@ func TestGetPluginStateOverride(t *testing.T) { th2 := Setup(t) defer th2.TearDown() - overrides, value := th2.App.PluginService().getPluginStateOverride("com.mattermost.calls") + overrides, value := th2.App.ch.getPluginStateOverride("com.mattermost.calls") require.True(t, overrides) require.False(t, value) }) @@ -1134,7 +1134,7 @@ func TestGetPluginStateOverride(t *testing.T) { t.Run("apps override", func(t *testing.T) { t.Run("without enabled flag", func(t *testing.T) { - overrides, value := th.App.PluginService().getPluginStateOverride("com.mattermost.apps") + overrides, value := th.App.ch.getPluginStateOverride("com.mattermost.apps") require.False(t, overrides) require.False(t, value) }) @@ -1146,7 +1146,7 @@ func TestGetPluginStateOverride(t *testing.T) { th2 := Setup(t) defer th2.TearDown() - overrides, value := th2.App.PluginService().getPluginStateOverride("com.mattermost.apps") + overrides, value := th2.App.ch.getPluginStateOverride("com.mattermost.apps") require.True(t, overrides) require.False(t, value) }) diff --git a/app/server.go b/app/server.go index c15689e7b7..8699a013c6 100644 --- a/app/server.go +++ b/app/server.go @@ -143,7 +143,6 @@ type Server struct { telemetryService *telemetry.TelemetryService userService *users.UserService teamService *teams.TeamService - pluginService *PluginService serviceMux sync.RWMutex remoteClusterService remotecluster.RemoteClusterServiceIFace @@ -738,10 +737,6 @@ func (s *Server) Shutdown() { } } - // Stop the plugin service, we need to stop plugin service before stopping the - // product as products are being consumed by this service. - s.pluginService.ShutDownPlugins() - // Stop products. // This needs to happen last because products are dependent // on parent services. @@ -848,18 +843,11 @@ func stripPort(hostport string) string { func (s *Server) Start() error { // Start products. // This needs to happen before because products are dependent on the HTTP server. + // make sure channels starts first if err := s.products["channels"].Start(); err != nil { return errors.Wrap(err, "Unable to start channels") } - - // This should actually be started after products, but we have a product hooks - // dependency for now, once that get sorted out, this should be moved to the appropriate - // order. - if err := s.InitializePluginService(); err != nil { - return errors.Wrap(err, "Unable to start plugin service") - } - for name, product := range s.products { if name == "channels" { continue diff --git a/app/web_conn.go b/app/web_conn.go index ba78d179fe..0edc3504ae 100644 --- a/app/web_conn.go +++ b/app/web_conn.go @@ -16,5 +16,5 @@ func (a *App) PopulateWebConnConfig(s *model.Session, cfg *platform.WebConnConfi // NewWebConn returns a new WebConn instance. func (a *App) NewWebConn(cfg *platform.WebConnConfig) *platform.WebConn { - return a.Srv().Platform().NewWebConn(cfg, a, a.Srv().pluginService.GetPluginsEnvironment) + return a.Srv().Platform().NewWebConn(cfg, a, a.ch.GetPluginsEnvironment) } diff --git a/cmd/mattermost/commands/init.go b/cmd/mattermost/commands/init.go index 9ff238029e..e93d9640fe 100644 --- a/cmd/mattermost/commands/init.go +++ b/cmd/mattermost/commands/init.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/mattermost/mattermost-server/v6/app" + "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/config" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/i18n" @@ -20,7 +21,7 @@ func initDBCommandContextCobra(command *cobra.Command, readOnlyConfigStore bool) panic(err) } - a.Srv().InitializePluginService() + a.InitPlugins(request.EmptyContext(a.Log()), *a.Config().PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory) a.DoAppMigrations() return a, nil diff --git a/web/web_test.go b/web/web_test.go index 84ffd798dd..139753675d 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -280,7 +280,7 @@ func TestPublicFilesRequest(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server.Platform()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) pluginID := "com.mattermost.sample" @@ -327,7 +327,7 @@ func TestPublicFilesRequest(t *testing.T) { require.NotNil(t, manifest) require.True(t, activated) - th.App.PluginService().SetPluginsEnvironment(env) + th.App.Channels().SetPluginsEnvironment(env) req, _ := http.NewRequest("GET", "/plugins/com.mattermost.sample/public/hello.html", nil) res := httptest.NewRecorder() From f390d88d0cda558993e02fdb7b0b7f8a048473b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Garc=C3=ADa=20Montoro?= Date: Fri, 25 Nov 2022 12:33:51 +0100 Subject: [PATCH 30/80] Skip flaky test GetUptoNSizeFileTime (#21731) --- store/storetest/file_info_store.go | 1 + 1 file changed, 1 insertion(+) diff --git a/store/storetest/file_info_store.go b/store/storetest/file_info_store.go index 4df67ae24a..7155f2f538 100644 --- a/store/storetest/file_info_store.go +++ b/store/storetest/file_info_store.go @@ -810,6 +810,7 @@ func testFileInfoGetStorageUsage(t *testing.T, ss store.Store) { } func testGetUptoNSizeFileTime(t *testing.T, ss store.Store) { + t.Skip("MM-48627") _, err := ss.FileInfo().GetUptoNSizeFileTime(0) assert.Error(t, err) _, err = ss.FileInfo().GetUptoNSizeFileTime(-1) From e031b16b16d133ca580b427ec64facfcc98c073a Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Fri, 25 Nov 2022 18:02:30 +0100 Subject: [PATCH 31/80] Adds support for clustered websocket messages for products (#21737) * Adds support for clustered websocket messages for products * Update app/cluster_handlers.go Co-authored-by: Ibrahim Serdar Acikgoz Co-authored-by: Ibrahim Serdar Acikgoz --- app/cluster_handlers.go | 4 ++++ plugin/environment.go | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/app/cluster_handlers.go b/app/cluster_handlers.go index beb8f71c73..182e6ede01 100644 --- a/app/cluster_handlers.go +++ b/app/cluster_handlers.go @@ -37,6 +37,10 @@ func (s *Server) clusterPluginEventHandler(msg *model.ClusterMessage) { return } pluginID := msg.Props["PluginID"] + // if the plugin key is empty, the message might be coming from a product. + if pluginID == "" { + pluginID = msg.Props["ProductID"] + } eventID := msg.Props["EventID"] if pluginID == "" || eventID == "" { mlog.Warn("Invalid ClusterMessage.Props values for plugin event", diff --git a/plugin/environment.go b/plugin/environment.go index fa737f5ecf..0b73b9c435 100644 --- a/plugin/environment.go +++ b/plugin/environment.go @@ -499,6 +499,12 @@ func (env *Environment) HooksForPlugin(id string) (Hooks, error) { } } + if p, ok := env.registeredProducts.Load(id); ok { + rp := p.(*registeredProduct) + + return rp.adapter, nil + } + return nil, fmt.Errorf("plugin not found: %v", id) } From ccc9e9650c8ab41a0c22beec8d2c99fb2f0a70eb Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Fri, 25 Nov 2022 14:45:25 -0500 Subject: [PATCH 32/80] MM-48070 - Remove the unused CallsMobile feature flag (#21721) Co-authored-by: Mattermod --- model/feature_flags.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/model/feature_flags.go b/model/feature_flags.go index 9331177c57..b5e9259ab8 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -33,9 +33,6 @@ type FeatureFlags struct { PermalinkPreviews bool - // Enable Calls plugin support in the mobile app - CallsMobile bool - // CallsEnabled controls whether or not the Calls plugin should be enabled CallsEnabled bool @@ -92,7 +89,6 @@ func (f *FeatureFlags) SetDefaults() { f.PluginApps = "" f.PluginFocalboard = "" f.PermalinkPreviews = true - f.CallsMobile = false f.BoardsFeatureFlags = "" f.CustomGroups = true f.BoardsDataRetention = false From b4d0c419d767d66d1f6610364d0afef08daa2268 Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Mon, 28 Nov 2022 10:34:51 +0100 Subject: [PATCH 33/80] Update haserver docker-compose to include boards as a product (#21738) --- docker-compose.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index 61fb04b5a5..d644331ae4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -108,6 +108,7 @@ services: - "RUN_SERVER_IN_BACKGROUND=false" - "MM_CLUSTERSETTINGS_ENABLE=true" - "MM_CLUSTERSETTINGS_CLUSTERNAME=mm_dev_cluster" + - "MM_FEATUREFLAGS_BoardsProduct=true" networks: - mm-test depends_on: @@ -116,6 +117,7 @@ services: - './:/home/mattermost-server' - './../mattermost-webapp:/home/mattermost-webapp' - './../enterprise:/home/enterprise' + - './../focalboard:/home/focalboard' restart: on-failure healthcheck: test: ["CMD", "curl", "-f", "http://leader:8065/api/v4/system/ping"] @@ -144,6 +146,7 @@ services: - "RUN_SERVER_IN_BACKGROUND=false" - "MM_CLUSTERSETTINGS_ENABLE=true" - "MM_CLUSTERSETTINGS_CLUSTERNAME=mm_dev_cluster" + - "MM_FEATUREFLAGS_BoardsProduct=true" networks: - mm-test depends_on: @@ -152,6 +155,7 @@ services: - './:/home/mattermost-server' - './../mattermost-webapp:/home/mattermost-webapp' - './../enterprise:/home/enterprise' + - './../focalboard:/home/focalboard' healthcheck: test: ["CMD", "curl", "-f", "http://follower:8065/api/v4/system/ping"] interval: 5s @@ -180,6 +184,7 @@ services: - "RUN_SERVER_IN_BACKGROUND=false" - "MM_CLUSTERSETTINGS_ENABLE=true" - "MM_CLUSTERSETTINGS_CLUSTERNAME=mm_dev_cluster" + - "MM_FEATUREFLAGS_BoardsProduct=true" networks: - mm-test depends_on: @@ -188,6 +193,7 @@ services: - './:/home/mattermost-server' - './../mattermost-webapp:/home/mattermost-webapp' - './../enterprise:/home/enterprise' + - './../focalboard:/home/focalboard' healthcheck: test: ["CMD", "curl", "-f", "http://follower2:8065/api/v4/system/ping"] interval: 5s From 860989e2ca9c409ceff18f4db53f965d7177928c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossi=20V=C3=A4=C3=A4n=C3=A4nen?= <4111915+oh6hay@users.noreply.github.com> Date: Mon, 28 Nov 2022 18:12:41 +0200 Subject: [PATCH 34/80] [MM-47982] [MM-47983] Honor the ShowEmailAddress setting (#21707) * Honor the ShowEmailAddress setting for the `/api/v4/teams/[teamid]/regenerate_invite_id` and `/api/v4/users/me/teams` API paths. Fixes MM-47982 and MM-47983 * Fix linter error in test code * MM-48186: Add a new API endpoint to add a user to their default GroupChannels and GroupTeams. (#21591) * MM-48186: Add a new API endpoint to add a user to their default GroupChannels and GroupTeams. * MM-48186: Removed unrelated lint fixes. * MM-48186: Removed variable from previous iteration. * MM-48186: Adds translation. * MM-48186: Not upgrading golang.org/x/text in this pr. * MM-48186: Validate user ID and auth service. * MM-48186: Use user id from struct. * MM-48186: Added basic client test. * MM-48186: Adds empty translation. * MM-48186: Added translations. Co-authored-by: Mattermod * [MM-47384] Make OpenID Connect free for all (#21556) * wip: make OpenID Connect free-for-all * Deprecation note: GoogleOAuth, Office365OAuth * Improve deprecation comments Co-authored-by: Martin Kraft * Lint fix * Add model/oauthproviders, move google, openid, office365 from enterprise * Vet fixes * Remove redundant log Co-authored-by: Martin Kraft Co-authored-by: Mattermod Co-authored-by: Martin Kraft Co-authored-by: Mattermod Co-authored-by: Shivashis Padhi --- api4/team.go | 10 ++++++++++ api4/team_test.go | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/api4/team.go b/api4/team.go index a4d687f1ff..08b520ebe2 100644 --- a/api4/team.go +++ b/api4/team.go @@ -407,6 +407,10 @@ func regenerateTeamInviteId(c *Context, w http.ResponseWriter, r *http.Request) c.App.SanitizeTeam(*c.AppContext.Session(), patchedTeam) + if !*c.App.Config().PrivacySettings.ShowEmailAddress && !c.IsSystemAdmin() { + patchedTeam.Email = "" + } + auditRec.Success() auditRec.AddEventResultState(patchedTeam) auditRec.AddEventObjectType("team") @@ -493,6 +497,12 @@ func getTeamsForUser(c *Context, w http.ResponseWriter, r *http.Request) { c.App.SanitizeTeams(*c.AppContext.Session(), teams) + if !*c.App.Config().PrivacySettings.ShowEmailAddress && !c.IsSystemAdmin() { + for _, team := range teams { + team.Email = "" + } + } + js, err := json.Marshal(teams) if err != nil { c.Err = model.NewAppError("getTeamsForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) diff --git a/api4/team_test.go b/api4/team_test.go index 04cca44f71..c1eef6e6ac 100644 --- a/api4/team_test.go +++ b/api4/team_test.go @@ -871,11 +871,21 @@ func TestRegenerateTeamInviteId(t *testing.T) { assert.NotEqual(t, team.InviteId, "") assert.NotEqual(t, team.InviteId, "inviteid0") + *th.App.Config().PrivacySettings.ShowEmailAddress = true rteam, _, err := client.RegenerateTeamInviteId(team.Id) require.NoError(t, err) assert.NotEqual(t, team.InviteId, rteam.InviteId) assert.NotEqual(t, team.InviteId, "") + assert.NotEqual(t, rteam.Email, "") + + *th.App.Config().PrivacySettings.ShowEmailAddress = false + rteam, _, err = client.RegenerateTeamInviteId(team.Id) + require.NoError(t, err) + + assert.NotEqual(t, team.InviteId, rteam.InviteId) + assert.NotEqual(t, team.InviteId, "") + assert.Equal(t, rteam.Email, "") } func TestSoftDeleteTeam(t *testing.T) { @@ -1819,6 +1829,17 @@ func TestGetTeamsForUserSanitization(t *testing.T) { require.NotEmpty(t, rteam.Email, "should not have sanitized email") require.NotEmpty(t, rteam.InviteId, "should have not sanitized inviteid") } + *th.App.Config().PrivacySettings.ShowEmailAddress = false + rteams, _, err2 := th.Client.GetTeamsForUser(th.BasicUser.Id, "") + require.NoError(t, err2) + for _, rteam := range rteams { + if rteam.Id != team.Id && rteam.Id != team2.Id { + continue + } + + require.Empty(t, rteam.Email, "should have sanitized email") + require.NotEmpty(t, rteam.InviteId, "should have not sanitized inviteid") + } }) t.Run("system admin", func(t *testing.T) { From 98a14c8c55117ed379e80d8b0d355bbd4f8f357c Mon Sep 17 00:00:00 2001 From: Martin Kraft Date: Mon, 28 Nov 2022 11:16:47 -0500 Subject: [PATCH 35/80] gofmt fixes. (#21742) --- app/teams/teams.go | 1 - app/teams/utils.go | 4 +++- services/cache/lru_striped.go | 6 +++--- store/searchlayer/user_layer.go | 1 + store/storetest/compliance_store.go | 8 ++++---- utils/merge.go | 17 +++++++++-------- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/teams/teams.go b/app/teams/teams.go index af0ef2bccf..9b6db445b3 100644 --- a/app/teams/teams.go +++ b/app/teams/teams.go @@ -43,7 +43,6 @@ func (ts *TeamService) GetTeams(teamIDs []string) ([]*model.Team, error) { } // CreateDefaultChannels creates channels in the given team for each channel returned by (*App).DefaultChannelNames. -// func (ts *TeamService) createDefaultChannels(teamID string) ([]*model.Channel, error) { displayNames := map[string]string{ "town-square": i18n.T("api.channel.create_default_channels.town_square"), diff --git a/app/teams/utils.go b/app/teams/utils.go index 2fc8e199d6..14bef9b549 100644 --- a/app/teams/utils.go +++ b/app/teams/utils.go @@ -10,11 +10,13 @@ import ( ) // By default the list will be (not necessarily in this order): +// // ['town-square', 'off-topic'] +// // However, if TeamSettings.ExperimentalDefaultChannels contains a list of channels then that list will replace // 'off-topic' and be included in the return results in addition to 'town-square'. For example: -// ['town-square', 'game-of-thrones', 'wow'] // +// ['town-square', 'game-of-thrones', 'wow'] func (ts *TeamService) DefaultChannelNames() []string { names := []string{"town-square"} diff --git a/services/cache/lru_striped.go b/services/cache/lru_striped.go index 3e11933bfa..5ce231b0c4 100644 --- a/services/cache/lru_striped.go +++ b/services/cache/lru_striped.go @@ -29,9 +29,9 @@ import ( // cache where a simple LRU wouldn't have. Example: // // Two buckets B1 and B2, of max size 2 each, meaning, theoretically, a max size of 4: -// * Say you have a set of 3 keys, they could fill an entire LRU cache. -// * But if all those keys are assigned to a single bucket B1, the first key will be evicted from B1 -// * B2 will remain empty, even though there was enough memory allocated +// - Say you have a set of 3 keys, they could fill an entire LRU cache. +// - But if all those keys are assigned to a single bucket B1, the first key will be evicted from B1 +// - B2 will remain empty, even though there was enough memory allocated // // With 4 buckets and random UUIDs as keys, the amount of false evictions is around 5%. // diff --git a/store/searchlayer/user_layer.go b/store/searchlayer/user_layer.go index 268b18cacc..b754fdf940 100644 --- a/store/searchlayer/user_layer.go +++ b/store/searchlayer/user_layer.go @@ -149,6 +149,7 @@ func (s *SearchUserStore) autocompleteUsersInChannelByEngine(engine searchengine } // getListOfAllowedChannels return the list of allowed channels to search user based on the +// // next scenarios: // - If there isn't view restrictions (team or channel) and no team id to filter them, then all // channels are allowed (nil return) diff --git a/store/storetest/compliance_store.go b/store/storetest/compliance_store.go index b052332b1d..f39b1c43ef 100644 --- a/store/storetest/compliance_store.go +++ b/store/storetest/compliance_store.go @@ -782,7 +782,7 @@ func testMessageExportGroupMessageChannel(t *testing.T, ss store.Store) { assert.Equal(t, user1.Username, *messageExportMap[post.Id].Username) } -//post,edit,export +// post,edit,export func testEditExportMessage(t *testing.T, ss store.Store) { defer cleanupStoreState(t, ss) // get the starting number of message export entries @@ -874,7 +874,7 @@ func testEditExportMessage(t *testing.T, ss store.Store) { } } -//post, export, edit, export +// post, export, edit, export func testEditAfterExportMessage(t *testing.T, ss store.Store) { defer cleanupStoreState(t, ss) // get the starting number of message export entries @@ -985,7 +985,7 @@ func testEditAfterExportMessage(t *testing.T, ss store.Store) { } } -//post, delete, export +// post, delete, export func testDeleteExportMessage(t *testing.T, ss store.Store) { defer cleanupStoreState(t, ss) // get the starting number of message export entries @@ -1070,7 +1070,7 @@ func testDeleteExportMessage(t *testing.T, ss store.Store) { assert.Equal(t, user1.Username, *v.Username) } -//post,export,delete,export +// post,export,delete,export func testDeleteAfterExportMessage(t *testing.T, ss store.Store) { defer cleanupStoreState(t, ss) // get the starting number of message export entries diff --git a/utils/merge.go b/utils/merge.go index 073a3380b2..cdeaa6e908 100644 --- a/utils/merge.go +++ b/utils/merge.go @@ -26,15 +26,16 @@ type MergeConfig struct { // - maps and slices are treated as pointers, and merged as a single value // // Note that callers need to cast the returned interface back into the original type: -// func mergeTestStruct(base, patch *testStruct) (*testStruct, error) { -// ret, err := merge(base, patch) -// if err != nil { -// return nil, err -// } // -// retTS := ret.(testStruct) -// return &retTS, nil -// } +// func mergeTestStruct(base, patch *testStruct) (*testStruct, error) { +// ret, err := merge(base, patch) +// if err != nil { +// return nil, err +// } +// +// retTS := ret.(testStruct) +// return &retTS, nil +// } func Merge(base any, patch any, mergeConfig *MergeConfig) (any, error) { if reflect.TypeOf(base) != reflect.TypeOf(patch) { return nil, fmt.Errorf( From 3e7a8d84266a0acea281341444d1a59264f40899 Mon Sep 17 00:00:00 2001 From: Martin Kraft Date: Mon, 28 Nov 2022 11:18:17 -0500 Subject: [PATCH 36/80] MM-48181: Invalidate cache to reflect permissions changes from a team scheme. (#21735) * MM-48181: Bust the allChannelMembersForUserCache when assigning a team scheme. * MM-48181: Tests cache fix. --- app/team.go | 2 ++ app/team_test.go | 39 ++++++++++++++++++++++ store/opentracinglayer/opentracinglayer.go | 13 ++++++++ store/retrylayer/retrylayer.go | 6 ++++ store/sqlstore/channel_store.go | 4 +++ store/store.go | 1 + store/storetest/mocks/ChannelStore.go | 5 +++ store/timerlayer/timerlayer.go | 15 +++++++++ 8 files changed, 85 insertions(+) diff --git a/app/team.go b/app/team.go index 1de3c92350..9c1d99ee6b 100644 --- a/app/team.go +++ b/app/team.go @@ -308,6 +308,8 @@ func (a *App) UpdateTeamScheme(team *model.Team) (*model.Team, *model.AppError) return nil, model.NewAppError("UpdateTeamScheme", "app.team.clear_cache.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } + a.Srv().Store().Channel().ClearMembersForUserCache() + if appErr := a.sendTeamEvent(oldTeam, model.WebsocketEventUpdateTeamScheme); appErr != nil { return nil, appErr } diff --git a/app/team_test.go b/app/team_test.go index 604cef33d6..23fd15117b 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -1109,6 +1109,45 @@ func TestAppUpdateTeamScheme(t *testing.T) { updatedTeam, err := th.App.UpdateTeamScheme(th.BasicTeam) require.Nil(t, err) require.Equal(t, mockID, updatedTeam.SchemeId, "Wrong Team SchemeId") + + // Test that a newly applied team scheme applies the new permissions to a team member + th.App.SetPhase2PermissionsMigrationStatus(true) + + team2Scheme := th.SetupTeamScheme() + channelUser, err := th.App.GetRoleByName(context.Background(), team2Scheme.DefaultChannelUserRole) + require.Nil(t, err) + channelUser.Permissions = []string{} + _, err = th.App.UpdateRole(channelUser) // Remove all permissions from the team user role of the scheme + require.Nil(t, err) + + channelAdmin, err := th.App.GetRoleByName(context.Background(), team2Scheme.DefaultChannelAdminRole) + require.Nil(t, err) + channelAdmin.Permissions = []string{} + _, err = th.App.UpdateRole(channelAdmin) // Remove all permissions from the team admin role of the scheme + require.Nil(t, err) + + team2 := th.CreateTeam() + th.App.AddUserToTeam(th.Context, team2.Id, th.BasicUser.Id, "") + channel := th.CreateChannel(th.Context, team2) + th.App.AddUserToChannel(th.Context, th.BasicUser, channel, true) + session := model.Session{ + Roles: model.SystemUserRoleId, + UserId: th.BasicUser.Id, + TeamMembers: []*model.TeamMember{ + { + UserId: th.BasicUser.Id, + TeamId: team2.Id, + SchemeUser: true, + }, + }, + } + // ensure user can update channel properties before applying the scheme + require.True(t, th.App.SessionHasPermissionToChannel(th.Context, session, channel.Id, model.PermissionManagePublicChannelProperties)) + // apply the team scheme + team2.SchemeId = &team2Scheme.Id + _, err = th.App.UpdateTeamScheme(team2) + require.Nil(t, err) + require.False(t, th.App.SessionHasPermissionToChannel(th.Context, session, channel.Id, model.PermissionManagePublicChannelProperties)) } func TestGetTeamMembers(t *testing.T) { diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 61d30cf909..0ce432c3d0 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -696,6 +696,19 @@ func (s *OpenTracingLayerChannelStore) ClearCaches() { } +func (s *OpenTracingLayerChannelStore) ClearMembersForUserCache() { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.ClearMembersForUserCache") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + s.ChannelStore.ClearMembersForUserCache() + +} + func (s *OpenTracingLayerChannelStore) ClearSidebarOnTeamLeave(userID string, teamID string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.ClearSidebarOnTeamLeave") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index e2e17d5e61..b05b3dfc21 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -750,6 +750,12 @@ func (s *RetryLayerChannelStore) ClearCaches() { } +func (s *RetryLayerChannelStore) ClearMembersForUserCache() { + + s.ChannelStore.ClearMembersForUserCache() + +} + func (s *RetryLayerChannelStore) ClearSidebarOnTeamLeave(userID string, teamID string) error { tries := 0 diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index ab1d6c0ebb..85864f3784 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -451,6 +451,10 @@ var channelByNameCache = cache.NewLRU(cache.LRUOptions{ Size: model.ChannelCacheSize, }) +func (s SqlChannelStore) ClearMembersForUserCache() { + allChannelMembersForUserCache.Purge() +} + func (s SqlChannelStore) ClearCaches() { allChannelMembersForUserCache.Purge() allChannelMembersNotifyPropsForChannelCache.Purge() diff --git a/store/store.go b/store/store.go index deebac76f6..7cbf0d3e1b 100644 --- a/store/store.go +++ b/store/store.go @@ -267,6 +267,7 @@ type ChannelStore interface { AnalyticsDeletedTypeCount(teamID string, channelType model.ChannelType) (int64, error) GetChannelUnread(channelID, userID string) (*model.ChannelUnread, error) ClearCaches() + ClearMembersForUserCache() GetChannelsByScheme(schemeID string, offset int, limit int) (model.ChannelList, error) MigrateChannelMembers(fromChannelID string, fromUserID string) (map[string]string, error) ResetAllChannelSchemes() error diff --git a/store/storetest/mocks/ChannelStore.go b/store/storetest/mocks/ChannelStore.go index 5cfe28d6a0..f54a4266d8 100644 --- a/store/storetest/mocks/ChannelStore.go +++ b/store/storetest/mocks/ChannelStore.go @@ -150,6 +150,11 @@ func (_m *ChannelStore) ClearCaches() { _m.Called() } +// ClearMembersForUserCache provides a mock function with given fields: +func (_m *ChannelStore) ClearMembersForUserCache() { + _m.Called() +} + // ClearSidebarOnTeamLeave provides a mock function with given fields: userID, teamID func (_m *ChannelStore) ClearSidebarOnTeamLeave(userID string, teamID string) error { ret := _m.Called(userID, teamID) diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 86a925e4e4..da4503ae92 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -669,6 +669,21 @@ func (s *TimerLayerChannelStore) ClearCaches() { } } +func (s *TimerLayerChannelStore) ClearMembersForUserCache() { + start := time.Now() + + s.ChannelStore.ClearMembersForUserCache() + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if true { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.ClearMembersForUserCache", success, elapsed) + } +} + func (s *TimerLayerChannelStore) ClearSidebarOnTeamLeave(userID string, teamID string) error { start := time.Now() From b0001f7cadd4803d0045eaedf79d08b83ca8e4f9 Mon Sep 17 00:00:00 2001 From: Martin Kraft Date: Mon, 28 Nov 2022 11:19:58 -0500 Subject: [PATCH 37/80] =?UTF-8?q?MM-41294:=20Allow=20team=20scheme=20APIs?= =?UTF-8?q?=20to=20be=20accessed=20with=20a=20professional=20l=E2=80=A6=20?= =?UTF-8?q?(#21660)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MM-41294: Allow team scheme APIs to be accessed with a professional license. * MM-41294: Adds tests. --- api4/scheme.go | 6 ++-- api4/scheme_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/api4/scheme.go b/api4/scheme.go index efb5f781b0..82421ee610 100644 --- a/api4/scheme.go +++ b/api4/scheme.go @@ -33,7 +33,7 @@ func createScheme(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) auditRec.AddEventParameter("scheme", scheme) - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.CustomPermissionsSchemes { + if c.App.Channels().License() == nil || (!*c.App.Channels().License().Features.CustomPermissionsSchemes && c.App.Channels().License().SkuShortName != model.LicenseShortSkuProfessional) { c.Err = model.NewAppError("Api4.CreateScheme", "api.scheme.create_scheme.license.error", nil, "", http.StatusNotImplemented) return } @@ -194,7 +194,7 @@ func patchScheme(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventParameter("scheme_patch", patch) defer c.LogAuditRec(auditRec) - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.CustomPermissionsSchemes { + if c.App.Channels().License() == nil || (!*c.App.Channels().License().Features.CustomPermissionsSchemes && c.App.Channels().License().SkuShortName != model.LicenseShortSkuProfessional) { c.Err = model.NewAppError("Api4.PatchScheme", "api.scheme.patch_scheme.license.error", nil, "", http.StatusNotImplemented) return } @@ -239,7 +239,7 @@ func deleteScheme(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventParameter("scheme_id", c.Params.SchemeId) defer c.LogAuditRec(auditRec) - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.CustomPermissionsSchemes { + if c.App.Channels().License() == nil || (!*c.App.Channels().License().Features.CustomPermissionsSchemes && c.App.Channels().License().SkuShortName != model.LicenseShortSkuProfessional) { c.Err = model.NewAppError("Api4.DeleteScheme", "api.scheme.delete_scheme.license.error", nil, "", http.StatusNotImplemented) return } diff --git a/api4/scheme_test.go b/api4/scheme_test.go index 8fda68d19f..ba478a243e 100644 --- a/api4/scheme_test.go +++ b/api4/scheme_test.go @@ -150,6 +150,31 @@ func TestCreateScheme(t *testing.T) { _, r6, _ := th.SystemAdminClient.CreateScheme(scheme6) CheckNotImplementedStatus(t, r6) + // Create scheme with a Professional SKU license but no explicit 'custom_permissions_schemes' license feature. + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuProfessional, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + th.App.Srv().SetLicense(lic) + scheme6b := &model.Scheme{ + DisplayName: model.NewId(), + Name: model.NewId(), + Description: model.NewId(), + Scope: model.SchemeScopeTeam, + } + _, resp, err := th.SystemAdminClient.CreateScheme(scheme6b) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + th.App.SetPhase2PermissionsMigrationStatus(false) th.LoginSystemAdmin() @@ -579,6 +604,24 @@ func TestPatchScheme(t *testing.T) { _, r11, _ := th.SystemAdminClient.PatchScheme(s6.Id, schemePatch) CheckNotImplementedStatus(t, r11) + // Patch scheme with a Professional SKU license but no explicit 'custom_permissions_schemes' license feature. + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuProfessional, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + th.App.Srv().SetLicense(lic) + _, _, err = th.SystemAdminClient.PatchScheme(s6.Id, schemePatch) + require.NoError(t, err) + th.App.SetPhase2PermissionsMigrationStatus(false) th.LoginSystemAdmin() @@ -745,6 +788,15 @@ func TestDeleteScheme(t *testing.T) { s1, _, err := th.SystemAdminClient.CreateScheme(scheme1) require.NoError(t, err) + scheme2 := &model.Scheme{ + DisplayName: model.NewId(), + Name: model.NewId(), + Description: model.NewId(), + Scope: model.SchemeScopeChannel, + } + s2, _, err := th.SystemAdminClient.CreateScheme(scheme2) + require.NoError(t, err) + // Test with unknown ID. r2, err := th.SystemAdminClient.DeleteScheme(model.NewId()) require.Error(t, err) @@ -766,6 +818,24 @@ func TestDeleteScheme(t *testing.T) { require.Error(t, err) CheckNotImplementedStatus(t, r5) + // Delete scheme with a Professional SKU license but no explicit 'custom_permissions_schemes' license feature. + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuProfessional, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + th.App.Srv().SetLicense(lic) + _, err = th.SystemAdminClient.DeleteScheme(s2.Id) + require.NoError(t, err) + th.App.SetPhase2PermissionsMigrationStatus(false) th.App.Srv().SetLicense(model.NewTestLicense("custom_permissions_schemes")) From 6965585aa8cd6f398b8639484c0a23a6a1862280 Mon Sep 17 00:00:00 2001 From: Tim Scheuermann Date: Mon, 28 Nov 2022 17:35:47 +0100 Subject: [PATCH 38/80] [MM-48391] Store the server info in the export version line (#21709) --- app/export.go | 10 ++++++++++ app/imports/import_types.go | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/app/export.go b/app/export.go index fae99af054..6afd544534 100644 --- a/app/export.go +++ b/app/export.go @@ -7,11 +7,13 @@ import ( "archive/zip" "context" "encoding/json" + "fmt" "io" "net/http" "os" "path/filepath" "strings" + "time" "github.com/pkg/errors" @@ -157,9 +159,17 @@ func (a *App) exportWriteLine(w io.Writer, line *imports.LineImportData) *model. func (a *App) exportVersion(writer io.Writer) *model.AppError { version := 1 + + info := &imports.VersionInfoImportData{ + Generator: "mattermost-server", + Version: fmt.Sprintf("%s (%s, enterprise: %s)", model.CurrentVersion, model.BuildHash, model.BuildEnterpriseReady), + Created: time.Now().Format(time.RFC3339Nano), + } + versionLine := &imports.LineImportData{ Type: "version", Version: &version, + Info: info, } return a.exportWriteLine(writer, versionLine) diff --git a/app/imports/import_types.go b/app/imports/import_types.go index 0780245feb..30996df709 100644 --- a/app/imports/import_types.go +++ b/app/imports/import_types.go @@ -5,6 +5,7 @@ package imports import ( "archive/zip" + "encoding/json" "github.com/mattermost/mattermost-server/v6/model" ) @@ -22,6 +23,14 @@ type LineImportData struct { DirectPost *DirectPostImportData `json:"direct_post,omitempty"` Emoji *EmojiImportData `json:"emoji,omitempty"` Version *int `json:"version,omitempty"` + Info *VersionInfoImportData `json:"info,omitempty"` +} + +type VersionInfoImportData struct { + Generator string `json:"generator"` + Version string `json:"version"` + Created string `json:"created"` + Additional json.RawMessage `json:"additional,omitempty"` } type TeamImportData struct { From 62d92180114dba9e5417f96f9302053cface8bbf Mon Sep 17 00:00:00 2001 From: Martin Kraft Date: Mon, 28 Nov 2022 13:49:51 -0500 Subject: [PATCH 39/80] MM-47936: Removes CustomGroups feature flag. (#21739) * MM-47936: Removes CustomGroups feature flag. * MM-47936: Updates godoc. --- api4/group.go | 6 ++---- config/client_test.go | 12 ++---------- model/feature_flags.go | 3 --- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/api4/group.go b/api4/group.go index 78c606a448..72c78e7179 100644 --- a/api4/group.go +++ b/api4/group.go @@ -980,7 +980,7 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } // If they don't specify a source and custom groups are disabled, ensure they only get ldap groups in the response - if !c.App.Config().FeatureFlags.CustomGroups || !*c.App.Config().ServiceSettings.EnableCustomGroups { + if !*c.App.Config().ServiceSettings.EnableCustomGroups { source = model.GroupSourceLdap } @@ -1328,8 +1328,6 @@ func deleteGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { // // err := licensedAndConfiguredForGroupBySource(c.App, group.Source) // err.Where = "Api4.getGroup" -// -// Temporarily, this function also checks for the CustomGroups feature flag. func licensedAndConfiguredForGroupBySource(app app.AppIface, source model.GroupSource) *model.AppError { lic := app.Srv().License() @@ -1345,7 +1343,7 @@ func licensedAndConfiguredForGroupBySource(app app.AppIface, source model.GroupS return model.NewAppError("", "api.custom_groups.license_error", nil, "", http.StatusBadRequest) } - if source == model.GroupSourceCustom && (!app.Config().FeatureFlags.CustomGroups || !*app.Config().ServiceSettings.EnableCustomGroups) { + if source == model.GroupSourceCustom && !*app.Config().ServiceSettings.EnableCustomGroups { return model.NewAppError("", "api.custom_groups.feature_disabled", nil, "", http.StatusBadRequest) } diff --git a/config/client_test.go b/config/client_test.go index 4c23801424..a13b9cea77 100644 --- a/config/client_test.go +++ b/config/client_test.go @@ -216,11 +216,7 @@ func TestGetClientConfig(t *testing.T) { }, { "Custom groups professional license", - &model.Config{ - FeatureFlags: &model.FeatureFlags{ - CustomGroups: true, - }, - }, + &model.Config{}, "", &model.License{ Features: &model.Features{}, @@ -232,11 +228,7 @@ func TestGetClientConfig(t *testing.T) { }, { "Custom groups enterprise license", - &model.Config{ - FeatureFlags: &model.FeatureFlags{ - CustomGroups: true, - }, - }, + &model.Config{}, "", &model.License{ Features: &model.Features{}, diff --git a/model/feature_flags.go b/model/feature_flags.go index b5e9259ab8..ae1005b30d 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -39,8 +39,6 @@ type FeatureFlags struct { // A dash separated list for feature flags to turn on for Boards BoardsFeatureFlags string - CustomGroups bool - // Enable DataRetention for Boards BoardsDataRetention bool @@ -90,7 +88,6 @@ func (f *FeatureFlags) SetDefaults() { f.PluginFocalboard = "" f.PermalinkPreviews = true f.BoardsFeatureFlags = "" - f.CustomGroups = true f.BoardsDataRetention = false f.NormalizeLdapDNs = false f.EnableInactivityCheckJob = true From cfa008e9c366917cc75bd302b862895925316e49 Mon Sep 17 00:00:00 2001 From: emmyni <44761757+emmyni@users.noreply.github.com> Date: Mon, 28 Nov 2022 14:46:48 -0500 Subject: [PATCH 40/80] [MM-48097]: Delinquency Email Spacing/Wording Changes (#21740) --- app/email/email.go | 2 +- i18n/en.json | 10 +++++----- templates/cloud_30_day_arrears.html | 2 +- templates/cloud_30_day_arrears.mjml | 2 +- templates/cloud_45_day_arrears.html | 2 +- templates/cloud_90_day_arrears.html | 2 +- templates/partials/cloud_title_3subtitles_button.mjml | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/email/email.go b/app/email/email.go index 1bd25f999b..b0cd66e7dd 100644 --- a/app/email/email.go +++ b/app/email/email.go @@ -1037,7 +1037,7 @@ func (es *Service) SendNoCardPaymentFailedEmail(email string, locale string, sit func (es *Service) SendDelinquencyEmail7(email, locale, siteURL, planName string) error { T := i18n.GetUserTranslations(locale) - subject := T("api.templates.payment_failed.subject") + subject := T("api.templates.payment_failed.subject", map[string]any{"Plan": planName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL diff --git a/i18n/en.json b/i18n/en.json index 4087b69144..365a0d2199 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3265,7 +3265,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Payment is overdue for your Mattermost {{.Plan}}." + "translation": "Payment is overdue for your Mattermost {{.Plan}}" }, { "id": "api.templates.delinquency_14.subtitle1", @@ -3313,7 +3313,7 @@ }, { "id": "api.templates.delinquency_30.subtitle2", - "translation": "if no action is taken, your workspace will be downgraded and the following data may be archived:" + "translation": "If no action is taken, your workspace will be downgraded and the following data may be archived:" }, { "id": "api.templates.delinquency_30.title", @@ -3329,7 +3329,7 @@ }, { "id": "api.templates.delinquency_45.subtitle1", - "translation": "We've been unable to collect payment for outstanding invoices dated {{.DelinquencyDate}}. Your workspace is at risk of being downgraded." + "translation": "We've been unable to collect payment for outstanding invoices since {{.DelinquencyDate}}. Your workspace is at risk of being downgraded." }, { "id": "api.templates.delinquency_45.subtitle2", @@ -3377,7 +3377,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "We couldn't process your most recent payment" + "translation": "We couldn't process your most recent payment." }, { "id": "api.templates.delinquency_7.subtitle2", @@ -3401,7 +3401,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "This is a final reminder that we haven’t received payment for your Mattermost Cloud workspace since {{.DelinquencyDate}}" + "translation": "This is a final reminder that we haven’t received payment for your Mattermost Cloud workspace since {{.DelinquencyDate}}." }, { "id": "api.templates.delinquency_75.subtitle2", diff --git a/templates/cloud_30_day_arrears.html b/templates/cloud_30_day_arrears.html index 19290dd87f..40cc01ef49 100644 --- a/templates/cloud_30_day_arrears.html +++ b/templates/cloud_30_day_arrears.html @@ -439,7 +439,7 @@ - + -
diff --git a/templates/cloud_30_day_arrears.mjml b/templates/cloud_30_day_arrears.mjml index eaff779310..1b3fb3bb3c 100644 --- a/templates/cloud_30_day_arrears.mjml +++ b/templates/cloud_30_day_arrears.mjml @@ -32,7 +32,7 @@ {{.Props.LimitsDocs}} - + {{.Props.Button}} diff --git a/templates/cloud_45_day_arrears.html b/templates/cloud_45_day_arrears.html index 9c4af6731c..f868f37ab4 100644 --- a/templates/cloud_45_day_arrears.html +++ b/templates/cloud_45_day_arrears.html @@ -433,7 +433,7 @@
+ -
diff --git a/templates/cloud_90_day_arrears.html b/templates/cloud_90_day_arrears.html index f63b2b2a07..21769839ca 100644 --- a/templates/cloud_90_day_arrears.html +++ b/templates/cloud_90_day_arrears.html @@ -433,7 +433,7 @@
+
diff --git a/templates/partials/cloud_title_3subtitles_button.mjml b/templates/partials/cloud_title_3subtitles_button.mjml index 23681ab9e3..38c39071af 100644 --- a/templates/partials/cloud_title_3subtitles_button.mjml +++ b/templates/partials/cloud_title_3subtitles_button.mjml @@ -16,7 +16,7 @@ {{.Props.Button}} {{if .IncludeSecondaryActionButton}} - + {{.Props.SecondaryActionButtonText}} {{end}} From f12ac02e0bf8b167b6480137acec849ba77ba9de Mon Sep 17 00:00:00 2001 From: emmyni <44761757+emmyni@users.noreply.github.com> Date: Mon, 28 Nov 2022 16:54:15 -0500 Subject: [PATCH 41/80] [MM-48088]: Ability to migrate from Monthly to Cloud Pro Annual (#21673) --- model/cloud.go | 1 + 1 file changed, 1 insertion(+) diff --git a/model/cloud.go b/model/cloud.go index 2b072e1f00..1d33c57146 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -227,6 +227,7 @@ type CloudWorkspaceOwner struct { } type SubscriptionChange struct { ProductID string `json:"product_id"` + Seats int `json:"seats"` } type BoardsLimits struct { From 37466fe43812c198acf218172478edc214f1649d Mon Sep 17 00:00:00 2001 From: Mattermod Date: Tue, 29 Nov 2022 12:30:18 +0200 Subject: [PATCH 42/80] Update Licences at Notice.txt to reflect dependency changes. (#21751) --- NOTICE.txt | 4049 ++++++++++++++++++++++++---------------------------- 1 file changed, 1878 insertions(+), 2171 deletions(-) diff --git a/NOTICE.txt b/NOTICE.txt index 3f186ef717..7b11691cf2 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,12 +1,13 @@ Mattermost Server -© 2015-present Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information. + +©2015-present Mattermost,Inc. All Rights Reserved. See LICENSE for license information. NOTICES: -------- This document includes a list of open source components used in Mattermost Server, including those that have been modified. ------ +-------- ## Go @@ -47,22 +48,15 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## Masterminds/squirrel +## Masterminds/semver -This product contains 'squirrel' by GitHub user "Masterminds". +This product contains 'Masterminds/semver' by Masterminds. -Fluent SQL generation for golang +Work with Semantic Versions in Go -* HOMEPAGE: - * https://github.com/Masterminds/squirrel +* LICENSE: MIT License -* LICENSE: MIT - -Squirrel -The Masterminds -Copyright (C) 2014-2015, Lann Martin -Copyright (C) 2015-2016, Google -Copyright (C) 2015, Matt Farina and Matt Butcher +Copyright (C) 2014-2019, Matt Butcher and Matt Farina Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -82,220 +76,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- - -## NYTimes/gziphandler - -This product contains 'gziphandler' by The New York Times. - -Go middleware to gzip HTTP responses - -* HOMEPAGE: - * https://github.com/NYTimes/gziphandler - -* LICENSE: Apache-2.0 - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2016-2017 The New York Times Company - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. --- @@ -505,6 +285,223 @@ Go package for fast and reliable abstraction of browser user agent strings. --- +## aws/aws-sdk-go + +This product contains 'aws/aws-sdk-go' by Amazon Web Services. + +AWS SDK for the Go programming language. + +* HOMEPAGE: + * http://aws.amazon.com/sdk-for-go/ + +* LICENSE: Apache License 2.0 + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +--- + ## blang/semver This product contains 'semver' by Benedikt Lang. @@ -538,6 +535,315 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## blevesearch/bleve + +This product contains 'blevesearch/bleve' by bleve. + +A modern text indexing library for go + +* HOMEPAGE: + * https://github.com/blevesearch/bleve + +* LICENSE: Apache License 2.0 + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- + +## cespare/xxhash + +This product contains 'cespare/xxhash' by Caleb Spare. + +A Go implementation of the 64-bit xxHash algorithm (XXH64) + +* LICENSE: MIT License + +Copyright (c) 2016 Caleb Spare + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +--- + +## code.sajari.com/docconv + +This product contains 'code.sajari.com/docconv' by Search.io. + +Converts PDF, DOC, DOCX, XML, HTML, RTF, etc to plain text + +* HOMEPAGE: + * https://github.com/sajari/docconv + +* LICENSE: MIT License + +The MIT License (MIT) + +Copyright (c) 2014 Sajari Pty Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--- + +## dgrijalva/jwt-go + +This product contains 'dgrijalva/jwt-go' by Dave Grijalva. + +ARCHIVE - Golang implementation of JSON Web Tokens (JWT). This project is now maintained at: + +* HOMEPAGE: + * https://github.com/golang-jwt/jwt + +* LICENSE: MIT License + +Copyright (c) 2012 Dave Grijalva +Copyright (c) 2021 golang-jwt maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + --- ## dgryski/dgoogauth @@ -699,6 +1005,38 @@ SOFTWARE. --- +## francoispqt/gojay + +This product contains 'francoispqt/gojay' by Francois Parquet. + +high performance JSON encoder/decoder with stream API for Golang + +* LICENSE: MIT License + +MIT License + +Copyright (c) 2016 gojay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + ## fsnotify/fsnotify This product contains 'fsnotify' by GitHub user "fsnotify". @@ -741,78 +1079,59 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## go-ldap/ldap +## getsentry/sentry-go -This product contains 'ldap' by GitHub user "go-ldap". +This product contains 'getsentry/sentry-go' by Sentry. -Basic LDAP v3 functionality for the GO programming language. +Official Sentry SDK for Go * HOMEPAGE: - * https://github.com/go-ldap/ldap + * https://docs.sentry.io/platforms/go/ -* LICENSE: MIT +* LICENSE: BSD 2-Clause "Simplified" License + +Copyright (c) 2019 Sentry (https://sentry.io) and individual contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +--- + +## go-mail/mail + +This product contains 'go-mail/mail' by go-mail. + +Actively maintained fork of gomail. The best way to send emails in Go. + +* LICENSE: MIT License The MIT License (MIT) -Copyright (c) 2011-2015 Michael Mitton (mmitton@gmail.com) -Portions copyright (c) 2015-2016 go-ldap Authors +Copyright (c) 2014 Alexandre Cesaro -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- - -## go-redis/redis - -This product contains 'redis' by GitHub user "go-redis". - -Type-safe Redis client for Golang - -* HOMEPAGE: - * https://github.com/go-redis/redis - -* LICENSE: BSD-2-Clause - -Copyright (c) 2013 The github.com/go-redis/redis Authors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- @@ -1201,6 +1520,277 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. +--- + +## go-yaml/yaml + +This product contains 'yaml' by GitHub user "go-yaml". + +YAML support for the Go language. + +* HOMEPAGE: + * https://github.com/go-yaml/yaml + +* LICENSE: Apache-2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +* This package includes the following NOTICE: + +Copyright 2011-2016 Canonical Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- + +## golang-migrate/migrate + +This product contains 'golang-migrate/migrate' by golang-migrate. + +Database migrations. CLI and Golang library. + +* LICENSE: Other + +The MIT License (MIT) + +Original Work +Copyright (c) 2016 Matthias Kadenbach +https://github.com/mattes/migrate + +Modified Work +Copyright (c) 2018 Dale Hui +https://github.com/golang-migrate/migrate + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + --- ## golang/freetype @@ -1383,20 +1973,20 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## hako/durafmt +## graph-gophers/dataloader -This product contains 'durafmt' by Wesley Hill. +This product contains 'graph-gophers/dataloader' by graph-gophers. -:clock8: Better time duration formatting in Go! +Implementation of Facebook's DataLoader in Golang * HOMEPAGE: - * https://github.com/hako/durafmt + * https://github.com/graph-gophers/dataloader -* LICENSE: MIT +* LICENSE: MIT License -The MIT License (MIT) +MIT License -Copyright (c) 2016 Wesley Hill +Copyright (c) 2017 Nick Randall Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -1416,6 +2006,79 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## graph-gophers/graphql-go + +This product contains 'graph-gophers/graphql-go' by graph-gophers. + +GraphQL server with a focus on ease of use + +* LICENSE: BSD 2-Clause "Simplified" License + +Copyright (c) 2016 Richard Musiol. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +--- + +## h2non/go-is-svg + +This product contains 'h2non/go-is-svg' by Tom. + +Check if a given buffer is a valid SVG image in Go (golang) + +* LICENSE: MIT License + +The MIT License + +Copyright (c) 2016 Tomas Aparicio + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + --- ## hashicorp/go-hclog @@ -1820,405 +2483,219 @@ Exhibit B - “Incompatible With Secondary Licenses” Notice --- -## hashicorp/memberlist +## jaegertracing/jaeger-client-go -This product contains 'memberlist' by HashiCorp. +This product contains 'jaegertracing/jaeger-client-go' by Jaeger - Distributed Tracing Platform. -Golang package for gossip based membership and failure detection +🛑 This library is DEPRECATED! * HOMEPAGE: - * https://github.com/hashicorp/memberlist + * https://jaegertracing.io/ + +* LICENSE: Apache License 2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. -* LICENSE: MPL-2.0 - -Mozilla Public License, version 2.0 - -1. Definitions - -1.1. “Contributor” - - means each individual or legal entity that creates, contributes to the - creation of, or owns Covered Software. - -1.2. “Contributor Version” - - means the combination of the Contributions of others (if any) used by a - Contributor and that particular Contributor’s Contribution. - -1.3. “Contribution” - - means Covered Software of a particular Contributor. - -1.4. “Covered Software” - - means Source Code Form to which the initial Contributor has attached the - notice in Exhibit A, the Executable Form of such Source Code Form, and - Modifications of such Source Code Form, in each case including portions - thereof. - -1.5. “Incompatible With Secondary Licenses” - means - - a. that the initial Contributor has attached the notice described in - Exhibit B to the Covered Software; or - - b. that the Covered Software was made available under the terms of version - 1.1 or earlier of the License, but not also under the terms of a - Secondary License. - -1.6. “Executable Form” - - means any form of the work other than Source Code Form. - -1.7. “Larger Work” - - means a work that combines Covered Software with other material, in a separate - file or files, that is not Covered Software. - -1.8. “License” - - means this document. - -1.9. “Licensable” - - means having the right to grant, to the maximum extent possible, whether at the - time of the initial grant or subsequently, any and all of the rights conveyed by - this License. - -1.10. “Modifications” - - means any of the following: - - a. any file in Source Code Form that results from an addition to, deletion - from, or modification of the contents of Covered Software; or - - b. any new file in Source Code Form that contains any Covered Software. - -1.11. “Patent Claims” of a Contributor - - means any patent claim(s), including without limitation, method, process, - and apparatus claims, in any patent Licensable by such Contributor that - would be infringed, but for the grant of the License, by the making, - using, selling, offering for sale, having made, import, or transfer of - either its Contributions or its Contributor Version. - -1.12. “Secondary License” - - means either the GNU General Public License, Version 2.0, the GNU Lesser - General Public License, Version 2.1, the GNU Affero General Public - License, Version 3.0, or any later versions of those licenses. - -1.13. “Source Code Form” - - means the form of the work preferred for making modifications. - -1.14. “You” (or “Your”) - - means an individual or a legal entity exercising rights under this - License. For legal entities, “You” includes any entity that controls, is - controlled by, or is under common control with You. For purposes of this - definition, “control” means (a) the power, direct or indirect, to cause - the direction or management of such entity, whether by contract or - otherwise, or (b) ownership of more than fifty percent (50%) of the - outstanding shares or beneficial ownership of such entity. - - -2. License Grants and Conditions - -2.1. Grants - - Each Contributor hereby grants You a world-wide, royalty-free, - non-exclusive license: - - a. under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or as - part of a Larger Work; and - - b. under Patent Claims of such Contributor to make, use, sell, offer for - sale, have made, import, and otherwise transfer either its Contributions - or its Contributor Version. - -2.2. Effective Date - - The licenses granted in Section 2.1 with respect to any Contribution become - effective for each Contribution on the date the Contributor first distributes - such Contribution. - -2.3. Limitations on Grant Scope - - The licenses granted in this Section 2 are the only rights granted under this - License. No additional rights or licenses will be implied from the distribution - or licensing of Covered Software under this License. Notwithstanding Section - 2.1(b) above, no patent license is granted by a Contributor: - - a. for any code that a Contributor has removed from Covered Software; or - - b. for infringements caused by: (i) Your and any other third party’s - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - - c. under Patent Claims infringed by Covered Software in the absence of its - Contributions. - - This License does not grant any rights in the trademarks, service marks, or - logos of any Contributor (except as may be necessary to comply with the - notice requirements in Section 3.4). - -2.4. Subsequent Licenses - - No Contributor makes additional grants as a result of Your choice to - distribute the Covered Software under a subsequent version of this License - (see Section 10.2) or under the terms of a Secondary License (if permitted - under the terms of Section 3.3). - -2.5. Representation - - Each Contributor represents that the Contributor believes its Contributions - are its original creation(s) or it has sufficient rights to grant the - rights to its Contributions conveyed by this License. - -2.6. Fair Use - - This License is not intended to limit any rights You have under applicable - copyright doctrines of fair use, fair dealing, or other equivalents. - -2.7. Conditions - - Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in - Section 2.1. - - -3. Responsibilities - -3.1. Distribution of Source Form - - All distribution of Covered Software in Source Code Form, including any - Modifications that You create or to which You contribute, must be under the - terms of this License. You must inform recipients that the Source Code Form - of the Covered Software is governed by the terms of this License, and how - they can obtain a copy of this License. You may not attempt to alter or - restrict the recipients’ rights in the Source Code Form. - -3.2. Distribution of Executable Form - - If You distribute Covered Software in Executable Form then: - - a. such Covered Software must also be made available in Source Code Form, - as described in Section 3.1, and You must inform recipients of the - Executable Form how they can obtain a copy of such Source Code Form by - reasonable means in a timely manner, at a charge no more than the cost - of distribution to the recipient; and - - b. You may distribute such Executable Form under the terms of this License, - or sublicense it under different terms, provided that the license for - the Executable Form does not attempt to limit or alter the recipients’ - rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - - You may create and distribute a Larger Work under terms of Your choice, - provided that You also comply with the requirements of this License for the - Covered Software. If the Larger Work is a combination of Covered Software - with a work governed by one or more Secondary Licenses, and the Covered - Software is not Incompatible With Secondary Licenses, this License permits - You to additionally distribute such Covered Software under the terms of - such Secondary License(s), so that the recipient of the Larger Work may, at - their option, further distribute the Covered Software under the terms of - either this License or such Secondary License(s). - -3.4. Notices - - You may not remove or alter the substance of any license notices (including - copyright notices, patent notices, disclaimers of warranty, or limitations - of liability) contained within the Source Code Form of the Covered - Software, except that You may alter any license notices to the extent - required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - - You may choose to offer, and to charge a fee for, warranty, support, - indemnity or liability obligations to one or more recipients of Covered - Software. However, You may do so only on Your own behalf, and not on behalf - of any Contributor. You must make it absolutely clear that any such - warranty, support, indemnity, or liability obligation is offered by You - alone, and You hereby agree to indemnify every Contributor for any - liability incurred by such Contributor as a result of warranty, support, - indemnity or liability terms You offer. You may include additional - disclaimers of warranty and limitations of liability specific to any - jurisdiction. - -4. Inability to Comply Due to Statute or Regulation - - If it is impossible for You to comply with any of the terms of this License - with respect to some or all of the Covered Software due to statute, judicial - order, or regulation then You must: (a) comply with the terms of this License - to the maximum extent possible; and (b) describe the limitations and the code - they affect. Such description must be placed in a text file included with all - distributions of the Covered Software under this License. Except to the - extent prohibited by statute or regulation, such description must be - sufficiently detailed for a recipient of ordinary skill to be able to - understand it. - -5. Termination - -5.1. The rights granted under this License will terminate automatically if You - fail to comply with any of its terms. However, if You become compliant, - then the rights granted under this License from a particular Contributor - are reinstated (a) provisionally, unless and until such Contributor - explicitly and finally terminates Your grants, and (b) on an ongoing basis, - if such Contributor fails to notify You of the non-compliance by some - reasonable means prior to 60 days after You have come back into compliance. - Moreover, Your grants from a particular Contributor are reinstated on an - ongoing basis if such Contributor notifies You of the non-compliance by - some reasonable means, this is the first time You have received notice of - non-compliance with this License from such Contributor, and You become - compliant prior to 30 days after Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent - infringement claim (excluding declaratory judgment actions, counter-claims, - and cross-claims) alleging that a Contributor Version directly or - indirectly infringes any patent, then the rights granted to You by any and - all Contributors for the Covered Software under Section 2.1 of this License - shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user - license agreements (excluding distributors and resellers) which have been - validly granted by You or Your distributors under this License prior to - termination shall survive termination. - -6. Disclaimer of Warranty - - Covered Software is provided under this License on an “as is” basis, without - warranty of any kind, either expressed, implied, or statutory, including, - without limitation, warranties that the Covered Software is free of defects, - merchantable, fit for a particular purpose or non-infringing. The entire - risk as to the quality and performance of the Covered Software is with You. - Should any Covered Software prove defective in any respect, You (not any - Contributor) assume the cost of any necessary servicing, repair, or - correction. This disclaimer of warranty constitutes an essential part of this - License. No use of any Covered Software is authorized under this License - except under this disclaimer. - -7. Limitation of Liability - - Under no circumstances and under no legal theory, whether tort (including - negligence), contract, or otherwise, shall any Contributor, or anyone who - distributes Covered Software as permitted above, be liable to You for any - direct, indirect, special, incidental, or consequential damages of any - character including, without limitation, damages for lost profits, loss of - goodwill, work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses, even if such party shall have been - informed of the possibility of such damages. This limitation of liability - shall not apply to liability for death or personal injury resulting from such - party’s negligence to the extent applicable law prohibits such limitation. - Some jurisdictions do not allow the exclusion or limitation of incidental or - consequential damages, so this exclusion and limitation may not apply to You. - -8. Litigation - - Any litigation relating to this License may be brought only in the courts of - a jurisdiction where the defendant maintains its principal place of business - and such litigation shall be governed by laws of that jurisdiction, without - reference to its conflict-of-law provisions. Nothing in this Section shall - prevent a party’s ability to bring cross-claims or counter-claims. - -9. Miscellaneous - - This License represents the complete agreement concerning the subject matter - hereof. If any provision of this License is held to be unenforceable, such - provision shall be reformed only to the extent necessary to make it - enforceable. Any law or regulation which provides that the language of a - contract shall be construed against the drafter shall not be used to construe - this License against a Contributor. - - -10. Versions of the License - -10.1. New Versions - - Mozilla Foundation is the license steward. Except as provided in Section - 10.3, no one other than the license steward has the right to modify or - publish new versions of this License. Each version will be given a - distinguishing version number. - -10.2. Effect of New Versions - - You may distribute the Covered Software under the terms of the version of - the License under which You originally received the Covered Software, or - under the terms of any subsequent version published by the license - steward. - -10.3. Modified Versions - - If you create software not governed by this License, and you want to - create a new license for such software, you may create and use a modified - version of this License if you rename the license and remove any - references to the name of the license steward (except to note that such - modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses - If You choose to distribute Source Code Form that is Incompatible With - Secondary Licenses under the terms of this version of the License, the - notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice - - This Source Code Form is subject to the - terms of the Mozilla Public License, v. - 2.0. If a copy of the MPL was not - distributed with this file, You can - obtain one at - http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular file, then -You may include the notice in a location (such as a LICENSE file in a relevant -directory) where a recipient would be likely to look for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - “Incompatible With Secondary Licenses” Notice - - This Source Code Form is “Incompatible - With Secondary Licenses”, as defined by - the Mozilla Public License, v. 2.0. - ---- - -## icrowley/fake - -This product contains 'fake' by GitHub user "icrowley". - -Fake data generator for Go (Golang) - -* HOMEPAGE: - * https://github.com/icrowley/fake - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2014 Dmitry Afanasyev - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. --- @@ -2255,6 +2732,84 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## jmoiron/sqlx + +This product contains 'sqlx' by Jason Moiron. + +general purpose extensions to golang's database/sql + +* HOMEPAGE: + * https://github.com/jmoiron/sqlx + +* LICENSE: MIT + + Copyright (c) 2013, Jason Moiron + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + +--- + +## ledongthuc/pdf + +This product contains 'ledongthuc/pdf' by Thuc Le. + +PDF reader + +* HOMEPAGE: + * https://github.com/ledongthuc/pdf + +* LICENSE: BSD 3-Clause "New" or "Revised" License + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + --- ## lib/pq @@ -2279,39 +2834,347 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --- -## mattermost/gorp +## mattermost/go-i18n -This product contains 'gorp' by Mattermost (forked from original GitHub repo 'go-gorp/gorp' owned by GitHub user "go-gorp"). +This product contains 'mattermost/go-i18n' by Mattermost. -Go Relational Persistence - an ORM-ish library for Go +Translate your Go program into multiple languages. + +* LICENSE: MIT License + +Copyright (c) 2014 Nick Snyder https://github.com/nicksnyder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +--- + +## mattermost/gziphandler + +This product contains 'mattermost/gziphandler' by Mattermost. * HOMEPAGE: - * https://github.com/mattermost/gorp + * https://github.com/mattermost/gziphandler -* LICENSE: MIT +* LICENSE: Apache License 2.0 -(The MIT License) + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2012 James Cooper + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: + 1. Definitions. -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016-2017 The New York Times Company + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +--- + +## mattermost/ldap + +This product contains 'mattermost/ldap' by Mattermost. + +Basic LDAP v3 functionality for the GO programming language. + +* LICENSE: MIT License + +The MIT License (MIT) + +Copyright (c) 2011-2015 Michael Mitton (mmitton@gmail.com) +Portions copyright (c) 2015-2016 go-ldap Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +--- + +## mattermost/logr + +This product contains 'mattermost/logr' by Mattermost. + +Fully asynchronous, structured, pluggable logging for Go. + +* LICENSE: MIT License + +MIT License + +Copyright (c) 2019 wiggin77 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +--- + +## mattermost/morph + +This product contains 'mattermost/morph' by Mattermost. + +* LICENSE: Other + +The MIT License (MIT) + +Copyright (c) 2021 The go-morph AUTHORS. All rights reserved. +https://github.com/go-morph/morph + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- @@ -2356,20 +3219,55 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## mattermost/viper +## mattermost/squirrel -This product contains 'viper' by Mattermost (forked from original GitHub repo 'spf13/viper' owned by Steve Francia). +This product contains 'mattermost/squirrel' by Mattermost. -Go configuration with fangs +To house a custom fork of github.com/Masterminds/squirrel aka Fluent SQL generation for golang + +* LICENSE: Other + +Squirrel +The Masterminds +Copyright (C) 2014-2015, Lann Martin +Copyright (C) 2015-2016, Google +Copyright (C) 2015, Matt Farina and Matt Butcher + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +--- + +## mholt/archiver + +This product contains 'mholt/archiver' by Matt Holt. + +Easily create & extract archives, and compress & decompress files of various formats * HOMEPAGE: - * https://github.com/mattermost/viper + * https://pkg.go.dev/github.com/mholt/archiver/v4 -* LICENSE: MIT +* LICENSE: MIT License -The MIT License (MIT) +MIT License -Copyright (c) 2014 Steve Francia +Copyright (c) 2016 Matthew Holt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -2389,6 +3287,52 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## microcosm-cc/bluemonday + +This product contains 'microcosm-cc/bluemonday' by Microcosm. + +bluemonday: a fast golang HTML sanitizer (inspired by the OWASP Java HTML Sanitizer) to scrub user generated content of XSS + +* HOMEPAGE: + * https://github.com/microcosm-cc/bluemonday + +* LICENSE: Other + +SPDX short identifier: BSD-3-Clause +https://opensource.org/licenses/BSD-3-Clause + +Copyright (c) 2014, David Kitchen + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the organisation (Microcosm) nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + --- ## minio/minio-go @@ -2612,127 +3556,51 @@ Copyright 2015-2017 MinIO, Inc. --- -## nicksnyder/go-i18n +## oov/psd -This product contains 'go-i18n' by Mattermost, modified (forked) from original GitHub repo 'nicksnyder/go-i18n' owned by Nick Snyder. +This product contains 'psd' by oov. -Translate your Go program into multiple languages. +A PSD/PSB file reader for go * HOMEPAGE: - * https://github.com/mattermost/go-i18n + * https://github.com/oov/psd * LICENSE: MIT -Copyright (c) 2014 Nick Snyder https://github.com/nicksnyder +MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Copyright (c) 2016 oov -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. --- -## pborman/uuid +## opentracing/opentracing-go -This product contains 'uuid' by GitHub user "pborman". +This product contains 'opentracing/opentracing-go' by OpenTracing API. -Automatically exported from code.google.com/p/go-uuid +OpenTracing API for Go. 🛑 This library is DEPRECATED! https://github.com/opentracing/specification/issues/163 * HOMEPAGE: - * https://github.com/pborman/uuid + * http://opentracing.io -* LICENSE: BSD-3-Clause - -Copyright (c) 2009,2014 Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ---- - -## pkg/errors - -This product contains 'errors' by GitHub user "pkg". - -Simple error handling primitives - -* HOMEPAGE: - * https://github.com/pkg/errors - -* LICENSE: BSD-2-Clause - -Copyright (c) 2015, Dave Cheney -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ---- - -## prometheus/client_golang - -This product contains 'client_golang' by Prometheus. - -Prometheus instrumentation library for Go applications - -* HOMEPAGE: - * https://github.com/prometheus/client_golang - -* LICENSE: Apache-2.0 +* LICENSE: Apache License 2.0 Apache License Version 2.0, January 2004 @@ -2914,7 +3782,7 @@ Prometheus instrumentation library for Go applications APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" + boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -2922,7 +3790,7 @@ Prometheus instrumentation library for Go applications same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2016 The OpenTracing Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2936,31 +3804,120 @@ Prometheus instrumentation library for Go applications See the License for the specific language governing permissions and limitations under the License. -* This package includes the following NOTICE: -Prometheus instrumentation library for Go applications -Copyright 2012-2015 The Prometheus Authors +--- -This product includes software developed at -SoundCloud Ltd. (http://soundcloud.com/). +## pborman/uuid +This product contains 'uuid' by GitHub user "pborman". -The following components are included in this product: +Automatically exported from code.google.com/p/go-uuid -perks - a fork of https://github.com/bmizerany/perks -https://github.com/beorn7/perks -Copyright 2013-2015 Blake Mizerany, Björn Rabenstein -See https://github.com/beorn7/perks/blob/master/README.md for license details. +* HOMEPAGE: + * https://github.com/pborman/uuid -Go support for Protocol Buffers - Google's data interchange format -http://github.com/golang/protobuf/ -Copyright 2010 The Go Authors -See source code for license details. +* LICENSE: BSD-3-Clause + +Copyright (c) 2009,2014 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +## pkg/errors + +This product contains 'errors' by GitHub user "pkg". + +Simple error handling primitives + +* HOMEPAGE: + * https://github.com/pkg/errors + +* LICENSE: BSD-2-Clause + +Copyright (c) 2015, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +## reflog/dateconstraints + +This product contains 'reflog/dateconstraints' by Eli Yukelzon. + +Validate a date against constraints + +* HOMEPAGE: + * https://github.com/reflog/dateconstraints + +* LICENSE: MIT License + +MIT License + +Copyright (c) 2020 Eli Yukelzon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. -Support for streaming Protocol Buffer messages for the Go language (golang). -https://github.com/matttproud/golang_protobuf_extensions -Copyright 2013 Matt T. Proud -Licensed under the Apache License, Version 2.0 --- @@ -2995,6 +3952,21 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## rudderlabs/analytics-go + +This product contains 'rudderlabs/analytics-go' by RudderStack. + +RudderStack is an open-source, warehouse-first Customer Data Pipeline and Segment-alternative. It collects and routes clickstream data and builds your customer data lake on your data warehouse. + +* HOMEPAGE: + * https://rudderstack.com + +* LICENSE: MIT License + + + --- ## rwcarlsen/goexif @@ -3035,64 +4007,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## segmentio/analytics-go - -This product contains 'analytics-go' by Segment. - -Segment analytics client for Go - -* HOMEPAGE: - * https://github.com/segmentio/analytics-go - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2016 Segment, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- - -## sirupsen/logrus - -This product contains 'logrus' by Simon Eskildsen. - -Structured, pluggable logging for Go. - -* HOMEPAGE: - * https://github.com/sirupsen/logrus - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2014 Simon Eskildsen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - ## spf13/cobra This product contains 'cobra' by Steve Francia. @@ -3281,39 +4195,31 @@ A Commander for modern Go CLI interactions --- -## jmoiron/sqlx +## splitio/go-client -This product contains 'sqlx' by Jason Moiron. +This product contains 'splitio/go-client' by Split Software. -general purpose extensions to golang's database/sql +Go SDK client for Split Software * HOMEPAGE: - * https://github.com/jmoiron/sqlx + * https://split.io -* LICENSE: MIT +* LICENSE: Other - Copyright (c) 2013, Jason Moiron +Copyright © 2022 Split Software, Inc. - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. --- @@ -3378,71 +4284,22 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND --- -## tylerb/graceful +## tinylib/msgp -This product contains 'graceful' by Tyler Stillwater. +This product contains 'tinylib/msgp' by tinylib. -Graceful is a Go package enabling graceful shutdown of an http.Handler server. +A Go code generator for MessagePack / msgpack.org[Go] -* HOMEPAGE: - * https://github.com/tylerb/graceful +* LICENSE: MIT License -* LICENSE: MIT +Copyright (c) 2014 Philip Hofer +Portions Copyright (c) 2009 The Go Authors (license at http://golang.org) where indicated -The MIT License (MIT) +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -Copyright (c) 2014 Tyler Bunnell +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## zap - -This product contains 'zap' by Uber Technologies, Inc.. - -Blazing fast, structured, leveled logging in Go. - -* HOMEPAGE: - * https://github.com/uber-go/zap - -* LICENSE: MIT - -Copyright (c) 2016-2017 Uber Technologies, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- @@ -3567,6 +4424,48 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +--- + +## x/sync + +This product contains 'x/sync' by Go. + +[mirror] concurrency primitives + +* HOMEPAGE: + * https://github.com/golang/sync + +* LICENSE: BSD 3-Clause "New" or "Revised" License + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + --- ## x/text @@ -3610,931 +4509,16 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- -## mail +## x/tools -This product contains 'mail' by GitHub user "go-mail", modified (forked) from original GitHub repo 'go-gomail/gomail' owned by Gomail. +This product contains 'x/tools' by Go. -Actively maintained fork of gomail. The best way to send emails in Go. +[mirror] Go Tools * HOMEPAGE: - * https://github.com/go-mail/mail + * https://golang.org/x/tools -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2014 Alexandre Cesaro - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- - -## natefinch/lumberjack - -This product contains 'lumberjack' by Nate Finch. - -lumberjack is a log rolling package for Go - -* HOMEPAGE: - * https://github.com/natefinch/lumberjack - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2014 Nate Finch - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## olivere/elastic - -This product contains 'elastic' by Oliver Eilhard. - -Elasticsearch client for Go. - -* HOMEPAGE: - * https://github.com/olivere/elastic - -* LICENSE: MIT - -The MIT License (MIT) -Copyright © 2012-2015 Oliver Eilhard - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the “Software”), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. - ---- - -## go-yaml/yaml - -This product contains 'yaml' by GitHub user "go-yaml". - -YAML support for the Go language. - -* HOMEPAGE: - * https://github.com/go-yaml/yaml - -* LICENSE: Apache-2.0 - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -* This package includes the following NOTICE: - -Copyright 2011-2016 Canonical Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - ---- - -## go/imageproxy - -This product contains 'imageproxy' by Will Norris. - -A caching, resizing image proxy written in Go - -* HOMEPAGE: - * https://github.com/willnorris/imageproxy - -* LICENSE: Apache-2.0 - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - ---- - -## oov/psd - -This product contains 'psd' by oov. - -A PSD/PSB file reader for go - -* HOMEPAGE: - * https://github.com/oov/psd - -* LICENSE: MIT - -MIT License - -Copyright (c) 2016 oov - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## gopherjs - -This product contains 'gopherjs' by Richard Musiol. - -A Go code to javascript code compiler. - -* HOMEPAGE: - * https://github.com/gopherjs/gopherjs - -* LICENSE: - -Copyright (c) 2013 Richard Musiol. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -## AWS SDK for Go - -This product contains 'aws-sdk' by Amazon. - -AWS-SDK support for the Go language. - -* HOMEPAGE: - * https://github.com/aws/aws-sdk-go - -* LICENSE: - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - ---- - -## semver - -This product contains 'semver' by Masterminds. - -The semver package provides the ability to work with Semantic Versions in Go. - -* HOMEPAGE: - * https://github.com/Masterminds/semver - -* LICENSE: - -Copyright (C) 2014-2019, Matt Butcher and Matt Farina - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - -## Date Constraints - -This product contains 'dateconstraints' by Eli Yukelzon. - -Go library to validate a date against constraints - -* HOMEPAGE: - * https://github.com/reflog/dateconstraints - -* LICENSE: - -MIT License - -Copyright (c) 2020 Eli Yukelzon - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## Archiver - -This product contains 'archiver' by Matthew Holt - -A library to handle different archive files (zip, rar, tar.gz...) - -* HOMEPAGE: - * https://github.com/mholt/archiver - -* LICENSE: - -MIT License - -Copyright (c) 2016 Matthew Holt - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## PDF Reader library - -This product contains 'pdf' by the Go team and modified by Thuc Le - -A library to provide pdf reading support - -* HOMEPAGE: - * https://github.com/ledongthuc/pdf - -* LICENSE: +* LICENSE: BSD 3-Clause "New" or "Revised" License Copyright (c) 2009 The Go Authors. All rights reserved. @@ -4564,281 +4548,4 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ---- - -## GoOse - -This product contains 'GoOse' by Antonio Linari - -A library to provide html text extraction support - -* HOMEPAGE: - * https://github.com/advancedlogic/GoOse - -* LICENSE: - -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - ---- - -## Docconv - -This product contains 'docconv' by Sajari Pty Ltd - -A library to provide text extraction support for different documents - -* HOMEPAGE: - * https://github.com/sajari/docconv - -* LICENSE: - -The MIT License (MIT) - -Copyright (c) 2014 Sajari Pty Ltd - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - -## JWT-Go - -This product contains `jwt-go` by Dave Grijalva - -* HOMEPAGE: - * https://github.com/dgrijalva/jwt-go - -* LICENSE: - -The MIT License (MIT) - -Copyright (c) 2012 Dave Grijalva - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of -the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 2455de99ff4d78108a8b9a4011016932b5810d90 Mon Sep 17 00:00:00 2001 From: emmyni <44761757+emmyni@users.noreply.github.com> Date: Tue, 29 Nov 2022 11:47:51 -0500 Subject: [PATCH 43/80] [MM-47565]: In product true up for yearly products (#21704) --- app/app_iface.go | 2 + app/cloud.go | 17 ++++++ app/opentracing/opentracing_layer.go | 22 ++++++++ app/user.go | 5 ++ app/user_test.go | 82 ++++++++++++++++++++++++++++ einterfaces/cloud.go | 3 + einterfaces/mocks/CloudInterface.go | 46 ++++++++++++++++ model/cloud.go | 53 +++++++++++++----- 8 files changed, 215 insertions(+), 15 deletions(-) diff --git a/app/app_iface.go b/app/app_iface.go index e3c02a0d80..0ce3e27e33 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -83,6 +83,8 @@ type AppIface interface { ConvertBotToUser(c request.CTX, bot *model.Bot, userPatch *model.UserPatch, sysadmin bool) (*model.User, *model.AppError) // ConvertUserToBot converts a user to bot. ConvertUserToBot(user *model.User) (*model.Bot, *model.AppError) + // Create/ Update a subscription history event + SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) // CreateBot creates the given bot and corresponding user. CreateBot(c request.CTX, bot *model.Bot) (*model.Bot, *model.AppError) // CreateChannelScheme creates a new Scheme of scope channel and assigns it to the channel. diff --git a/app/cloud.go b/app/cloud.go index e4f19ac46b..fd2d4fb23e 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -230,3 +230,20 @@ func (a *App) SendNoCardPaymentFailedEmail() *model.AppError { } return nil } + +// Create/ Update a subscription history event +func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) { + license := a.Srv().License() + + // No need to create a Subscription History Event if the license isn't cloud + if !license.IsCloud() { + return nil, nil + } + + // Get user count + userCount, err := a.Srv().Store().User().Count(model.UserCountOptions{}) + if err != nil { + return nil, err + } + return a.Cloud().CreateOrUpdateSubscriptionHistoryEvent(userID, int(userCount)) +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 63a3389735..311543fb78 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -15441,6 +15441,28 @@ func (a *OpenTracingAppLayer) SendPaymentFailedEmail(failedPayment *model.Failed return resultVar0 } +func (a *OpenTracingAppLayer) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendSubscriptionHistoryEvent") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.SendSubscriptionHistoryEvent(userID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendTestPushNotification") diff --git a/app/user.go b/app/user.go index 206a030605..ddc329ddc3 100644 --- a/app/user.go +++ b/app/user.go @@ -318,6 +318,11 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m }) } + _, cwsErr := a.SendSubscriptionHistoryEvent(ruser.Id) + if cwsErr != nil { + c.Logger().Error("Failed to create/update the SubscriptionHistoryEvent", mlog.Err(cwsErr)) + } + return ruser, nil } diff --git a/app/user_test.go b/app/user_test.go index 64aa833295..80a9ffd77d 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -1852,3 +1852,85 @@ func TestIsFirstAdmin(t *testing.T) { require.True(t, isFirstAdmin) }) } + +func TestSendSubscriptionHistoryEvent(t *testing.T) { + cloudProduct := &model.Product{ + ID: "prod_test1", + Name: "name1", + Description: "description1", + PricePerSeat: 1000, + SKU: "sku1", + PriceID: "price_id1", + Family: "family1", + RecurringInterval: "year", + BillingScheme: "billing_scheme1", + CrossSellsTo: "prod_test2", + } + + subscription := &model.Subscription{ + ID: "MySubscriptionID", + CustomerID: "MyCustomer", + ProductID: "SomeProductId", + AddOns: []string{}, + StartAt: 1000000000, + EndAt: 2000000000, + CreateAt: 1000000000, + Seats: 10, + DNS: "some.dns.server", + IsPaidTier: "false", + } + + subscriptionHistory := &model.SubscriptionHistory{ + ID: "sub_history", + SubscriptionID: "MySubscriptionID", + Seats: 10, + CreateAt: 1000000000, + } + + t.Run("Should not create SubscriptionHistoryEvent if the license is not cloud", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("")) + + userID := "123" + + subscriptionHistoryEvent, err := th.App.SendSubscriptionHistoryEvent(userID) + require.NoError(t, err) + require.Nil(t, subscriptionHistoryEvent) + }) + + t.Run("Should create SubscriptionHistoryEvent if the license is cloud and the product is yearly", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := mocks.CloudInterface{} + + // mock the cloud functions + cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil) + cloud.Mock.On("GetCloudProduct", mock.Anything, mock.Anything).Return(cloudProduct, nil) + cloud.Mock.On("CreateOrUpdateSubscriptionHistoryEvent", mock.Anything, mock.Anything).Return(subscriptionHistory, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + // Mock to get the user count + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockUserStore := storemocks.UserStore{} + mockUserStore.On("Count", mock.Anything).Return(int64(10), nil) + + mockStore.On("User").Return(&mockUserStore) + + userID := "123" + + subscriptionHistoryEvent, err := th.App.SendSubscriptionHistoryEvent(userID) + require.NoError(t, err) + require.Equal(t, subscription.ID, subscriptionHistoryEvent.SubscriptionID, "subscription ID doesn't match") + require.Equal(t, 10, subscriptionHistoryEvent.Seats, "Number of seats doesn't match") + }) +} diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 854b54fdf5..36b302a321 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -8,6 +8,7 @@ import ( ) type CloudInterface interface { + GetCloudProduct(userID string, productID string) (*model.Product, error) GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) GetCloudLimits(userID string) (*model.ProductLimits, error) @@ -30,5 +31,7 @@ type CloudInterface interface { // GetLicenseRenewalStatus checks on the portal whether it is possible to use token to renew a license GetLicenseRenewalStatus(userID, token string) error InvalidateCaches() error + + CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) HandleLicenseChange() error } diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index 3157ecfc44..be0e1801d8 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -74,6 +74,29 @@ func (_m *CloudInterface) CreateCustomerPayment(userID string) (*model.StripeSet return r0, r1 } +// CreateOrUpdateSubscriptionHistoryEvent provides a mock function with given fields: userID, userCount +func (_m *CloudInterface) CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) { + ret := _m.Called(userID, userCount) + + var r0 *model.SubscriptionHistory + if rf, ok := ret.Get(0).(func(string, int) *model.SubscriptionHistory); ok { + r0 = rf(userID, userCount) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SubscriptionHistory) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int) error); ok { + r1 = rf(userID, userCount) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCloudCustomer provides a mock function with given fields: userID func (_m *CloudInterface) GetCloudCustomer(userID string) (*model.CloudCustomer, error) { ret := _m.Called(userID) @@ -120,6 +143,29 @@ func (_m *CloudInterface) GetCloudLimits(userID string) (*model.ProductLimits, e return r0, r1 } +// GetCloudProduct provides a mock function with given fields: userID, productID +func (_m *CloudInterface) GetCloudProduct(userID string, productID string) (*model.Product, error) { + ret := _m.Called(userID, productID) + + var r0 *model.Product + if rf, ok := ret.Get(0).(func(string, string) *model.Product); ok { + r0 = rf(userID, productID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Product) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(userID, productID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCloudProducts provides a mock function with given fields: userID, includeLegacyProducts func (_m *CloudInterface) GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) { ret := _m.Called(userID, includeLegacyProducts) diff --git a/model/cloud.go b/model/cloud.go index 1d33c57146..bbc3c8f32f 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -140,21 +140,36 @@ type PaymentMethod struct { // Subscription model represents a subscription on the system. type Subscription struct { - ID string `json:"id"` - CustomerID string `json:"customer_id"` - ProductID string `json:"product_id"` - AddOns []string `json:"add_ons"` - StartAt int64 `json:"start_at"` - EndAt int64 `json:"end_at"` - CreateAt int64 `json:"create_at"` - Seats int `json:"seats"` - Status string `json:"status"` - DNS string `json:"dns"` - IsPaidTier string `json:"is_paid_tier"` - LastInvoice *Invoice `json:"last_invoice"` - IsFreeTrial string `json:"is_free_trial"` - TrialEndAt int64 `json:"trial_end_at"` - DelinquentSince *int64 `json:"delinquent_since"` + ID string `json:"id"` + CustomerID string `json:"customer_id"` + ProductID string `json:"product_id"` + AddOns []string `json:"add_ons"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` + CreateAt int64 `json:"create_at"` + Seats int `json:"seats"` + Status string `json:"status"` + DNS string `json:"dns"` + IsPaidTier string `json:"is_paid_tier"` + LastInvoice *Invoice `json:"last_invoice"` + IsFreeTrial string `json:"is_free_trial"` + TrialEndAt int64 `json:"trial_end_at"` + DelinquentSince *int64 `json:"delinquent_since"` + OriginallyLicensedSeats int `json:"originally_licensed_seats"` +} + +// Subscription History model represents true up event in a yearly subscription +type SubscriptionHistory struct { + ID string `json:"id"` + SubscriptionID string `json:"subscription_id"` + Seats int `json:"seats"` + CreateAt int64 `json:"create_at"` +} + +type SubscriptionHistoryChange struct { + SubscriptionID string `json:"subscription_id"` + Seats int `json:"seats"` + CreateAt int64 `json:"create_at"` } // GetWorkSpaceNameFromDNS returns the work space name. For example from test.mattermost.cloud.com, it returns test @@ -258,3 +273,11 @@ type ProductLimits struct { Messages *MessagesLimits `json:"messages,omitempty"` Teams *TeamsLimits `json:"teams,omitempty"` } + +func (p *Product) IsYearly() bool { + return p.RecurringInterval == RecurringIntervalYearly +} + +func (p *Product) IsMonthly() bool { + return p.RecurringInterval == RecurringIntervalMonthly +} From 4f3f6e649625b62324ec017ddfffe53f2370bd0b Mon Sep 17 00:00:00 2001 From: Nathaniel Allred Date: Tue, 29 Nov 2022 13:32:08 -0600 Subject: [PATCH 44/80] Mm 47422 signup token (#21662) * When service setting flag is on, self hosted workspaces can initiate process for self-serve sign up * Add hosted_customer api. --- api4/api.go | 5 ++ api4/hosted_customer.go | 73 ++++++++++++++++++++ api4/hosted_customer_test.go | 100 ++++++++++++++++++++++++++++ einterfaces/cloud.go | 1 + einterfaces/mocks/CloudInterface.go | 23 +++++++ model/client4.go | 21 ++++++ model/cloud.go | 13 ++++ model/config.go | 5 ++ model/license.go | 1 + model/license_test.go | 5 ++ services/telemetry/telemetry.go | 1 + web/handlers.go | 2 +- 12 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 api4/hosted_customer.go create mode 100644 api4/hosted_customer_test.go diff --git a/api4/api.go b/api4/api.go index ea4b72119a..d94e2d450b 100644 --- a/api4/api.go +++ b/api4/api.go @@ -140,6 +140,8 @@ type Routes struct { Usage *mux.Router // 'api/v4/usage' + HostedCustomer *mux.Router // 'api/v4/hosted_customer' + Drafts *mux.Router // 'api/v4/drafts' } @@ -267,6 +269,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter() + api.BaseRoutes.HostedCustomer = api.BaseRoutes.APIRoot.PathPrefix("/hosted_customer").Subrouter() + api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter() api.InitUser() @@ -312,6 +316,7 @@ func Init(srv *app.Server) (*API, error) { api.InitExport() api.InitInsights() api.InitUsage() + api.InitHostedCustomer() api.InitDrafts() if err := api.InitGraphQL(); err != nil { return nil, err diff --git a/api4/hosted_customer.go b/api4/hosted_customer.go new file mode 100644 index 0000000000..6e696967c6 --- /dev/null +++ b/api4/hosted_customer.go @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +// APIs for self-hosted workspaces to communicate with the backing customer & payments system. +// Endpoints for cloud installations should not go in this file. +func (api *API) InitHostedCustomer() { + + // POST /api/v4/hosted_customer/bootstrap + api.BaseRoutes.HostedCustomer.Handle("/bootstrap", api.APISessionRequired(selfHostedBootstrap)).Methods("POST") +} + +func ensureSelfHostedAdmin(c *Context, where string) { + license := c.App.Channels().License() + + if license.IsCloud() { + c.Err = model.NewAppError(where, "api.cloud.license_error", nil, "Cloud installations do not use this endpoint", http.StatusBadRequest) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { + c.SetPermissionError(model.PermissionSysconsoleWriteBilling) + return + } +} + +func checkSelfHostedFirstTimePurchaseEnabled(c *Context) bool { + config := c.App.Config() + if config == nil { + return false + } + enabled := config.ServiceSettings.SelfHostedFirstTimePurchase + return enabled != nil && *enabled +} + +func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { + where := "Api4.selfHostedBootstrap" + if !checkSelfHostedFirstTimePurchaseEnabled(c) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) + return + } + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + + user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + if userErr != nil { + c.Err = userErr + return + } + + signupProgress, err := c.App.Cloud().BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: user.Email}) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) + return + } + json, err := json.Marshal(signupProgress) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) + return + } + + w.Write(json) +} diff --git a/api4/hosted_customer_test.go b/api4/hosted_customer_test.go new file mode 100644 index 0000000000..6eff1e922d --- /dev/null +++ b/api4/hosted_customer_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" + "github.com/mattermost/mattermost-server/v6/model" +) + +var valFalse = false +var valTrue = true + +func TestSelfHostedBootstrap(t *testing.T) { + t.Run("feature flag off returns not implemented", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valFalse }) + th.App.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusNotImplemented, r.StatusCode) + require.Error(t, err) + }) + + t.Run("cloud instances not allowed to bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusBadRequest, r.StatusCode) + require.Error(t, err) + }) + + t.Run("non-admins not allowed to bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.Error(t, err) + }) + + t.Run("self-hosted admins can bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.ReloadConfig() + cloud := mocks.CloudInterface{} + + cloud.Mock.On("BootstrapSelfHostedSignup", mock.Anything).Return(&model.BootstrapSelfHostedSignupResponse{Progress: "START"}, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + response, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, err) + require.Equal(t, "START", response.Progress) + }) +} diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 36b302a321..8fa16ad023 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -32,6 +32,7 @@ type CloudInterface interface { GetLicenseRenewalStatus(userID, token string) error InvalidateCaches() error + BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) HandleLicenseChange() error } diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index be0e1801d8..05b8fe86d7 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -14,6 +14,29 @@ type CloudInterface struct { mock.Mock } +// BootstrapSelfHostedSignup provides a mock function with given fields: req +func (_m *CloudInterface) BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) { + ret := _m.Called(req) + + var r0 *model.BootstrapSelfHostedSignupResponse + if rf, ok := ret.Get(0).(func(model.BootstrapSelfHostedSignupRequest) *model.BootstrapSelfHostedSignupResponse); ok { + r0 = rf(req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.BootstrapSelfHostedSignupResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(model.BootstrapSelfHostedSignupRequest) error); ok { + r1 = rf(req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ChangeSubscription provides a mock function with given fields: userID, subscriptionID, subscriptionChange func (_m *CloudInterface) ChangeSubscription(userID string, subscriptionID string, subscriptionChange *model.SubscriptionChange) (*model.Subscription, error) { ret := _m.Called(userID, subscriptionID, subscriptionChange) diff --git a/model/client4.go b/model/client4.go index 1057d0c55b..6d12fbd916 100644 --- a/model/client4.go +++ b/model/client4.go @@ -326,6 +326,10 @@ func (c *Client4) cloudRoute() string { return "/cloud" } +func (c *Client4) hostedCustomerRoute() string { + return "/hosted_customer" +} + func (c *Client4) testEmailRoute() string { return "/email/test" } @@ -8220,6 +8224,23 @@ func (c *Client4) UpdateCloudCustomerAddress(address *Address) (*CloudCustomer, return customer, BuildResponse(r), nil } +func (c *Client4) BootstrapSelfHostedSignup(req BootstrapSelfHostedSignupRequest) (*BootstrapSelfHostedSignupResponse, *Response, error) { + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, nil, NewAppError("BootstrapSelfHostedSignup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + r, err := c.DoAPIPostBytes(c.hostedCustomerRoute()+"/bootstrap", reqBytes) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var res *BootstrapSelfHostedSignupResponse + json.NewDecoder(r.Body).Decode(&res) + + return res, BuildResponse(r), nil +} + func (c *Client4) ListImports() ([]string, *Response, error) { r, err := c.DoAPIGet(c.importsRoute(), "") if err != nil { diff --git a/model/cloud.go b/model/cloud.go index bbc3c8f32f..2985c929ea 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -274,6 +274,19 @@ type ProductLimits struct { Teams *TeamsLimits `json:"teams,omitempty"` } +type BootstrapSelfHostedSignupRequest struct { + Email string `json:"email"` +} + +type BootstrapSelfHostedSignupResponse struct { + Progress string `json:"progress"` +} + +type BootstrapSelfHostedSignupResponseInternal struct { + Progress string `json:"progress"` + License string `json:"license"` +} + func (p *Product) IsYearly() bool { return p.RecurringInterval == RecurringIntervalYearly } diff --git a/model/config.go b/model/config.go index dbb39a987c..058a7b7666 100644 --- a/model/config.go +++ b/model/config.go @@ -383,6 +383,7 @@ type ServiceSettings struct { CollapsedThreads *string `access:"experimental_features"` ManagedResourcePaths *string `access:"environment_web_server,write_restrictable,cloud_restrictable"` EnableCustomGroups *bool `access:"site_users_and_teams"` + SelfHostedFirstTimePurchase *bool `access:"write_restrictable,cloud_restrictable"` AllowSyncedDrafts *bool `access:"site_posts"` } @@ -852,6 +853,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.AllowSyncedDrafts == nil { s.AllowSyncedDrafts = NewBool(true) } + + if s.SelfHostedFirstTimePurchase == nil { + s.SelfHostedFirstTimePurchase = NewBool(false) + } } type ClusterSettings struct { diff --git a/model/license.go b/model/license.go index 43a4c6af69..d04a88acef 100644 --- a/model/license.go +++ b/model/license.go @@ -56,6 +56,7 @@ type License struct { SkuShortName string `json:"sku_short_name"` IsTrial bool `json:"is_trial"` IsGovSku bool `json:"is_gov_sku"` + SignupJWT *string `json:"signup_jwt"` } type Customer struct { diff --git a/model/license_test.go b/model/license_test.go index 62bba9439c..6319ccc8e9 100644 --- a/model/license_test.go +++ b/model/license_test.go @@ -158,6 +158,11 @@ func TestIsCloud(t *testing.T) { l1.Features = nil assert.False(t, l1.IsCloud()) + + t.Run("false if license is nil", func(t *testing.T) { + var license *License + assert.False(t, license.IsCloud()) + }) } func TestLicenseRecordIsValid(t *testing.T) { diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index c72759b128..ee8fea6f11 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -450,6 +450,7 @@ func (ts *TelemetryService) trackConfig() { "restrict_link_previews": isDefault(*cfg.ServiceSettings.RestrictLinkPreviews, ""), "enable_custom_groups": *cfg.ServiceSettings.EnableCustomGroups, "post_priority": *cfg.ServiceSettings.PostPriority, + "self_hosted_first_time_purchase": *cfg.ServiceSettings.SelfHostedFirstTimePurchase, "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, }) diff --git a/web/handlers.go b/web/handlers.go index bc836b72ba..798831bc73 100644 --- a/web/handlers.go +++ b/web/handlers.go @@ -236,7 +236,7 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } cloudCSP := "" - if c.App.Channels().License().IsCloud() { + if c.App.Channels().License().IsCloud() || *c.App.Config().ServiceSettings.SelfHostedFirstTimePurchase { cloudCSP = " js.stripe.com/v3" } From a240cd53ebe245fbb844e563d101bb8a1a32f0c3 Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Tue, 29 Nov 2022 23:36:47 +0200 Subject: [PATCH 45/80] MM-48614: saves priority for drafts (#21752) * MM-48614: saves priority for drafts Adds a new column in the drafts table, "priority". This way we can save post's priority in the draft. Fixes OmitConnectionId, which when you published a ws event for a user was getting bypassed. Fixes Get for drafts returns deleted ones as well, which is needed for upsert. * Adds test case for the OmitConnectionId * Addresses review comments, removes DeleteAt * Vets * Adds missing translation * Re-instates DeleteAt column * Adds separate case to get draft including deleted * Fixes Update Draft * Empty --- api4/drafts.go | 7 +- api4/drafts_test.go | 5 - app/draft.go | 6 +- app/draft_test.go | 10 - app/platform/web_conn.go | 8 +- app/web_conn_test.go | 1 + db/migrations/migrations.list | 4 + .../000100_add_draft_priority_column.down.sql | 14 + .../000100_add_draft_priority_column.up.sql | 14 + .../000100_add_draft_priority_column.down.sql | 1 + .../000100_add_draft_priority_column.up.sql | 1 + i18n/en.json | 4 + model/draft.go | 6 + model/websocket_message_test.go | 3 +- store/opentracinglayer/opentracinglayer.go | 4 +- store/retrylayer/retrylayer.go | 4 +- store/sqlstore/draft_store.go | 46 ++- store/sqlstore/draft_store_test.go | 336 ----------------- store/store.go | 2 +- store/storetest/draft_store.go | 346 ++++++++++++++++++ store/storetest/mocks/DraftStore.go | 14 +- store/timerlayer/timerlayer.go | 4 +- 22 files changed, 453 insertions(+), 387 deletions(-) create mode 100644 db/migrations/mysql/000100_add_draft_priority_column.down.sql create mode 100644 db/migrations/mysql/000100_add_draft_priority_column.up.sql create mode 100644 db/migrations/postgres/000100_add_draft_priority_column.down.sql create mode 100644 db/migrations/postgres/000100_add_draft_priority_column.up.sql diff --git a/api4/drafts.go b/api4/drafts.go index 2dd6210c72..92dd6f2c58 100644 --- a/api4/drafts.go +++ b/api4/drafts.go @@ -120,7 +120,12 @@ func deleteDraft(c *Context, w http.ResponseWriter, r *http.Request) { channelID := c.Params.ChannelId draft, err := c.App.GetDraft(userID, channelID, rootID) - if err != nil || c.AppContext.Session().UserId != draft.UserId { + if err != nil { + c.Err = err + return + } + + if c.AppContext.Session().UserId != draft.UserId { c.SetPermissionError(model.PermissionDeletePost) return } diff --git a/api4/drafts_test.go b/api4/drafts_test.go index 879d78bbb8..9788bd1897 100644 --- a/api4/drafts_test.go +++ b/api4/drafts_test.go @@ -33,7 +33,6 @@ func TestUpsertDraft(t *testing.T) { draft := &model.Draft{ CreateAt: 12345, UpdateAt: 12345, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "original", @@ -105,7 +104,6 @@ func TestGetDrafts(t *testing.T) { draft1 := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel1.Id, Message: "draft1", @@ -114,7 +112,6 @@ func TestGetDrafts(t *testing.T) { draft2 := &model.Draft{ CreateAt: 11111, UpdateAt: 32222, - DeleteAt: 0, UserId: user.Id, ChannelId: channel2.Id, Message: "draft2", @@ -180,7 +177,6 @@ func TestDeleteDraft(t *testing.T) { draft1 := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel1.Id, Message: "draft1", @@ -190,7 +186,6 @@ func TestDeleteDraft(t *testing.T) { draft2 := &model.Draft{ CreateAt: 11111, UpdateAt: 32222, - DeleteAt: 0, UserId: user.Id, ChannelId: channel2.Id, Message: "draft2", diff --git a/app/draft.go b/app/draft.go index 86786c909f..46f96d7fae 100644 --- a/app/draft.go +++ b/app/draft.go @@ -20,7 +20,7 @@ func (a *App) GetDraft(userID, channelID, rootID string) (*model.Draft, *model.A return nil, model.NewAppError("GetDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) } - draft, err := a.Srv().Store().Draft().Get(userID, channelID, rootID) + draft, err := a.Srv().Store().Draft().Get(userID, channelID, rootID, false) if err != nil { var nfErr *store.ErrNotFound switch { @@ -39,7 +39,7 @@ func (a *App) UpsertDraft(c *request.Context, draft *model.Draft, connectionID s return nil, model.NewAppError("UpsertDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) } - dt, dErr := a.Srv().Store().Draft().Get(draft.UserId, draft.ChannelId, draft.RootId) + dt, dErr := a.Srv().Store().Draft().Get(draft.UserId, draft.ChannelId, draft.RootId, true) var notFoundErr *store.ErrNotFound if dErr != nil && !errors.As(dErr, ¬FoundErr) { return nil, model.NewAppError("UpsertDraft", "app.select_error", nil, dErr.Error(), http.StatusInternalServerError) @@ -189,7 +189,7 @@ func (a *App) DeleteDraft(userID, channelID, rootID, connectionID string) (*mode return nil, model.NewAppError("DeleteDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) } - draft, nErr := a.Srv().Store().Draft().Get(userID, channelID, rootID) + draft, nErr := a.Srv().Store().Draft().Get(userID, channelID, rootID, false) if nErr != nil { return nil, model.NewAppError("DeleteDraft", "app.draft.get.app_error", nil, nErr.Error(), http.StatusBadRequest) } diff --git a/app/draft_test.go b/app/draft_test.go index fc543de443..2ab0983f45 100644 --- a/app/draft_test.go +++ b/app/draft_test.go @@ -34,7 +34,6 @@ func TestGetDraft(t *testing.T) { draft := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "draft", @@ -84,7 +83,6 @@ func TestUpsertDraft(t *testing.T) { draft1 := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "draft1", @@ -93,7 +91,6 @@ func TestUpsertDraft(t *testing.T) { draft2 := &model.Draft{ CreateAt: 00001, UpdateAt: 00002, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "draft2", @@ -148,7 +145,6 @@ func TestCreateDraft(t *testing.T) { draft1 := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "draft", @@ -157,7 +153,6 @@ func TestCreateDraft(t *testing.T) { draft2 := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel2.Id, Message: "draft2", @@ -223,7 +218,6 @@ func TestUpdateDraft(t *testing.T) { draft1 := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "draft1", @@ -232,7 +226,6 @@ func TestUpdateDraft(t *testing.T) { draft2 := &model.Draft{ CreateAt: 00001, UpdateAt: 00002, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "draft2", @@ -305,7 +298,6 @@ func TestGetDraftsForUser(t *testing.T) { draft1 := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "draft1", @@ -314,7 +306,6 @@ func TestGetDraftsForUser(t *testing.T) { draft2 := &model.Draft{ CreateAt: 00005, UpdateAt: 00005, - DeleteAt: 0, UserId: user.Id, ChannelId: channel2.Id, Message: "draft2", @@ -400,7 +391,6 @@ func TestDeleteDraft(t *testing.T) { draft1 := &model.Draft{ CreateAt: 00001, UpdateAt: 00001, - DeleteAt: 0, UserId: user.Id, ChannelId: channel.Id, Message: "draft1", diff --git a/app/platform/web_conn.go b/app/platform/web_conn.go index 4e4fc75062..d9fa60cd5d 100644 --- a/app/platform/web_conn.go +++ b/app/platform/web_conn.go @@ -753,15 +753,15 @@ func (wc *WebConn) ShouldSendEvent(msg *model.WebSocketEvent) bool { return wc.GetConnectionID() == msg.GetBroadcast().ConnectionId } + if wc.GetConnectionID() == msg.GetBroadcast().OmitConnectionId { + return false + } + // If the event is destined to a specific user if msg.GetBroadcast().UserId != "" { return wc.UserId == msg.GetBroadcast().UserId } - if wc.GetConnectionID() == msg.GetBroadcast().OmitConnectionId { - return false - } - // if the user is omitted don't send the message if len(msg.GetBroadcast().OmitUsers) > 0 { if _, ok := msg.GetBroadcast().OmitUsers[wc.UserId]; ok { diff --git a/app/web_conn_test.go b/app/web_conn_test.go index 6c05aacf56..7b30d292a7 100644 --- a/app/web_conn_test.go +++ b/app/web_conn_test.go @@ -123,6 +123,7 @@ func TestWebConnShouldSendEvent(t *testing.T) { {"should only send to non-admins", &model.WebsocketBroadcast{ContainsSanitizedData: true}, true, true, false, true}, {"should send to nobody", &model.WebsocketBroadcast{ContainsSensitiveData: true, ContainsSanitizedData: true}, false, false, false, false}, {"should omit basic user 2 by connection id", &model.WebsocketBroadcast{OmitConnectionId: user2ConnID}, true, false, true, true}, + {"should omit basic user 2 by connection id while user is set", &model.WebsocketBroadcast{UserId: th.BasicUser2.Id, OmitConnectionId: user2ConnID}, false, false, false, false}, // needs more cases to get full coverage } diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index 5ce8502a01..a7fffb38f3 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -198,6 +198,8 @@ db/migrations/mysql/000098_create_post_acknowledgements.down.sql db/migrations/mysql/000098_create_post_acknowledgements.up.sql db/migrations/mysql/000099_create_drafts.down.sql db/migrations/mysql/000099_create_drafts.up.sql +db/migrations/mysql/000100_add_draft_priority_column.down.sql +db/migrations/mysql/000100_add_draft_priority_column.up.sql db/migrations/postgres/000001_create_teams.down.sql db/migrations/postgres/000001_create_teams.up.sql db/migrations/postgres/000002_create_team_members.down.sql @@ -396,3 +398,5 @@ db/migrations/postgres/000098_create_post_acknowledgements.down.sql db/migrations/postgres/000098_create_post_acknowledgements.up.sql db/migrations/postgres/000099_create_drafts.down.sql db/migrations/postgres/000099_create_drafts.up.sql +db/migrations/postgres/000100_add_draft_priority_column.down.sql +db/migrations/postgres/000100_add_draft_priority_column.up.sql diff --git a/db/migrations/mysql/000100_add_draft_priority_column.down.sql b/db/migrations/mysql/000100_add_draft_priority_column.down.sql new file mode 100644 index 0000000000..a4f15cde40 --- /dev/null +++ b/db/migrations/mysql/000100_add_draft_priority_column.down.sql @@ -0,0 +1,14 @@ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Drafts' + AND table_schema = DATABASE() + AND column_name = 'Priority' + ) > 0, + 'ALTER TABLE Drafts DROP COLUMN Priority;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; diff --git a/db/migrations/mysql/000100_add_draft_priority_column.up.sql b/db/migrations/mysql/000100_add_draft_priority_column.up.sql new file mode 100644 index 0000000000..134cc86f39 --- /dev/null +++ b/db/migrations/mysql/000100_add_draft_priority_column.up.sql @@ -0,0 +1,14 @@ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Drafts' + AND table_schema = DATABASE() + AND column_name = 'Priority' + ) > 0, + 'SELECT 1', + 'ALTER TABLE Drafts ADD COLUMN Priority text;' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; diff --git a/db/migrations/postgres/000100_add_draft_priority_column.down.sql b/db/migrations/postgres/000100_add_draft_priority_column.down.sql new file mode 100644 index 0000000000..db071074d2 --- /dev/null +++ b/db/migrations/postgres/000100_add_draft_priority_column.down.sql @@ -0,0 +1 @@ +ALTER TABLE drafts DROP COLUMN IF EXISTS priority; diff --git a/db/migrations/postgres/000100_add_draft_priority_column.up.sql b/db/migrations/postgres/000100_add_draft_priority_column.up.sql new file mode 100644 index 0000000000..4ef6f9e991 --- /dev/null +++ b/db/migrations/postgres/000100_add_draft_priority_column.up.sql @@ -0,0 +1 @@ +ALTER TABLE drafts ADD COLUMN IF NOT EXISTS priority text; diff --git a/i18n/en.json b/i18n/en.json index 365a0d2199..240aa7aac1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -8679,6 +8679,10 @@ "id": "model.draft.is_valid.msg.app_error", "translation": "Invalid message." }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Invalid priority" + }, { "id": "model.draft.is_valid.props.app_error", "translation": "Invalid props." diff --git a/model/draft.go b/model/draft.go index e683959b1b..a9741e5727 100644 --- a/model/draft.go +++ b/model/draft.go @@ -23,6 +23,7 @@ type Draft struct { Props StringInterface `json:"props"` // Deprecated: use GetProps() FileIds StringArray `json:"file_ids,omitempty"` Metadata *PostMetadata `json:"metadata,omitempty"` + Priority StringInterface `json:"priority,omitempty"` } func (o *Draft) IsValid(maxDraftSize int) *AppError { @@ -58,6 +59,10 @@ func (o *Draft) IsValid(maxDraftSize int) *AppError { return NewAppError("Drafts.IsValid", "model.draft.is_valid.props.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) } + if utf8.RuneCountInString(StringInterfaceToJSON(o.Priority)) > PostPropsMaxRunes { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.priority.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + return nil } @@ -79,6 +84,7 @@ func (o *Draft) PreSave() { } o.UpdateAt = o.CreateAt + o.DeleteAt = 0 o.PreCommit() } diff --git a/model/websocket_message_test.go b/model/websocket_message_test.go index 14c49fa3ff..e8f9c17ecd 100644 --- a/model/websocket_message_test.go +++ b/model/websocket_message_test.go @@ -215,9 +215,10 @@ func TestWebSocketEventDeepCopy(t *testing.T) { TeamId: "ccc", ContainsSanitizedData: true, ContainsSensitiveData: true, + OmitConnectionId: "ddd", } - ev := NewWebSocketEvent("test", "team", "channel", "user", omitUsers, "") + ev := NewWebSocketEvent("test", "team", "channel", "user", omitUsers, "ddd") ev.Add("post", &Post{}) ev.SetBroadcast(broadcast) diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 0ce432c3d0..aa41c28ab5 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -3269,7 +3269,7 @@ func (s *OpenTracingLayerDraftStore) Delete(userID string, channelID string, roo return err } -func (s *OpenTracingLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { +func (s *OpenTracingLayerDraftStore) Get(userID string, channelID string, rootID string, includeDeleted bool) (*model.Draft, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Get") s.Root.Store.SetContext(newCtx) @@ -3278,7 +3278,7 @@ func (s *OpenTracingLayerDraftStore) Get(userID string, channelID string, rootID }() defer span.Finish() - result, err := s.DraftStore.Get(userID, channelID, rootID) + result, err := s.DraftStore.Get(userID, channelID, rootID, includeDeleted) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index b05b3dfc21..5aca1535cb 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -3651,11 +3651,11 @@ func (s *RetryLayerDraftStore) Delete(userID string, channelID string, rootID st } -func (s *RetryLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { +func (s *RetryLayerDraftStore) Get(userID string, channelID string, rootID string, includeDeleted bool) (*model.Draft, error) { tries := 0 for { - result, err := s.DraftStore.Get(userID, channelID, rootID) + result, err := s.DraftStore.Get(userID, channelID, rootID, includeDeleted) if err == nil { return result, nil } diff --git a/store/sqlstore/draft_store.go b/store/sqlstore/draft_store.go index fbf4d07e3d..2dfcc5763d 100644 --- a/store/sqlstore/draft_store.go +++ b/store/sqlstore/draft_store.go @@ -24,7 +24,18 @@ type SqlDraftStore struct { } func draftSliceColumns() []string { - return []string{"CreateAt", "UpdateAt", "DeleteAt", "Message", "RootId", "ChannelId", "UserId", "FileIds", "Props"} + return []string{ + "CreateAt", + "UpdateAt", + "DeleteAt", + "Message", + "RootId", + "ChannelId", + "UserId", + "FileIds", + "Props", + "Priority", + } } func draftToSlice(draft *model.Draft) []interface{} { @@ -38,6 +49,7 @@ func draftToSlice(draft *model.Draft) []interface{} { draft.UserId, model.ArrayToJSON(draft.FileIds), model.StringInterfaceToJSON(draft.Props), + model.StringInterfaceToJSON(draft.Priority), } } @@ -49,17 +61,20 @@ func newSqlDraftStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) } } -func (s *SqlDraftStore) Get(userId, channelId, rootId string) (*model.Draft, error) { +func (s *SqlDraftStore) Get(userId, channelId, rootId string, includeDeleted bool) (*model.Draft, error) { query := s.getQueryBuilder(). - Select("*"). + Select(draftSliceColumns()...). From("Drafts"). Where(sq.Eq{ "UserId": userId, "ChannelId": channelId, "RootId": rootId, - "DeleteAt": 0, }) + if !includeDeleted { + query = query.Where(sq.Eq{"DeleteAt": 0}) + } + dt := model.Draft{} err := s.GetReplicaX().GetBuilder(&dt, query) @@ -108,20 +123,15 @@ func (s *SqlDraftStore) Update(draft *model.Draft) (*model.Draft, error) { Set("Message", draft.Message). Set("Props", draft.Props). Set("FileIds", draft.FileIds). + Set("Priority", draft.Priority). + Set("DeleteAt", 0). Where(sq.Eq{ "UserId": draft.UserId, "ChannelId": draft.ChannelId, "RootId": draft.RootId, - "DeleteAt": 0, }) - sql, args, err := query.ToSql() - - if err != nil { - return nil, errors.Wrapf(err, "failed to convert to sql") - } - - if _, err = s.GetMasterX().Exec(sql, args...); err != nil { + if _, err := s.GetMasterX().ExecBuilder(query); err != nil { return nil, errors.Wrapf(err, "failed to update Draft with channelid=%s", draft.ChannelId) } @@ -132,7 +142,17 @@ func (s *SqlDraftStore) GetDraftsForUser(userID, teamID string) ([]*model.Draft, var drafts []*model.Draft query := s.getQueryBuilder(). - Select("Drafts.*"). + Select( + "Drafts.CreateAt", + "Drafts.UpdateAt", + "Drafts.Message", + "Drafts.RootId", + "Drafts.ChannelId", + "Drafts.UserId", + "Drafts.FileIds", + "Drafts.Props", + "Drafts.Priority", + ). From("Drafts"). InnerJoin("ChannelMembers ON ChannelMembers.ChannelId = Drafts.ChannelId"). Where(sq.And{ diff --git a/store/sqlstore/draft_store_test.go b/store/sqlstore/draft_store_test.go index 471161d0de..25f1a53423 100644 --- a/store/sqlstore/draft_store_test.go +++ b/store/sqlstore/draft_store_test.go @@ -6,345 +6,9 @@ package sqlstore import ( "testing" - "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/store" "github.com/mattermost/mattermost-server/v6/store/storetest" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestDraftStore(t *testing.T) { StoreTestWithSqlStore(t, storetest.TestDraftStore) } - -func TestSaveDraft(t *testing.T) { - StoreTest(t, func(t *testing.T, ss store.Store) { - user := &model.User{ - Id: model.NewId(), - } - - channel := &model.Channel{ - Id: model.NewId(), - } - channel2 := &model.Channel{ - Id: model.NewId(), - } - - member1 := &model.ChannelMember{ - ChannelId: channel.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - member2 := &model.ChannelMember{ - ChannelId: channel2.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - _, err := ss.Channel().SaveMember(member1) - require.NoError(t, err) - - _, err = ss.Channel().SaveMember(member2) - require.NoError(t, err) - - draft1 := &model.Draft{ - CreateAt: 00001, - UpdateAt: 00001, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel.Id, - Message: "draft1", - } - - draft2 := &model.Draft{ - CreateAt: 00005, - UpdateAt: 00005, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel2.Id, - Message: "draft2", - } - - t.Run("save drafts", func(t *testing.T) { - draftResp, err := ss.Draft().Save(draft1) - assert.NoError(t, err) - - assert.Equal(t, draft1.Message, draftResp.Message) - assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) - - draftResp, err = ss.Draft().Save(draft2) - assert.NoError(t, err) - - assert.Equal(t, draft2.Message, draftResp.Message) - assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) - }) - }) -} - -func TestUpdateDraft(t *testing.T) { - StoreTest(t, func(t *testing.T, ss store.Store) { - user := &model.User{ - Id: model.NewId(), - } - - channel := &model.Channel{ - Id: model.NewId(), - } - channel2 := &model.Channel{ - Id: model.NewId(), - } - - member1 := &model.ChannelMember{ - ChannelId: channel.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - member2 := &model.ChannelMember{ - ChannelId: channel2.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - _, err := ss.Channel().SaveMember(member1) - require.NoError(t, err) - - _, err = ss.Channel().SaveMember(member2) - require.NoError(t, err) - - draft1 := &model.Draft{ - CreateAt: 00001, - UpdateAt: 00001, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel.Id, - Message: "draft1", - } - - draft2 := &model.Draft{ - CreateAt: 00005, - UpdateAt: 00005, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel2.Id, - Message: "draft2", - } - - t.Run("update drafts", func(t *testing.T) { - draftResp, err := ss.Draft().Update(draft1) - assert.NoError(t, err) - - assert.Equal(t, draft1.Message, draftResp.Message) - assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) - - draftResp, err = ss.Draft().Update(draft2) - assert.NoError(t, err) - - assert.Equal(t, draft2.Message, draftResp.Message) - assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) - }) - }) -} - -func TestDeleteDraft(t *testing.T) { - StoreTest(t, func(t *testing.T, ss store.Store) { - user := &model.User{ - Id: model.NewId(), - } - - channel := &model.Channel{ - Id: model.NewId(), - } - channel2 := &model.Channel{ - Id: model.NewId(), - } - - member1 := &model.ChannelMember{ - ChannelId: channel.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - member2 := &model.ChannelMember{ - ChannelId: channel2.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - _, err := ss.Channel().SaveMember(member1) - require.NoError(t, err) - - _, err = ss.Channel().SaveMember(member2) - require.NoError(t, err) - - draft1 := &model.Draft{ - CreateAt: 00001, - UpdateAt: 00001, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel.Id, - Message: "draft1", - } - - draft2 := &model.Draft{ - CreateAt: 00005, - UpdateAt: 00005, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel2.Id, - Message: "draft2", - } - - _, err = ss.Draft().Save(draft1) - require.NoError(t, err) - - _, err = ss.Draft().Save(draft2) - require.NoError(t, err) - - t.Run("delete drafts", func(t *testing.T) { - err := ss.Draft().Delete(user.Id, channel.Id, "") - assert.NoError(t, err) - - err = ss.Draft().Delete(user.Id, channel2.Id, "") - assert.NoError(t, err) - }) - }) -} - -func TestGetDraft(t *testing.T) { - StoreTest(t, func(t *testing.T, ss store.Store) { - user := &model.User{ - Id: model.NewId(), - } - - channel := &model.Channel{ - Id: model.NewId(), - } - channel2 := &model.Channel{ - Id: model.NewId(), - } - - member1 := &model.ChannelMember{ - ChannelId: channel.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - member2 := &model.ChannelMember{ - ChannelId: channel2.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - _, err := ss.Channel().SaveMember(member1) - require.NoError(t, err) - - _, err = ss.Channel().SaveMember(member2) - require.NoError(t, err) - - draft1 := &model.Draft{ - CreateAt: 00001, - UpdateAt: 00001, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel.Id, - Message: "draft1", - } - - draft2 := &model.Draft{ - CreateAt: 00005, - UpdateAt: 00005, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel2.Id, - Message: "draft2", - } - - _, err = ss.Draft().Save(draft1) - require.NoError(t, err) - - _, err = ss.Draft().Save(draft2) - require.NoError(t, err) - - t.Run("get drafts", func(t *testing.T) { - draftResp, err := ss.Draft().Get(user.Id, channel.Id, "") - assert.NoError(t, err) - assert.Equal(t, draft1.Message, draftResp.Message) - assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) - - draftResp, err = ss.Draft().Get(user.Id, channel2.Id, "") - assert.NoError(t, err) - assert.Equal(t, draft2.Message, draftResp.Message) - assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) - }) - }) -} - -func TestGetDraftsForUser(t *testing.T) { - StoreTest(t, func(t *testing.T, ss store.Store) { - user := &model.User{ - Id: model.NewId(), - } - - channel := &model.Channel{ - Id: model.NewId(), - } - channel2 := &model.Channel{ - Id: model.NewId(), - } - - member1 := &model.ChannelMember{ - ChannelId: channel.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - member2 := &model.ChannelMember{ - ChannelId: channel2.Id, - UserId: user.Id, - NotifyProps: model.GetDefaultChannelNotifyProps(), - } - - _, err := ss.Channel().SaveMember(member1) - require.NoError(t, err) - - _, err = ss.Channel().SaveMember(member2) - require.NoError(t, err) - - draft1 := &model.Draft{ - CreateAt: 00001, - UpdateAt: 00001, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel.Id, - Message: "draft1", - } - - draft2 := &model.Draft{ - CreateAt: 00005, - UpdateAt: 00005, - DeleteAt: 0, - UserId: user.Id, - ChannelId: channel2.Id, - Message: "draft2", - } - - _, err = ss.Draft().Save(draft1) - require.NoError(t, err) - - _, err = ss.Draft().Save(draft2) - require.NoError(t, err) - - t.Run("get drafts", func(t *testing.T) { - draftResp, err := ss.Draft().GetDraftsForUser(user.Id, "") - assert.NoError(t, err) - - assert.Equal(t, draft2.Message, draftResp[0].Message) - assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) - - assert.Equal(t, draft1.Message, draftResp[1].Message) - assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) - }) - }) -} diff --git a/store/store.go b/store/store.go index 7cbf0d3e1b..6a2a7d6e1d 100644 --- a/store/store.go +++ b/store/store.go @@ -983,7 +983,7 @@ type PostPriorityStore interface { type DraftStore interface { Save(d *model.Draft) (*model.Draft, error) - Get(userID, channelID, rootID string) (*model.Draft, error) + Get(userID, channelID, rootID string, includeDeleted bool) (*model.Draft, error) Delete(userID, channelID, rootID string) error GetDraftsForUser(userID, teamID string) ([]*model.Draft, error) Update(d *model.Draft) (*model.Draft, error) diff --git a/store/storetest/draft_store.go b/store/storetest/draft_store.go index b540d672d3..9bbe65ee82 100644 --- a/store/storetest/draft_store.go +++ b/store/storetest/draft_store.go @@ -6,8 +6,354 @@ package storetest import ( "testing" + "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDraftStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("SaveDraft", func(t *testing.T) { testSaveDraft(t, ss) }) + t.Run("UpdateDraft", func(t *testing.T) { testUpdateDraft(t, ss) }) + t.Run("DeleteDraft", func(t *testing.T) { testDeleteDraft(t, ss) }) + t.Run("GetDraft", func(t *testing.T) { testGetDraft(t, ss) }) + t.Run("GetDraftsForUser", func(t *testing.T) { testGetDraftsForUser(t, ss) }) +} + +func testSaveDraft(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("save drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Save(draft1) + assert.NoError(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Save(draft2) + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) +} + +func testUpdateDraft(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("update drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Update(draft1) + assert.NoError(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Update(draft2) + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) +} + +func testDeleteDraft(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("delete drafts", func(t *testing.T) { + err := ss.Draft().Delete(user.Id, channel.Id, "") + assert.NoError(t, err) + + err = ss.Draft().Delete(user.Id, channel2.Id, "") + assert.NoError(t, err) + + _, err = ss.Draft().Get(user.Id, channel.Id, "", false) + require.Error(t, err) + assert.IsType(t, &store.ErrNotFound{}, err) + + _, err = ss.Draft().Get(user.Id, channel2.Id, "", false) + assert.Error(t, err) + assert.IsType(t, &store.ErrNotFound{}, err) + }) +} + +func testGetDraft(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Get(user.Id, channel.Id, "", false) + assert.NoError(t, err) + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Get(user.Id, channel2.Id, "", false) + assert.NoError(t, err) + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) + + t.Run("get draft including deleted", func(t *testing.T) { + draftResp, err := ss.Draft().Get(user.Id, channel.Id, "", false) + assert.NoError(t, err) + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + err = ss.Draft().Delete(user.Id, channel.Id, "") + assert.NoError(t, err) + _, err = ss.Draft().Get(user.Id, channel.Id, "", false) + assert.Error(t, err) + assert.IsType(t, &store.ErrNotFound{}, err) + + draftResp, err = ss.Draft().Get(user.Id, channel.Id, "", true) + assert.NoError(t, err) + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + }) +} + +func testGetDraftsForUser(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := ss.Draft().GetDraftsForUser(user.Id, "") + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + }) } diff --git a/store/storetest/mocks/DraftStore.go b/store/storetest/mocks/DraftStore.go index b7d89b1308..1eb7d5f17b 100644 --- a/store/storetest/mocks/DraftStore.go +++ b/store/storetest/mocks/DraftStore.go @@ -28,13 +28,13 @@ func (_m *DraftStore) Delete(userID string, channelID string, rootID string) err return r0 } -// Get provides a mock function with given fields: userID, channelID, rootID -func (_m *DraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { - ret := _m.Called(userID, channelID, rootID) +// Get provides a mock function with given fields: userID, channelID, rootID, includeDeleted +func (_m *DraftStore) Get(userID string, channelID string, rootID string, includeDeleted bool) (*model.Draft, error) { + ret := _m.Called(userID, channelID, rootID, includeDeleted) var r0 *model.Draft - if rf, ok := ret.Get(0).(func(string, string, string) *model.Draft); ok { - r0 = rf(userID, channelID, rootID) + if rf, ok := ret.Get(0).(func(string, string, string, bool) *model.Draft); ok { + r0 = rf(userID, channelID, rootID, includeDeleted) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Draft) @@ -42,8 +42,8 @@ func (_m *DraftStore) Get(userID string, channelID string, rootID string) (*mode } var r1 error - if rf, ok := ret.Get(1).(func(string, string, string) error); ok { - r1 = rf(userID, channelID, rootID) + if rf, ok := ret.Get(1).(func(string, string, string, bool) error); ok { + r1 = rf(userID, channelID, rootID, includeDeleted) } else { r1 = ret.Error(1) } diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index da4503ae92..c4c89b935c 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -2996,10 +2996,10 @@ func (s *TimerLayerDraftStore) Delete(userID string, channelID string, rootID st return err } -func (s *TimerLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { +func (s *TimerLayerDraftStore) Get(userID string, channelID string, rootID string, includeDeleted bool) (*model.Draft, error) { start := time.Now() - result, err := s.DraftStore.Get(userID, channelID, rootID) + result, err := s.DraftStore.Get(userID, channelID, rootID, includeDeleted) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { From 3e928f57fbb6b3601a3105ebc8b8b4dff8eb8e7e Mon Sep 17 00:00:00 2001 From: Julien Tant <785518+JulienTant@users.noreply.github.com> Date: Tue, 29 Nov 2022 14:53:03 -0700 Subject: [PATCH 46/80] [MM-48031] Work template read API (#21661) --- .circleci/config.yml | 31 ++ Makefile | 3 + api4/api.go | 5 + api4/worktemplates.go | 67 ++++ api4/worktemplates_test.go | 110 ++++++ app/app_iface.go | 2 + app/opentracing/opentracing_layer.go | 44 +++ app/worktemplates.go | 52 +++ app/worktemplates/categories.yaml | 2 + app/worktemplates/generator/main.go | 154 ++++++++ app/worktemplates/generator/worktemplate.tmpl | 101 ++++++ app/worktemplates/templates.yaml | 46 +++ app/worktemplates/types.go | 342 ++++++++++++++++++ app/worktemplates/worktemplate_generated.go | 104 ++++++ app/worktemplates/worktemplates.go | 37 ++ app/worktemplates_test.go | 125 +++++++ go.mod | 2 +- i18n/en.json | 28 ++ model/client4.go | 31 ++ model/worktemplate.go | 71 ++++ 20 files changed, 1356 insertions(+), 1 deletion(-) create mode 100644 api4/worktemplates.go create mode 100644 api4/worktemplates_test.go create mode 100644 app/worktemplates.go create mode 100644 app/worktemplates/categories.yaml create mode 100644 app/worktemplates/generator/main.go create mode 100644 app/worktemplates/generator/worktemplate.tmpl create mode 100644 app/worktemplates/templates.yaml create mode 100644 app/worktemplates/types.go create mode 100644 app/worktemplates/worktemplate_generated.go create mode 100644 app/worktemplates/worktemplates.go create mode 100644 app/worktemplates_test.go create mode 100644 model/worktemplate.go diff --git a/.circleci/config.yml b/.circleci/config.yml index 4ca2fcb0f1..de7cc6f0ea 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -110,6 +110,20 @@ jobs: cd mattermost-server make app-layers if [[ -n $(git status --porcelain) ]]; then echo "Please update the app layers using make app-layers"; exit 1; fi + + check-generate-worktemplates: + docker: + - image: cimg/go:1.18 + resource_class: medium + working_directory: /mnt/ramdisk + steps: + - attach_workspace: + at: /mnt/ramdisk + - run: + command: | + cd mattermost-server + make generate-worktemplates + if [[ -n $(git status --porcelain) ]]; then echo "Please update the worktemplates using make generate-worktemplates"; exit 1; fi check-go-mod-tidy: docker: - image: cimg/go:1.18 @@ -501,6 +515,9 @@ workflows: - check-app-layers: requires: - setup-multi-product-repositories + - check-generate-worktemplates: + requires: + - setup-multi-product-repositories - check-store-layers: requires: - setup-multi-product-repositories @@ -527,6 +544,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -541,6 +559,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -558,6 +577,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -576,6 +596,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -594,6 +615,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -608,6 +630,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -660,6 +683,9 @@ workflows: - check-app-layers: requires: - setup-multi-product-repositories + - check-generate-worktemplates: + requires: + - setup-multi-product-repositories - check-store-layers: requires: - setup-multi-product-repositories @@ -690,6 +716,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -704,6 +731,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -721,6 +749,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -737,6 +766,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -753,6 +783,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates diff --git a/Makefile b/Makefile index 1eed321800..d0eb7575a1 100644 --- a/Makefile +++ b/Makefile @@ -349,6 +349,9 @@ telemetry-mocks: ## Creates mock files. store-layers: ## Generate layers for the store $(GO) generate $(GOFLAGS) ./store +generate-worktemplates: ## Generate work templates + $(GO) generate $(GOFLAGS) ./app/worktemplates + new-migration: ## Creates a new migration. Run with make new-migration name=<> $(GO) install github.com/mattermost/morph/cmd/morph@master @echo "Generating new migration for mysql" diff --git a/api4/api.go b/api4/api.go index d94e2d450b..b50f119e19 100644 --- a/api4/api.go +++ b/api4/api.go @@ -140,6 +140,8 @@ type Routes struct { Usage *mux.Router // 'api/v4/usage' + WorkTemplates *mux.Router // 'api/v4/worktemplates' + HostedCustomer *mux.Router // 'api/v4/hosted_customer' Drafts *mux.Router // 'api/v4/drafts' @@ -269,6 +271,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter() + api.BaseRoutes.WorkTemplates = api.BaseRoutes.APIRoot.PathPrefix("/worktemplates").Subrouter() + api.BaseRoutes.HostedCustomer = api.BaseRoutes.APIRoot.PathPrefix("/hosted_customer").Subrouter() api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter() @@ -316,6 +320,7 @@ func Init(srv *app.Server) (*API, error) { api.InitExport() api.InitInsights() api.InitUsage() + api.InitWorkTemplate() api.InitHostedCustomer() api.InitDrafts() if err := api.InitGraphQL(); err != nil { diff --git a/api4/worktemplates.go b/api4/worktemplates.go new file mode 100644 index 0000000000..37af4bfdfb --- /dev/null +++ b/api4/worktemplates.go @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func (api *API) InitWorkTemplate() { + api.BaseRoutes.WorkTemplates.Handle("/categories", api.APISessionRequired(needsWorkTemplateFeatureFlag(getWorkTemplateCategories))).Methods("GET") + api.BaseRoutes.WorkTemplates.Handle("/categories/{category}/templates", api.APISessionRequired(needsWorkTemplateFeatureFlag(getWorkTemplates))).Methods("GET") +} + +func needsWorkTemplateFeatureFlag(h handlerFunc) handlerFunc { + return func(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.WorkTemplate { + http.NotFound(w, r) + return + } + + h(c, w, r) + } +} + +func getWorkTemplateCategories(c *Context, w http.ResponseWriter, r *http.Request) { + t := c.AppContext.GetT() + + categories, appErr := c.App.GetWorkTemplateCategories(t) + if appErr != nil { + c.Err = appErr + return + } + + b, err := json.Marshal(categories) + if err != nil { + c.Err = model.NewAppError("getWorkTemplateCategories", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func getWorkTemplates(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireCategory() + if c.Err != nil { + return + } + t := c.AppContext.GetT() + + workTemplates, appErr := c.App.GetWorkTemplates(c.Params.Category, c.App.Config().FeatureFlags.ToMap(), t) + if appErr != nil { + c.Err = appErr + return + } + + b, err := json.Marshal(workTemplates) + if err != nil { + c.Err = model.NewAppError("getWorkTemplates", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} diff --git a/api4/worktemplates_test.go b/api4/worktemplates_test.go new file mode 100644 index 0000000000..6f919d321c --- /dev/null +++ b/api4/worktemplates_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/stretchr/testify/require" +) + +func TestWorkTemplateCategories(t *testing.T) { + // Setup + cleanup := setupWorktemplateFeatureFlag(t) + defer cleanup() + + th := Setup(t).InitBasic() + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = []*worktemplates.WorkTemplateCategory{ + { + ID: "test-category", + Name: "Test Category", + }, + { + ID: "test-category-2", + Name: "Test Category 2", + }, + } + + // Act + categories, _, clientErr := th.Client.GetWorktemplateCategories() + + // Assert + require.NoError(t, clientErr) + require.Len(t, categories, 2) + assert.Equal("test-category", categories[0].ID) + assert.Equal("test-category-2", categories[1].ID) +} + +func TestGetWorkTemplatesByCategory(t *testing.T) { + // Setup + cleanup := setupWorktemplateFeatureFlag(t) + defer cleanup() + + th := Setup(t).InitBasic() + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = []*worktemplates.WorkTemplateCategory{ + { + ID: "test-category", + Name: "Test Category", + }, + { + ID: "test-category-2", + Name: "Test Category 2", + }, + } + + worktemplates.OrderedWorkTemplates = []*worktemplates.WorkTemplate{ + { + ID: "test-template", + Category: "test-category", + UseCase: "Test Template", + }, + { + ID: "test-template-2", + Category: "test-category", + UseCase: "Test Template 2", + }, + { // This one should not be returned because of the feature flag + ID: "test-template-3", + Category: "test-category", + UseCase: "Test Template 3", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: "random-nonexistant-feature-flag", + Value: "true", + }, + }, + { // this one should not be returned because of the category + ID: "test-template-4", + Category: "test-category-2", + UseCase: "Test Template 4", + }, + } + + // Act + workTemplates, _, clientErr := th.Client.GetWorkTemplatesByCategory("test-category") + + // Assert + assert.NoError(clientErr, "error while retrieve worktemplates list") + assert.Len(workTemplates, 2) + assert.Equal("test-template", workTemplates[0].ID) + assert.Equal("test-template-2", workTemplates[1].ID) +} + +func setupWorktemplateFeatureFlag(t *testing.T) func() { + t.Helper() + + oldFFValue := os.Getenv("MM_FEATUREFLAGS_WORKTEMPLATE") + os.Setenv("MM_FEATUREFLAGS_WORKTEMPLATE", "true") + + return func() { + os.Setenv("MM_FEATUREFLAGS_WORKTEMPLATE", oldFFValue) + } +} diff --git a/app/app_iface.go b/app/app_iface.go index 0ce3e27e33..aca9f1e795 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -850,6 +850,8 @@ type AppIface interface { GetViewUsersRestrictions(userID string) (*model.ViewUsersRestrictions, *model.AppError) GetWarnMetricsBot() (*model.Bot, *model.AppError) GetWarnMetricsStatus() (map[string]*model.WarnMetricStatus, *model.AppError) + GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) + GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) HTTPService() httpservice.HTTPService Handle404(w http.ResponseWriter, r *http.Request) HandleCommandResponse(c request.CTX, command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.CommandResponse, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 311543fb78..35664d638c 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -11214,6 +11214,50 @@ func (a *OpenTracingAppLayer) GetWarnMetricsStatus() (map[string]*model.WarnMetr return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWorkTemplateCategories") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetWorkTemplateCategories(t) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWorkTemplates") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetWorkTemplates(category, featureFlags, t) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) Handle404(w http.ResponseWriter, r *http.Request) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.Handle404") diff --git a/app/worktemplates.go b/app/worktemplates.go new file mode 100644 index 0000000000..e52c05a7aa --- /dev/null +++ b/app/worktemplates.go @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/i18n" +) + +func (a *App) GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) { + categories, err := worktemplates.ListCategories() + if err != nil { + return nil, model.NewAppError("GetWorkTemplateCategories", "app.worktemplates.get_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + modelCategories := make([]*model.WorkTemplateCategory, len(categories)) + for i := range categories { + modelCategories[i] = &model.WorkTemplateCategory{ + ID: categories[i].ID, + Name: t(categories[i].Name), + } + } + + return modelCategories, nil +} + +func (a *App) GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) { + templates, err := worktemplates.ListByCategory(category) + if err != nil { + return nil, model.NewAppError("GetWorkTemplates", "app.worktemplates.get_templates.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // filter out templates that are not enabled by feature Flag + enabledTemplates := []*model.WorkTemplate{} + for _, template := range templates { + mTemplate := template.ToModelWorkTemplate(t) + if template.FeatureFlag == nil { + enabledTemplates = append(enabledTemplates, mTemplate) + continue + } + + if featureFlags[template.FeatureFlag.Name] == template.FeatureFlag.Value { + enabledTemplates = append(enabledTemplates, mTemplate) + } + } + + return enabledTemplates, nil +} diff --git a/app/worktemplates/categories.yaml b/app/worktemplates/categories.yaml new file mode 100644 index 0000000000..ab76f31f1a --- /dev/null +++ b/app/worktemplates/categories.yaml @@ -0,0 +1,2 @@ +- id: product_teams + name: worktemplate.category.product_teams diff --git a/app/worktemplates/generator/main.go b/app/worktemplates/generator/main.go new file mode 100644 index 0000000000..14a447b09a --- /dev/null +++ b/app/worktemplates/generator/main.go @@ -0,0 +1,154 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "bytes" + "crypto/md5" + _ "embed" + "fmt" + "html/template" + "io" + "log" + "os" + "path" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/pkg/errors" + "golang.org/x/tools/imports" + "gopkg.in/yaml.v3" +) + +type WorkTemplateWithMD5 struct { + worktemplates.WorkTemplate + MD5 string +} + +type WorkTemplateCategoryWithMD5 struct { + worktemplates.WorkTemplateCategory `yaml:",inline"` + MD5 string +} + +func getFileContent(filename string) ([]byte, error) { + return os.ReadFile(path.Join(filename)) +} + +func main() { + // parse categories first + dat, err := getFileContent("categories.yaml") + if err != nil { + log.Fatal(errors.Wrap(err, "failed to read categories.yaml")) + } + + h := md5.New() + + cats := []WorkTemplateCategoryWithMD5{} // meow + err = yaml.Unmarshal(dat, &cats) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to unmarshal categories.yaml")) + } + + // validate categories + categoryIds := map[string]struct{}{} + for id := range cats { + cat := cats[id] + + if cat.ID == "" && cat.Name == "" { + // skip empty array element + continue + } + + if cat.ID == "" { + log.Fatal(errors.New("category ID cannot be empty")) + } + if cat.Name == "" { + log.Fatal(errors.New("category name cannot be empty")) + } + categoryIds[cat.ID] = struct{}{} + + h.Write([]byte(cat.ID)) + cats[id].MD5 = fmt.Sprintf("%x", h.Sum(nil)) + h.Reset() + } + + dat, err = getFileContent("templates.yaml") + if err != nil { + log.Fatal(errors.Wrap(err, "failed to read templates.yaml")) + } + + dec := yaml.NewDecoder(bytes.NewReader(dat)) + ts := []WorkTemplateWithMD5{} + for { + t := worktemplates.WorkTemplate{} + err = dec.Decode(&t) + if err != nil { + if err == io.EOF { + break + } + log.Fatal(err) + } + if t.ID == "" { + continue + } + + h.Write([]byte(t.ID)) + err = t.Validate(categoryIds) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to validate template")) + } + + ts = append(ts, WorkTemplateWithMD5{ + WorkTemplate: t, + MD5: fmt.Sprintf("%x", h.Sum(nil)), + }) + h.Reset() + } + + code := bytes.NewBuffer(nil) + tmpl, err := template.New("worktemplates").Parse(tpl) + if err != nil { + log.Fatal(err) + } + tmpl.Execute(code, struct { + Templates []WorkTemplateWithMD5 + Categories []WorkTemplateCategoryWithMD5 + }{ + Templates: ts, + Categories: cats, + }) + + formattedCode, err := imports.Process(path.Join("worktemplate_generated.go"), code.Bytes(), &imports.Options{Comments: true}) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to format code")) + } + + err = os.WriteFile(path.Join("worktemplate_generated.go"), formattedCode, 0644) + if err != nil { + log.Fatal(err) + } + + // print all translatable content + fmt.Println("\nTranslation helpers:\n====================") + for _, t := range ts { + translationHelper(t.Description.Channel) + translationHelper(t.Description.Board) + translationHelper(t.Description.Playbook) + translationHelper(t.Description.Integration) + } +} + +var translationHelperTemplate = `{ + "id": %q, + "translation": %q +},` + +func translationHelper(t *worktemplates.TranslatableString) { + if t != nil && t.ID != "" && t.DefaultMessage != "" { + fmt.Printf(translationHelperTemplate, t.ID, t.DefaultMessage) + fmt.Println("") + } +} + +//go:embed worktemplate.tmpl +var tpl string diff --git a/app/worktemplates/generator/worktemplate.tmpl b/app/worktemplates/generator/worktemplate.tmpl new file mode 100644 index 0000000000..60f40b5301 --- /dev/null +++ b/app/worktemplates/generator/worktemplate.tmpl @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Code generated by "make generate-worktemplates" +// DO NOT EDIT + +package worktemplates + +func init() { + {{- range .Categories}} + registerWorkTemplateCategory("{{.ID}}", wtc{{.MD5}}) + {{- end -}} + {{range .Templates}} + registerWorkTemplate("{{.ID}}", wt{{.MD5}}) + {{- end}} + + // Register categories strings + {{range .Categories -}} + _ = T("{{.Name}}") + {{end}} + + // Register translation strings + {{range .Templates -}} + {{if and (.Description.Channel) (ne .Description.Channel.ID "")}}_ = T("{{.Description.Channel.ID}}") + {{end -}} + {{if and (.Description.Board) (ne .Description.Board.ID "")}}_ = T("{{.Description.Board.ID}}") + {{end -}} + {{if and (.Description.Playbook) (ne .Description.Playbook.ID "")}}_ = T("{{.Description.Playbook.ID}}") + {{end -}} + {{if and (.Description.Integration) (ne .Description.Integration.ID "")}}_ = T("{{.Description.Integration.ID}}") + {{end -}} + {{end -}} +} + +{{range .Categories}} +var wtc{{.MD5}} = &WorkTemplateCategory{ + ID: "{{.ID}}", + Name: "{{.Name}}", +} +{{end}} + +{{range .Templates}} +var wt{{.MD5}} = &WorkTemplate{ + ID: "{{.ID}}", + Category: "{{.Category}}", + UseCase: "{{.UseCase}}", + Illustration: "{{.Illustration}}", + Visibility: "{{.Visibility}}", + {{if .FeatureFlag}}FeatureFlag: &FeatureFlag{ + Name: "{{.FeatureFlag.Name}}", + Value: "{{.FeatureFlag.Value}}", + },{{end}} + Description: Description{ + {{if .Description.Channel}}Channel: &TranslatableString{ + ID: "{{.Description.Channel.ID}}", + DefaultMessage: "{{.Description.Channel.DefaultMessage}}", + Illustration: "{{.Description.Channel.Illustration}}", + },{{end}} + {{if .Description.Board}}Board: &TranslatableString{ + ID: "{{.Description.Board.ID}}", + DefaultMessage: "{{.Description.Board.DefaultMessage}}", + Illustration: "{{.Description.Board.Illustration}}", + },{{end}} + {{if .Description.Playbook}}Playbook: &TranslatableString{ + ID: "{{.Description.Playbook.ID}}", + DefaultMessage: "{{.Description.Playbook.DefaultMessage}}", + Illustration: "{{.Description.Playbook.Illustration}}", + },{{end}} + {{if .Description.Integration}}Integration: &TranslatableString{ + ID: "{{.Description.Integration.ID}}", + DefaultMessage: "{{.Description.Integration.DefaultMessage}}", + Illustration: "{{.Description.Integration.Illustration}}", + },{{end}} + }, + Content: []Content{ + {{range .Content}}{ + {{if .Channel}}Channel: &Channel{ + ID: "{{.Channel.ID}}", + Name: "{{.Channel.Name}}", + Purpose: "{{.Channel.Purpose}}", + Playbook: "{{.Channel.Playbook}}", + Illustration: "{{.Channel.Illustration}}", + },{{end}}{{if .Board}}Board: &Board{ + ID: "{{.Board.ID}}", + Template: "{{.Board.Template}}", + Name: "{{.Board.Name}}", + Channel: "{{.Board.Channel}}", + Illustration: "{{.Board.Illustration}}", + },{{end}}{{if .Playbook}}Playbook: &Playbook{ + Template: "{{.Playbook.Template}}", + Name: "{{.Playbook.Name}}", + ID: "{{.Playbook.ID}}", + Illustration: "{{.Playbook.Illustration}}", + },{{end}}{{if .Integration}}Integration: &Integration{ + ID: "{{.Integration.ID}}", + },{{end}} + }, + {{end}} + }, +} +{{end}} diff --git a/app/worktemplates/templates.yaml b/app/worktemplates/templates.yaml new file mode 100644 index 0000000000..bf188cae73 --- /dev/null +++ b/app/worktemplates/templates.yaml @@ -0,0 +1,46 @@ +id: "product_teams/feature_release:v1" +category: product_teams +useCase: Feature Release +illustration: https://via.placeholder.com/204x123.png +visibility: public +description: + channel: + id: "worktemplate.product_teams.feature_release.description.channel" + defaultMessage: "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots." + board: + id: "worktemplate.product_teams.feature_release.description.board" + defaultMessage: "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way." + playbook: + id: "worktemplate.product_teams.feature_release.description.playbook" + defaultMessage: "Create transparent workflows across development teams to ensure your feature development process is seamless." + integration: + id: "worktemplate.product_teams.feature_release.description.integration" + defaultMessage: "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you." + illustration: "https://via.placeholder.com/509x352.png?text=Integrations" +content: + - channel: + id: feature-release + name: Feature Release + playbook: product-release-playbook # playbook id. if set the channel will be created by the playbook run. + illustration: "https://via.placeholder.com/509x352.png?text=Channel+feature+release" + - board: + id: "board-meeting-agenda" + template: "meeting agenda|bwps66irhr7b9dxgayf9kz33g5o" # <-- have to find a way to target the board template... could hardcode the ids but need to verify that they don't change? + name: Meeting Agenda + channel: feature-release # <-- optional. we use the channel "id" from above + illustration: "https://via.placeholder.com/509x352.png?text=Board+meeting+agenda" + - board: + id: "board-project-task" + template: "project task|bmttiziw35irgtmztewd9upyqdy" + name: project task board + channel: feature-release + illustration: "https://via.placeholder.com/509x352.png?text=Board+project+task" + - playbook: + template: "product release" # <-- playbooks templates don't have ids, have to rely on name + name: "Feature release" + id: product-release-playbook + illustration: "https://via.placeholder.com/509x352.png?text=Playbook+feature+release" + - integration: + id: jira + - integration: + id: github diff --git a/app/worktemplates/types.go b/app/worktemplates/types.go new file mode 100644 index 0000000000..162f569053 --- /dev/null +++ b/app/worktemplates/types.go @@ -0,0 +1,342 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package worktemplates + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/i18n" + "github.com/pkg/errors" +) + +type WorkTemplateCategory struct { + ID string `yaml:"id"` + Name string `yaml:"name"` +} + +type WorkTemplate struct { + ID string `yaml:"id"` + Category string `yaml:"category"` + UseCase string `yaml:"useCase"` + Illustration string `yaml:"illustration"` + Visibility string `yaml:"visibility"` + FeatureFlag *FeatureFlag `yaml:"featureFlag,omitempty"` + Description Description `yaml:"description"` + Content []Content `yaml:"content"` +} + +func (wt WorkTemplate) ToModelWorkTemplate(t i18n.TranslateFunc) *model.WorkTemplate { + mwt := &model.WorkTemplate{ + ID: wt.ID, + Category: wt.Category, + UseCase: wt.UseCase, + Illustration: wt.Illustration, + Visibility: wt.Visibility, + } + + if wt.FeatureFlag != nil { + mwt.FeatureFlag = &model.WorkTemplateFeatureFlag{ + Name: wt.FeatureFlag.Name, + Value: wt.FeatureFlag.Value, + } + } + + if wt.Description.Channel != nil { + mwt.Description.Channel = &model.DescriptionContent{ + Message: wt.Description.Channel.Translate(t), + Illustration: wt.Description.Channel.Illustration, + } + } + + if wt.Description.Board != nil { + mwt.Description.Board = &model.DescriptionContent{ + Message: wt.Description.Board.Translate(t), + Illustration: wt.Description.Board.Illustration, + } + } + + if wt.Description.Playbook != nil { + mwt.Description.Playbook = &model.DescriptionContent{ + Message: wt.Description.Playbook.Translate(t), + Illustration: wt.Description.Playbook.Illustration, + } + } + + if wt.Description.Integration != nil { + mwt.Description.Integration = &model.DescriptionContent{ + Message: wt.Description.Integration.Translate(t), + Illustration: wt.Description.Integration.Illustration, + } + } + + for _, content := range wt.Content { + if content.Channel != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Channel: &model.WorkTemplateChannel{ + ID: content.Channel.ID, + Name: content.Channel.Name, + Purpose: content.Channel.Purpose, + Playbook: content.Channel.Playbook, + Illustration: content.Channel.Illustration, + }, + }) + } + if content.Board != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Board: &model.WorkTemplateBoard{ + ID: content.Board.ID, + Name: content.Board.Name, + Template: content.Board.Template, + Channel: content.Board.Channel, + Illustration: content.Board.Illustration, + }, + }) + } + if content.Playbook != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Playbook: &model.WorkTemplatePlaybook{ + ID: content.Playbook.ID, + Name: content.Playbook.Name, + Template: content.Playbook.Template, + Illustration: content.Playbook.Illustration, + }, + }) + } + if content.Integration != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Integration: &model.WorkTemplateIntegration{ + ID: content.Integration.ID, + }, + }) + } + } + + return mwt +} + +func (wt WorkTemplate) Validate(categoryIds map[string]struct{}) error { + if wt.ID == "" { + return errors.New("id is required") + } + if wt.Category == "" { + return errors.New("category is required") + } + if _, ok := categoryIds[wt.Category]; !ok { + return fmt.Errorf("category %s does not exist", wt.Category) + } + if wt.UseCase == "" { + return errors.New("useCase is required") + } + if wt.Illustration == "" { + return errors.New("illustration is required") + } + if wt.Visibility == "" { + return errors.New("visibility is required") + } + hasChannel := false + hasBoard := false + hasPlaybook := false + hasIntegration := false + foundChannels := map[string]struct{}{} + foundPlaybooks := map[string]struct{}{} + foundBoards := map[string]struct{}{} + foundIntegrations := map[string]struct{}{} + mustHaveChannels := []string{} + mustHavePlaybooks := []string{} + + currentIdx := 0 + for _, content := range wt.Content { + if content.Channel != nil { + hasChannel = true + if cErr := content.Channel.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundChannels[content.Channel.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate channel %s found", content.Channel.ID), currentIdx) + } + foundChannels[content.Channel.ID] = struct{}{} + + if content.Channel.Playbook != "" { + mustHavePlaybooks = append(mustHavePlaybooks, content.Channel.Playbook) + } + } + + if content.Board != nil { + hasBoard = true + if cErr := content.Board.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundBoards[content.Board.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate board %s found", content.Board.ID), currentIdx) + } + foundBoards[content.Board.ID] = struct{}{} + + if content.Board.Channel != "" { + mustHaveChannels = append(mustHaveChannels, content.Board.Channel) + } + } + if content.Playbook != nil { + hasPlaybook = true + if cErr := content.Playbook.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundPlaybooks[content.Playbook.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate playbook %s found", content.Playbook.ID), currentIdx) + } + foundPlaybooks[content.Playbook.ID] = struct{}{} + } + if content.Integration != nil { + hasIntegration = true + if cErr := content.Integration.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundIntegrations[content.Integration.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate integration %s found", content.Integration.ID), currentIdx) + } + foundIntegrations[content.Integration.ID] = struct{}{} + } + } + + if hasChannel && wt.Description.Channel == nil { + return errors.New("description.channel is required") + } + if hasBoard && wt.Description.Board == nil { + return errors.New("description.board is required") + } + if hasPlaybook && wt.Description.Playbook == nil { + return errors.New("description.playbook is required") + } + if hasIntegration && wt.Description.Integration == nil { + return errors.New("description.integration is required") + } + + for _, channel := range mustHaveChannels { + if _, ok := foundChannels[channel]; !ok { + return fmt.Errorf("channel %s is required", channel) + } + } + + for _, playbook := range mustHavePlaybooks { + if _, ok := foundPlaybooks[playbook]; !ok { + return fmt.Errorf("playbook %s is required", playbook) + } + } + + return nil +} + +type FeatureFlag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type TranslatableString struct { + ID string `yaml:"id"` + DefaultMessage string `yaml:"defaultMessage"` + Illustration string `yaml:"illustration"` +} + +func (ts TranslatableString) Translate(t i18n.TranslateFunc) string { + if ts.ID != "" { + msg := t(ts.ID) + if msg != ts.ID && msg != "" { + return msg + } + } + + return ts.DefaultMessage +} + +type Description struct { + Channel *TranslatableString `yaml:"channel"` + Board *TranslatableString `yaml:"board"` + Playbook *TranslatableString `yaml:"playbook"` + Integration *TranslatableString `yaml:"integration"` +} + +type Channel struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Purpose string `yaml:"purpose"` + Playbook string `yaml:"playbook"` + Illustration string `yaml:"illustration"` +} + +func (c *Channel) Validate() error { + if c.ID == "" { + return errors.New("id is required") + } + if c.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Board struct { + ID string `yaml:"id"` + Template string `yaml:"template"` + Name string `yaml:"name"` + Channel string `yaml:"channel"` + Illustration string `yaml:"illustration"` +} + +func (b Board) Validate() error { + if b.ID == "" { + return errors.New("id is required") + } + if b.Template == "" { + return errors.New("template is required") + } + if b.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Playbook struct { + Template string `yaml:"template"` + Name string `yaml:"name"` + ID string `yaml:"id"` + Illustration string `yaml:"illustration"` +} + +func (p *Playbook) Validate() error { + if p.ID == "" { + return errors.New("id is required") + } + if p.Template == "" { + return errors.New("template is required") + } + if p.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Integration struct { + ID string `yaml:"id"` +} + +func (i *Integration) Validate() error { + if i.ID == "" { + return errors.New("id is required") + } + + return nil +} + +type Content struct { + Channel *Channel `yaml:"channel,omitempty"` + Board *Board `yaml:"board,omitempty"` + Playbook *Playbook `yaml:"playbook,omitempty"` + Integration *Integration `yaml:"integration,omitempty"` +} + +func wrapContentError(err error, index int) error { + return errors.Wrapf(err, "content #%d validation failed", index) +} diff --git a/app/worktemplates/worktemplate_generated.go b/app/worktemplates/worktemplate_generated.go new file mode 100644 index 0000000000..77bd419653 --- /dev/null +++ b/app/worktemplates/worktemplate_generated.go @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Code generated by "make generate-worktemplates" +// DO NOT EDIT + +package worktemplates + +func init() { + registerWorkTemplateCategory("product_teams", wtc846b565cd80043537945134a54812e07) + registerWorkTemplate("product_teams/feature_release:v1", wt00a1b44a5831c0a3acb14787b3fdd352) + + // Register categories strings + _ = T("worktemplate.category.product_teams") + + // Register translation strings + _ = T("worktemplate.product_teams.feature_release.description.channel") + _ = T("worktemplate.product_teams.feature_release.description.board") + _ = T("worktemplate.product_teams.feature_release.description.playbook") + _ = T("worktemplate.product_teams.feature_release.description.integration") +} + +var wtc846b565cd80043537945134a54812e07 = &WorkTemplateCategory{ + ID: "product_teams", + Name: "worktemplate.category.product_teams", +} + +var wt00a1b44a5831c0a3acb14787b3fdd352 = &WorkTemplate{ + ID: "product_teams/feature_release:v1", + Category: "product_teams", + UseCase: "Feature Release", + Illustration: "https://via.placeholder.com/204x123.png", + Visibility: "public", + + Description: Description{ + Channel: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.channel", + DefaultMessage: "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots.", + Illustration: "", + }, + Board: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.board", + DefaultMessage: "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way.", + Illustration: "", + }, + Playbook: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.playbook", + DefaultMessage: "Create transparent workflows across development teams to ensure your feature development process is seamless.", + Illustration: "", + }, + Integration: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.integration", + DefaultMessage: "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you.", + Illustration: "https://via.placeholder.com/509x352.png?text=Integrations", + }, + }, + Content: []Content{ + { + Channel: &Channel{ + ID: "feature-release", + Name: "Feature Release", + Purpose: "", + Playbook: "product-release-playbook", + Illustration: "https://via.placeholder.com/509x352.png?text=Channel+feature+release", + }, + }, + { + Board: &Board{ + ID: "board-meeting-agenda", + Template: "meeting agenda|bwps66irhr7b9dxgayf9kz33g5o", + Name: "Meeting Agenda", + Channel: "feature-release", + Illustration: "https://via.placeholder.com/509x352.png?text=Board+meeting+agenda", + }, + }, + { + Board: &Board{ + ID: "board-project-task", + Template: "project task|bmttiziw35irgtmztewd9upyqdy", + Name: "project task board", + Channel: "feature-release", + Illustration: "https://via.placeholder.com/509x352.png?text=Board+project+task", + }, + }, + { + Playbook: &Playbook{ + Template: "product release", + Name: "Feature release", + ID: "product-release-playbook", + Illustration: "https://via.placeholder.com/509x352.png?text=Playbook+feature+release", + }, + }, + { + Integration: &Integration{ + ID: "jira", + }, + }, + { + Integration: &Integration{ + ID: "github", + }, + }, + }, +} diff --git a/app/worktemplates/worktemplates.go b/app/worktemplates/worktemplates.go new file mode 100644 index 0000000000..44e7931844 --- /dev/null +++ b/app/worktemplates/worktemplates.go @@ -0,0 +1,37 @@ +//go:generate go run generator/main.go + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package worktemplates + +var OrderedWorkTemplates = []*WorkTemplate{} +var OrderedWorkTemplateCategories = []*WorkTemplateCategory{} + +// T is a placeholder to allow the translation tool to register the strings +func T(id string) string { + return id +} + +func registerWorkTemplate(id string, wt *WorkTemplate) { + OrderedWorkTemplates = append(OrderedWorkTemplates, wt) +} + +func registerWorkTemplateCategory(id string, wtc *WorkTemplateCategory) { + OrderedWorkTemplateCategories = append(OrderedWorkTemplateCategories, wtc) +} + +func ListCategories() ([]*WorkTemplateCategory, error) { + return OrderedWorkTemplateCategories, nil +} + +func ListByCategory(category string) ([]*WorkTemplate, error) { + wts := []*WorkTemplate{} + for i := range OrderedWorkTemplates { + if OrderedWorkTemplates[i].Category == category { + wts = append(wts, OrderedWorkTemplates[i]) + } + } + + return wts, nil +} diff --git a/app/worktemplates_test.go b/app/worktemplates_test.go new file mode 100644 index 0000000000..eb0342fa99 --- /dev/null +++ b/app/worktemplates_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" +) + +func TestGetWorkTemplateCategories(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = wtGetCategories() + + categories, appErr := th.App.GetWorkTemplateCategories(wtTranslationFunc) + assert.Nil(appErr) + assert.Len(categories, 2) + assert.Equal("Translated test.1", categories[0].Name) + assert.Equal("Translated test.2", categories[1].Name) +} + +func TestGetWorkTemplatesByCategory(t *testing.T) { + // Setup + th := SetupWithStoreMock(t) + defer th.TearDown() + assert := require.New(t) + + existingFFkey := "test-feature-flag" + existingFFvalue := "true" + ff := map[string]string{ + existingFFkey: existingFFvalue, + } + + worktemplates.OrderedWorkTemplateCategories = wtGetCategories() + firstCat := worktemplates.OrderedWorkTemplateCategories[0] + worktemplates.OrderedWorkTemplates = []*worktemplates.WorkTemplate{ + { + ID: "test-template", + Category: firstCat.ID, + UseCase: "test use case", + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "test-template-channel-description", + DefaultMessage: "test template channel description", + }, + }, + }, + { // this one should not be returned because of the FF + ID: "test-template-2", + Category: firstCat.ID, + UseCase: "test use case 2", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: "nonexistant-random-test-feature-flag", + Value: "hi", + }, + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "test-template-2-channel-description", + DefaultMessage: "test template 2 channel description", + }, + }, + }, + { // this one should be present and match the FF + ID: "test-template-3", + Category: firstCat.ID, + UseCase: "test use case 3", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: existingFFkey, + Value: existingFFvalue, + }, + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "unknown", // simulating an unknown translation, we return the default message in this case + DefaultMessage: "default message picked for unknown", + }, + }, + }, + { // this one should not be returned because of the category + ID: "test-template-4", + Category: "cat-test2", + UseCase: "test use case 4", + }, + } + + // Act + worktemplates, appErr := th.App.GetWorkTemplates(firstCat.ID, ff, wtTranslationFunc) + + // Assert + assert.Nil(appErr) + assert.Len(worktemplates, 2) + // assert the correct work templates have been returned + assert.Equal("test-template", worktemplates[0].ID) + assert.Equal("test-template-3", worktemplates[1].ID) + // assert the descriptions have been translated + assert.Equal("Translated test-template-channel-description", worktemplates[0].Description.Channel.Message) + assert.Equal("default message picked for unknown", worktemplates[1].Description.Channel.Message) +} + +// helpers +func wtTranslationFunc(id string, args ...interface{}) string { + if id == "unknown" { + return "" + } + + return "Translated " + id +} + +func wtGetCategories() []*worktemplates.WorkTemplateCategory { + return []*worktemplates.WorkTemplateCategory{ + { + ID: "cat-test1", + Name: "test.1", + }, + { + ID: "cat-test2", + Name: "test.2", + }, + } +} diff --git a/go.mod b/go.mod index ad8a4fbb28..c157155e51 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( golang.org/x/tools v0.3.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -178,7 +179,6 @@ require ( gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.1.1 // indirect modernc.org/cc/v3 v3.36.0 // indirect modernc.org/ccgo/v3 v3.16.6 // indirect diff --git a/i18n/en.json b/i18n/en.json index 240aa7aac1..91c3351aaf 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -6991,6 +6991,14 @@ "id": "app.webhooks.update_outgoing.app_error", "translation": "Unable to update the webhook." }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "Unable to get work template categories" + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "Unable to get work templates" + }, { "id": "bleveengine.already_started.error", "translation": "Bleve is already started." @@ -9686,5 +9694,25 @@ { "id": "web.incoming_webhook.user.app_error", "translation": "Couldn't find the user." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Product Teams" + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you." + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Create transparent workflows across development teams to ensure your feature development process is seamless." } ] diff --git a/model/client4.go b/model/client4.go index 6d12fbd916..794969c0d6 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8557,3 +8557,34 @@ func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) { defer closeBody(r) return BuildResponse(r), nil } + +// Worktemplates sections + +func (c *Client4) worktemplatesRoute() string { + return "/worktemplates" +} + +// GetWorktemplateCategories returns categories of worktemplates +func (c *Client4) GetWorktemplateCategories() ([]*WorkTemplateCategory, *Response, error) { + r, err := c.DoAPIGet(c.worktemplatesRoute()+"/categories", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var categories []*WorkTemplateCategory + err = json.NewDecoder(r.Body).Decode(&categories) + return categories, BuildResponse(r), err +} + +func (c *Client4) GetWorkTemplatesByCategory(category string) ([]*WorkTemplate, *Response, error) { + r, err := c.DoAPIGet(c.worktemplatesRoute()+"/categories/"+category+"/templates", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var templates []*WorkTemplate + err = json.NewDecoder(r.Body).Decode(&templates) + return templates, BuildResponse(r), err +} diff --git a/model/worktemplate.go b/model/worktemplate.go new file mode 100644 index 0000000000..0e8add822b --- /dev/null +++ b/model/worktemplate.go @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type WorkTemplateCategory struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type WorkTemplate struct { + ID string `json:"id"` + Category string `json:"category"` + UseCase string `json:"useCase"` + Illustration string `json:"illustration"` + Visibility string `json:"visibility"` + FeatureFlag *WorkTemplateFeatureFlag `json:"featureFlag,omitempty"` + Description Description `json:"description"` + Content []WorkTemplateContent `json:"content"` +} + +type WorkTemplateFeatureFlag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type DescriptionContent struct { + Message string `json:"message"` + Illustration string `json:"illustration"` +} + +type Description struct { + Channel *DescriptionContent `json:"channel"` + Board *DescriptionContent `json:"board"` + Playbook *DescriptionContent `json:"playbook"` + Integration *DescriptionContent `json:"integration"` +} + +type WorkTemplateChannel struct { + ID string `json:"id"` + Name string `json:"name"` + Purpose string `json:"purpose"` + Playbook string `json:"playbook"` + Illustration string `json:"illustration"` +} + +type WorkTemplateBoard struct { + ID string `json:"id"` + Template string `json:"template"` + Name string `json:"name"` + Channel string `json:"channel"` + Illustration string `json:"illustration"` +} + +type WorkTemplatePlaybook struct { + Template string `json:"template"` + Name string `json:"name"` + ID string `json:"id"` + Illustration string `json:"illustration"` +} + +type WorkTemplateIntegration struct { + ID string `json:"id"` +} + +type WorkTemplateContent struct { + Channel *WorkTemplateChannel `json:"channel,omitempty"` + Board *WorkTemplateBoard `json:"board,omitempty"` + Playbook *WorkTemplatePlaybook `json:"playbook,omitempty"` + Integration *WorkTemplateIntegration `json:"integration,omitempty"` +} From ed3f3fec46c33660a1b3031f4137f51ad5a09354 Mon Sep 17 00:00:00 2001 From: Shivashis Padhi Date: Wed, 30 Nov 2022 11:53:16 +0530 Subject: [PATCH 47/80] [MM-43850] Separate leave_team events for associated user and team (#21231) * Separate leave_team events for associated user and team * Remove unnecessary change of sending mode * Remove redundant get, set of message.broadcast Co-authored-by: Mattermod --- api4/team_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ app/teams/teams.go | 22 ++++++++++++++++++---- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/api4/team_test.go b/api4/team_test.go index c1eef6e6ac..4ffe1db335 100644 --- a/api4/team_test.go +++ b/api4/team_test.go @@ -2632,6 +2632,51 @@ func TestRemoveTeamMember(t *testing.T) { require.NoError(t, err) } +func TestRemoveTeamMemberEvents(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + client1 := th.CreateClient() + th.LoginBasicWithClient(client1) + WebSocketClient, err := th.CreateWebSocketClientWithClient(client1) + require.NoError(t, err) + defer WebSocketClient.Close() + WebSocketClient.Listen() + resp := <-WebSocketClient.ResponseChannel + require.Equal(t, resp.Status, model.StatusOk) + + client2 := th.CreateClient() + th.LoginBasic2WithClient(client2) + WebSocketClient2, err := th.CreateWebSocketClientWithClient(client2) + require.NoError(t, err) + defer WebSocketClient2.Close() + WebSocketClient2.Listen() + resp = <-WebSocketClient2.ResponseChannel + require.Equal(t, resp.Status, model.StatusOk) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + // remove second user from basic team + _, err := client.RemoveTeamMember(th.BasicTeam.Id, th.BasicUser2.Id) + require.NoError(t, err) + + assertExpectedWebsocketEvent(t, WebSocketClient, model.WebsocketEventLeaveTeam, func(event *model.WebSocketEvent) { + eventUserId, ok := event.GetData()["user_id"].(string) + require.True(t, ok, "expected user") + // assert eventUser.Id is same as th.BasicUser.Id + assert.Equal(t, eventUserId, th.BasicUser2.Id) + // assert this event doesn't go to event creator + assert.Equal(t, event.GetBroadcast().OmitUsers[eventUserId], true) + }) + assertExpectedWebsocketEvent(t, WebSocketClient2, model.WebsocketEventLeaveTeam, func(event *model.WebSocketEvent) { + eventUserId, ok := event.GetData()["user_id"].(string) + require.True(t, ok, "expected user") + // assert eventUser.Id is same as th.BasicUser.Id + assert.Equal(t, eventUserId, th.BasicUser2.Id) + }) + }) + +} + func TestGetTeamStats(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/teams/teams.go b/app/teams/teams.go index 9b6db445b3..fb0143b35a 100644 --- a/app/teams/teams.go +++ b/app/teams/teams.go @@ -191,11 +191,25 @@ func (ts *TeamService) JoinUserToTeam(team *model.Team, user *model.User) (*mode // RemoveTeamMember removes the team member from the team. This method sends // the websocket message before actually removing so the user being removed gets it. func (ts *TeamService) RemoveTeamMember(teamMember *model.TeamMember) error { - message := model.NewWebSocketEvent(model.WebsocketEventLeaveTeam, teamMember.TeamId, "", "", nil, "") - message.Add("user_id", teamMember.UserId) - message.Add("team_id", teamMember.TeamId) - ts.wh.Publish(message) + /* + MM-43850: send leave_team event to user using `ReliableClusterSend` to improve safety + */ + // message for other team members + omitUsers := make(map[string]bool, 1) + omitUsers[teamMember.UserId] = true + messageTeam := model.NewWebSocketEvent(model.WebsocketEventLeaveTeam, teamMember.TeamId, "", "", omitUsers, "") + messageTeam.Add("user_id", teamMember.UserId) + messageTeam.Add("team_id", teamMember.TeamId) + ts.wh.Publish(messageTeam) + // message for teamMember.UserId + messageUser := model.NewWebSocketEvent(model.WebsocketEventLeaveTeam, "", "", teamMember.UserId, nil, "") + messageUser.Add("user_id", teamMember.UserId) + messageUser.Add("team_id", teamMember.TeamId) + + ts.wh.Publish(messageUser) + + // delete team member teamMember.Roles = "" teamMember.DeleteAt = model.GetMillis() From 966456567d55141c7a0bd2daa6d63a5b3da937e4 Mon Sep 17 00:00:00 2001 From: Allan Guwatudde Date: Wed, 30 Nov 2022 14:26:30 +0300 Subject: [PATCH 48/80] MM-47420 - Add endpoint to fetch selfhosted products (#21678) * created selfhosted endpoint * make autogenerated mocks * add test * add user id * remove user id requirement * add tests * send user id with request Co-authored-by: Mattermod --- api4/cloud.go | 36 ++++++++++ api4/cloud_test.go | 107 ++++++++++++++++++++++++++++ einterfaces/cloud.go | 1 + einterfaces/mocks/CloudInterface.go | 23 ++++++ model/client4.go | 13 ++++ 5 files changed, 180 insertions(+) diff --git a/api4/cloud.go b/api4/cloud.go index 3e38324b06..173d975279 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -22,6 +22,8 @@ func (api *API) InitCloud() { // GET /api/v4/cloud/limits api.BaseRoutes.Cloud.Handle("/limits", api.APISessionRequired(getCloudLimits)).Methods("GET") + api.BaseRoutes.Cloud.Handle("/products/selfhosted", api.APISessionRequired(getSelfHostedProducts)).Methods("GET") + // POST /api/v4/cloud/payment // POST /api/v4/cloud/payment/confirm api.BaseRoutes.Cloud.Handle("/payment", api.APISessionRequired(createCustomerPayment)).Methods("POST") @@ -276,6 +278,40 @@ func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.R } } +func getSelfHostedProducts(c *Context, w http.ResponseWriter, r *http.Request) { + products, err := c.App.Cloud().GetSelfHostedProducts(c.AppContext.Session().UserId) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + byteProductsData, err := json.Marshal(products) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) { + sanitizedProducts := []model.UserFacingProduct{} + err = json.Unmarshal(byteProductsData, &sanitizedProducts) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + byteSanitizedProductsData, err := json.Marshal(sanitizedProducts) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(byteSanitizedProductsData) + return + } + + w.Write(byteProductsData) +} + func getCloudProducts(c *Context, w http.ResponseWriter, r *http.Request) { if !c.App.Channels().License().IsCloud() { c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.license_error", nil, "", http.StatusForbidden) diff --git a/api4/cloud_test.go b/api4/cloud_test.go index 15ff7b1831..f44176656c 100644 --- a/api4/cloud_test.go +++ b/api4/cloud_test.go @@ -651,3 +651,110 @@ func TestGetCloudProducts(t *testing.T) { require.Equal(t, returnedProducts[2].CrossSellsTo, "prod_test2") }) } + +func TestGetSelfHostedProducts(t *testing.T) { + products := []*model.Product{ + { + ID: "prod_test", + Name: "Self-Hosted Professional", + Description: "Ideal for small companies and departments with data security requirements", + PricePerSeat: 10, + SKU: "professional", + PriceID: "price_1JPXbNI67GP2qpb4VuFdFbwQ", + Family: "on-prem", + RecurringInterval: model.RecurringIntervalYearly, + }, + { + ID: "prod_test2", + Name: "Self-Hosted Enterprise", + Description: "Built to scale for high-trust organizations and companies in regulated industries.", + PricePerSeat: 30, + SKU: "enterprise", + PriceID: "price_1JPXaVI67GP2qpb4l40bXyRu", + Family: "on-prem", + RecurringInterval: model.RecurringIntervalYearly, + }, + } + + sanitizedProducts := []*model.Product{ + { + ID: "prod_test", + Name: "Self-Hosted Professional", + PricePerSeat: 10, + SKU: "professional", + RecurringInterval: model.RecurringIntervalYearly, + }, + { + ID: "prod_test2", + Name: "Self-Hosted Enterprise", + PricePerSeat: 30, + SKU: "enterprise", + RecurringInterval: model.RecurringIntervalYearly, + }, + } + + t.Run("get products for admins", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + cloud := mocks.CloudInterface{} + cloud.Mock.On("GetSelfHostedProducts", mock.Anything, mock.Anything).Return(products, nil) + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + returnedProducts, r, err := th.Client.GetSelfHostedProducts() + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") + require.Equal(t, returnedProducts, products) + }) + + t.Run("get products for non admins", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + cloud := mocks.CloudInterface{} + + cloud.Mock.On("GetSelfHostedProducts", mock.Anything, mock.Anything).Return(products, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + returnedProducts, r, err := th.Client.GetSelfHostedProducts() + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") + require.Equal(t, returnedProducts, sanitizedProducts) + + // make a more explicit check + require.Equal(t, returnedProducts[0].ID, "prod_test") + require.Equal(t, returnedProducts[0].Name, "Self-Hosted Professional") + require.Equal(t, returnedProducts[0].SKU, "professional") + require.Equal(t, returnedProducts[0].PricePerSeat, float64(10)) + require.Equal(t, returnedProducts[0].Description, "") + require.Equal(t, returnedProducts[0].PriceID, "") + require.Equal(t, returnedProducts[0].Family, model.SubscriptionFamily("")) + require.Equal(t, returnedProducts[0].RecurringInterval, model.RecurringInterval("year")) + require.Equal(t, returnedProducts[0].BillingScheme, model.BillingScheme("")) + require.Equal(t, returnedProducts[0].CrossSellsTo, "") + + require.Equal(t, returnedProducts[1].ID, "prod_test2") + require.Equal(t, returnedProducts[1].Name, "Self-Hosted Enterprise") + require.Equal(t, returnedProducts[1].SKU, "enterprise") + require.Equal(t, returnedProducts[1].PricePerSeat, float64(30)) + require.Equal(t, returnedProducts[1].Description, "") + require.Equal(t, returnedProducts[1].PriceID, "") + require.Equal(t, returnedProducts[1].Family, model.SubscriptionFamily("")) + require.Equal(t, returnedProducts[1].RecurringInterval, model.RecurringInterval("year")) + require.Equal(t, returnedProducts[1].BillingScheme, model.BillingScheme("")) + require.Equal(t, returnedProducts[1].CrossSellsTo, "") + }) +} diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 8fa16ad023..46f5783225 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -10,6 +10,7 @@ import ( type CloudInterface interface { GetCloudProduct(userID string, productID string) (*model.Product, error) GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) + GetSelfHostedProducts(userID string) ([]*model.Product, error) GetCloudLimits(userID string) (*model.ProductLimits, error) CreateCustomerPayment(userID string) (*model.StripeSetupIntent, error) diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index 05b8fe86d7..141823c834 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -279,6 +279,29 @@ func (_m *CloudInterface) GetLicenseRenewalStatus(userID string, token string) e return r0 } +// GetSelfHostedProducts provides a mock function with given fields: userID +func (_m *CloudInterface) GetSelfHostedProducts(userID string) ([]*model.Product, error) { + ret := _m.Called(userID) + + var r0 []*model.Product + if rf, ok := ret.Get(0).(func(string) []*model.Product); ok { + r0 = rf(userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Product) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetSubscription provides a mock function with given fields: userID func (_m *CloudInterface) GetSubscription(userID string) (*model.Subscription, error) { ret := _m.Called(userID) diff --git a/model/client4.go b/model/client4.go index 794969c0d6..fded08a23d 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8041,6 +8041,19 @@ func (c *Client4) GetCloudProducts() ([]*Product, *Response, error) { return cloudProducts, BuildResponse(r), nil } +func (c *Client4) GetSelfHostedProducts() ([]*Product, *Response, error) { + r, err := c.DoAPIGet(c.cloudRoute()+"/products/selfhosted", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var products []*Product + json.NewDecoder(r.Body).Decode(&products) + + return products, BuildResponse(r), nil +} + func (c *Client4) GetProductLimits() (*ProductLimits, *Response, error) { r, err := c.DoAPIGet(c.cloudRoute()+"/limits", "") if err != nil { From ebaa1703389769d3178056d7265d7ebcfa87cd86 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Wed, 30 Nov 2022 15:47:51 +0300 Subject: [PATCH 49/80] docker-compose: include db driver type for the haserver env vars (#21771) --- docker-compose.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index d644331ae4..d83054b5c6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -103,6 +103,7 @@ services: dockerfile: ./build/Dockerfile.buildenv working_dir: '/home/mattermost-server' environment: + - "MM_SQLSETTINGS_DRIVERNAME=postgres" - "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10" - "MM_NO_DOCKER=true" - "RUN_SERVER_IN_BACKGROUND=false" @@ -141,6 +142,7 @@ services: dockerfile: ./build/Dockerfile.buildenv working_dir: '/home/mattermost-server' environment: + - "MM_SQLSETTINGS_DRIVERNAME=postgres" - "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10" - "MM_NO_DOCKER=true" - "RUN_SERVER_IN_BACKGROUND=false" @@ -179,6 +181,7 @@ services: dockerfile: ./build/Dockerfile.buildenv working_dir: '/home/mattermost-server' environment: + - "MM_SQLSETTINGS_DRIVERNAME=postgres" - "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10" - "MM_NO_DOCKER=true" - "RUN_SERVER_IN_BACKGROUND=false" From 070ee9a74de06810bf18975bc7101f38bccc6f8d Mon Sep 17 00:00:00 2001 From: Doug Lauder Date: Wed, 30 Nov 2022 09:36:01 -0500 Subject: [PATCH 50/80] use rolling-stable branch when fetching focalboard repo (#21769) --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index de7cc6f0ea..e452a6a2f4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,7 +31,7 @@ jobs: cd .. git clone --depth=1 --no-single-branch https://github.com/mattermost/focalboard.git cd focalboard - git checkout $CIRCLE_BRANCH || git checkout main + git checkout $CIRCLE_BRANCH || git checkout rolling-stable echo $(git rev-parse HEAD) cd ../mattermost-server make setup-go-work From 40921072622ff7227011223c7e64174aded17ee6 Mon Sep 17 00:00:00 2001 From: Muhammad S <841955+mhd-sln@users.noreply.github.com> Date: Sun, 27 Nov 2022 12:21:04 +0200 Subject: [PATCH 51/80] [MM-48409] url mapping to configured path for cws --- api4/user.go | 17 ++++++++++++++++- app/login.go | 1 - model/feature_flags.go | 3 +++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/api4/user.go b/api4/user.go index 439660e991..7cda65e422 100644 --- a/api4/user.go +++ b/api4/user.go @@ -1914,6 +1914,10 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) { } func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { + campaignToURL := map[string]string{ + "focalboard": "/boards", + } + if !c.App.Channels().License().IsCloud() { c.Err = model.NewAppError("loginCWS", "api.user.login_cws.license.error", nil, "", http.StatusUnauthorized) return @@ -1921,6 +1925,7 @@ func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { r.ParseForm() var loginID string var token string + var campaign string if len(r.Form) > 0 { for key, value := range r.Form { if key == "login_id" { @@ -1929,6 +1934,9 @@ func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { if key == "cws_token" { token = value[0] } + if key == "utm_campaign" { + campaign = value[0] + } } } @@ -1952,7 +1960,14 @@ func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { } c.LogAuditWithUserId(user.Id, "success") c.App.AttachSessionCookies(c.AppContext, w, r) - http.Redirect(w, r, *c.App.Config().ServiceSettings.SiteURL, http.StatusFound) + + redirectURL := *c.App.Config().ServiceSettings.SiteURL + if len(campaign) > 0 { + if url, ok := campaignToURL[campaign]; ok { + redirectURL += url + } + } + http.Redirect(w, r, redirectURL , http.StatusFound) } func logout(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/app/login.go b/app/login.go index bd63787546..16cc2d515f 100644 --- a/app/login.go +++ b/app/login.go @@ -63,7 +63,6 @@ func (a *App) AuthenticateUserForLogin(c *request.Context, id, loginId, password if user, err = a.GetUserForLogin(id, loginId); err != nil { return nil, err } - // CWS login allow to use the one-time token to login the users when they're redirected to their // installation for the first time if IsCWSLogin(a, cwsToken) { diff --git a/model/feature_flags.go b/model/feature_flags.go index ae1005b30d..8bce7a1a88 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -76,6 +76,8 @@ type FeatureFlags struct { ThreadsEverywhere bool GlobalDrafts bool + + UrlMappingForCWS bool } func (f *FeatureFlags) SetDefaults() { @@ -105,6 +107,7 @@ func (f *FeatureFlags) SetDefaults() { f.ReduceOnBoardingTaskList = false f.ThreadsEverywhere = false f.GlobalDrafts = false + f.UrlMappingForCWS = false } func (f *FeatureFlags) Plugins() map[string]string { From a71adb08e01cde5e1c5198e4746cf3db3cda4d13 Mon Sep 17 00:00:00 2001 From: Muhammad S <841955+mhd-sln@users.noreply.github.com> Date: Sun, 27 Nov 2022 12:43:53 +0200 Subject: [PATCH 52/80] [MM-48409] lint and vet fix --- api4/user.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api4/user.go b/api4/user.go index 7cda65e422..9d3ca204d2 100644 --- a/api4/user.go +++ b/api4/user.go @@ -1962,12 +1962,12 @@ func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { c.App.AttachSessionCookies(c.AppContext, w, r) redirectURL := *c.App.Config().ServiceSettings.SiteURL - if len(campaign) > 0 { + if campaign != "" { if url, ok := campaignToURL[campaign]; ok { redirectURL += url } } - http.Redirect(w, r, redirectURL , http.StatusFound) + http.Redirect(w, r, redirectURL, http.StatusFound) } func logout(c *Context, w http.ResponseWriter, r *http.Request) { From 62a1601331af4e9ee2df4bb9d6e2fe8ae9f88761 Mon Sep 17 00:00:00 2001 From: Muhammad S <841955+mhd-sln@users.noreply.github.com> Date: Wed, 30 Nov 2022 19:03:06 +0200 Subject: [PATCH 53/80] [MM-48409] code review fix --- app/login.go | 1 + model/feature_flags.go | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/login.go b/app/login.go index 16cc2d515f..bd63787546 100644 --- a/app/login.go +++ b/app/login.go @@ -63,6 +63,7 @@ func (a *App) AuthenticateUserForLogin(c *request.Context, id, loginId, password if user, err = a.GetUserForLogin(id, loginId); err != nil { return nil, err } + // CWS login allow to use the one-time token to login the users when they're redirected to their // installation for the first time if IsCWSLogin(a, cwsToken) { diff --git a/model/feature_flags.go b/model/feature_flags.go index 8bce7a1a88..ae1005b30d 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -76,8 +76,6 @@ type FeatureFlags struct { ThreadsEverywhere bool GlobalDrafts bool - - UrlMappingForCWS bool } func (f *FeatureFlags) SetDefaults() { @@ -107,7 +105,6 @@ func (f *FeatureFlags) SetDefaults() { f.ReduceOnBoardingTaskList = false f.ThreadsEverywhere = false f.GlobalDrafts = false - f.UrlMappingForCWS = false } func (f *FeatureFlags) Plugins() map[string]string { From 06e964b86b0d4a27f2efdf70d2c5232ec3353625 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Wed, 30 Nov 2022 15:02:43 -0500 Subject: [PATCH 54/80] Add prepackage plugins for react-dom updates (#21617) * add prepackage plugins for react-dom updates * update github plugin version, and add confluence plugin * debug log * Revert "debug log" This reverts commit 22753058556d69d357660596a253880c60ddda73. * update jira and gitlab plugin versions Co-authored-by: Mattermod --- Makefile | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index d0eb7575a1..923d0900ca 100644 --- a/Makefile +++ b/Makefile @@ -151,13 +151,16 @@ PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.2.2 PLUGIN_PACKAGES += mattermost-plugin-aws-SNS-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-calls-v0.10.0 PLUGIN_PACKAGES += mattermost-plugin-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-confluence-v1.3.0 +PLUGIN_PACKAGES += mattermost-plugin-custom-attributes-v1.3.1 +PLUGIN_PACKAGES += mattermost-plugin-github-v2.1.4 +PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.5.2 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.32.6 PLUGIN_PACKAGES += mattermost-plugin-jenkins-v1.1.0 -PLUGIN_PACKAGES += mattermost-plugin-jira-v2.4.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v3.2.2 +PLUGIN_PACKAGES += mattermost-plugin-jitsi-v2.0.1 PLUGIN_PACKAGES += mattermost-plugin-nps-v1.3.0 +PLUGIN_PACKAGES += mattermost-plugin-todo-v0.6.1 PLUGIN_PACKAGES += mattermost-plugin-welcomebot-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.6.0 PLUGIN_PACKAGES += focalboard-v7.5.2 From b0804f26ef9ff7f8a7cff395961c2fb12ada81b6 Mon Sep 17 00:00:00 2001 From: Shivashis Padhi Date: Sun, 4 Dec 2022 21:20:51 +0530 Subject: [PATCH 55/80] Adhere to convention while writing insights response (#21602) --- api4/insights.go | 56 ++++++++++++++---------------------------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/api4/insights.go b/api4/insights.go index c5bfa018ee..dd9e204eae 100644 --- a/api4/insights.go +++ b/api4/insights.go @@ -89,13 +89,10 @@ func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Requ return } - js, err := json.Marshal(topReactionList) - if err != nil { - c.Err = model.NewAppError("getTopReactionsForTeamSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + if err := json.NewEncoder(w).Encode(topReactionList); err != nil { + c.Err = model.NewAppError("getTopReactionsForTeamSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { @@ -149,13 +146,10 @@ func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Requ return } - js, err := json.Marshal(topReactionList) - if err != nil { - c.Err = model.NewAppError("getTopReactionsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + if err := json.NewEncoder(w).Encode(topReactionList); err != nil { + c.Err = model.NewAppError("getTopReactionsForUserSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } // Top Channels @@ -218,13 +212,10 @@ func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reque return } - js, err := json.Marshal(topChannels) - if err != nil { - c.Err = model.NewAppError("getTopChannelsForTeamSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + if err := json.NewEncoder(w).Encode(topChannels); err != nil { + c.Err = model.NewAppError("getTopChannelsForTeamSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { @@ -285,13 +276,10 @@ func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Reque return } - js, jsonErr := json.Marshal(topChannels) - if jsonErr != nil { - c.Err = model.NewAppError("getTopChannelsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + if err := json.NewEncoder(w).Encode(topChannels); err != nil { + c.Err = model.NewAppError("getTopChannelsForUserSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } // Top Threads @@ -347,13 +335,10 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques return } - js, jsonError := json.Marshal(topThreads) - if jsonError != nil { - c.Err = model.NewAppError("getTopThreadsForTeamSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + if err := json.NewEncoder(w).Encode(topThreads); err != nil { + c.Err = model.NewAppError("getTopThreadsForTeamSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { @@ -407,13 +392,10 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques return } - js, jsonErr := json.Marshal(topThreads) - if jsonErr != nil { - c.Err = model.NewAppError("getTopThreadsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + if err := json.NewEncoder(w).Encode(topThreads); err != nil { + c.Err = model.NewAppError("getTopThreadsForUserSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } // Top DMs @@ -448,13 +430,10 @@ func getTopDMsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { return } - js, jsonErr := json.Marshal(topDMs) - if jsonErr != nil { - c.Err = model.NewAppError("getTopDMsForUserSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(topDMs); err != nil { + c.Err = model.NewAppError("getTopDMsForUserSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } // Top Channels @@ -651,11 +630,8 @@ func getNewTeamMembersSince(c *Context, w http.ResponseWriter, r *http.Request) ntms.TotalCount = count - js, jsonErr := json.Marshal(ntms) - if jsonErr != nil { - c.Err = model.NewAppError("getNewTeamembersForTeamSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(ntms); err != nil { + c.Err = model.NewAppError("getNewTeamembersForTeamSince", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) return } - - w.Write(js) } From 0c64252c9f7c35b0545ff012dc3838d129e3aea3 Mon Sep 17 00:00:00 2001 From: Shivashis Padhi Date: Mon, 5 Dec 2022 16:47:28 +0530 Subject: [PATCH 56/80] [MM-48521] Fix for batch notification email not rendering properly (#21736) * Add MessageAttachment field to postdata while rendering batched email notifications * Add test for SlackAttachments body generator * Add context to changes * Add license text to new test file * Use app/email/notification_email.go as single source for attachment generation --- app/email/email_batching.go | 2 + app/email/notification_email.go | 90 +++++++++++++++++++++++++++ app/email/notification_email_test.go | 72 ++++++++++++++++++++++ app/notification_email.go | 92 +--------------------------- 4 files changed, 167 insertions(+), 89 deletions(-) create mode 100644 app/email/notification_email_test.go diff --git a/app/email/email_batching.go b/app/email/email_batching.go index ff016c0314..c4b3b01b60 100644 --- a/app/email/email_batching.go +++ b/app/email/email_batching.go @@ -33,6 +33,7 @@ type postData struct { Time string ShowChannelIcon bool OtherChannelMembersCount int + MessageAttachments []*EmailMessageAttachment } func (es *Service) InitEmailBatching() { @@ -314,6 +315,7 @@ func (es *Service) sendBatchedEmailNotification(userID string, notifications []* MessageURL: MessageURL, ShowChannelIcon: showChannelIcon, OtherChannelMembersCount: otherChannelMembersCount, + MessageAttachments: ProcessMessageAttachments(notification.post), }) } } diff --git a/app/email/notification_email.go b/app/email/notification_email.go index 6f83b1191f..06c0364b38 100644 --- a/app/email/notification_email.go +++ b/app/email/notification_email.go @@ -4,6 +4,8 @@ package email import ( + "html" + "html/template" "net/url" "path/filepath" "strings" @@ -11,8 +13,21 @@ import ( "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/i18n" "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/utils" ) +type FieldRow struct { + Cells []*model.SlackAttachmentField +} + +type EmailMessageAttachment struct { + model.SlackAttachment + + Pretext template.HTML + Text template.HTML + FieldRows []FieldRow +} + func (es *Service) GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string { if strings.TrimSpace(post.Message) != "" || len(post.FileIds) == 0 { return post.Message @@ -44,3 +59,78 @@ func (es *Service) GetMessageForNotification(post *model.Post, translateFunc i18 } return translateFunc("api.post.get_message_for_notification.files_sent", len(filenames), props) } + +func ProcessMessageAttachments(post *model.Post) []*EmailMessageAttachment { + emailMessageAttachments := []*EmailMessageAttachment{} + + for _, messageAttachment := range post.Attachments() { + emailMessageAttachment := &EmailMessageAttachment{ + SlackAttachment: *messageAttachment, + Pretext: prepareTextForEmail(messageAttachment.Pretext), + Text: prepareTextForEmail(messageAttachment.Text), + } + + stripedTitle, err := utils.StripMarkdown(emailMessageAttachment.Title) + if err != nil { + mlog.Warn("Failed parse to markdown from messageatatchment title", mlog.String("post_id", post.Id), mlog.Err(err)) + stripedTitle = "" + } + + emailMessageAttachment.Title = stripedTitle + + shortFieldRow := FieldRow{} + + for i := range messageAttachment.Fields { + // Create a new instance to avoid altering the original pointer reference + // We update field value to parse markdown. + // If we do that on the original pointer, the rendered text in mattermost + // becomes invalid as its no longer a markdown string, but rather an HTML string. + field := &model.SlackAttachmentField{ + Title: messageAttachment.Fields[i].Title, + Value: messageAttachment.Fields[i].Value, + Short: messageAttachment.Fields[i].Short, + } + + if stringValue, ok := field.Value.(string); ok { + field.Value = prepareTextForEmail(stringValue) + } + + if !field.Short { + if len(shortFieldRow.Cells) > 0 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, FieldRow{[]*model.SlackAttachmentField{field}}) + } else { + shortFieldRow.Cells = append(shortFieldRow.Cells, field) + + if len(shortFieldRow.Cells) == 2 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + } + } + + // collect any leftover short fields + if len(shortFieldRow.Cells) > 0 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + + emailMessageAttachments = append(emailMessageAttachments, emailMessageAttachment) + } + + return emailMessageAttachments +} + +func prepareTextForEmail(text string) template.HTML { + escapedText := html.EscapeString(text) + markdownText, err := utils.MarkdownToHTML(escapedText) + if err != nil { + mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(err)) + return template.HTML(text) + } + + return template.HTML(markdownText) +} diff --git a/app/email/notification_email_test.go b/app/email/notification_email_test.go new file mode 100644 index 0000000000..4f8e176c5b --- /dev/null +++ b/app/email/notification_email_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func TestProcessMessageAttachments(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + post := &model.Post{ + Message: "This is the message", + } + + messageAttachments := []*model.SlackAttachment{ + { + Color: "#FF0000", + Pretext: "message attachment 1 pretext", + AuthorName: "author name", + AuthorLink: "https://example.com/slack_attachment_1/author_link", + AuthorIcon: "https://example.com/slack_attachment_1/author_icon", + Title: "message attachment 1 title", + TitleLink: "https://example.com/slack_attachment_1/title_link", + Text: "message attachment 1 text", + ImageURL: "https://example.com/slack_attachment_1/image", + ThumbURL: "https://example.com/slack_attachment_1/thumb", + Fields: []*model.SlackAttachmentField{ + { + Short: true, + Title: "message attachment 1 field 1 title", + Value: "message attachment 1 field 1 value", + }, + { + Short: false, + Title: "message attachment 1 field 2 title", + Value: "message attachment 1 field 2 value", + }, + { + Short: true, + Title: "message attachment 1 field 3 title", + Value: "message attachment 1 field 3 value", + }, + { + Short: true, + Title: "message attachment 1 field 4 title", + Value: "message attachment 1 field 4 value", + }, + }, + }, + { + Color: "#FF0000", + Pretext: "message attachment 2 pretext", + AuthorName: "author name 2", + Text: "message attachment 2 text", + }, + } + + model.ParseSlackAttachment(post, messageAttachments) + + processedAttachcmentsPost := ProcessMessageAttachments(post) + require.NotNil(t, processedAttachcmentsPost) + require.Len(t, processedAttachcmentsPost, 2) + require.Equal(t, processedAttachcmentsPost[0].Color, "#FF0000") + require.Equal(t, processedAttachcmentsPost[0].FieldRows[0].Cells[0].Title, "message attachment 1 field 1 title") + require.Equal(t, processedAttachcmentsPost[1].Color, "#FF0000") +} diff --git a/app/notification_email.go b/app/notification_email.go index 965dd9a292..a56180ea2f 100644 --- a/app/notification_email.go +++ b/app/notification_email.go @@ -12,6 +12,7 @@ import ( "strings" "time" + email "github.com/mattermost/mattermost-server/v6/app/email" "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/i18n" @@ -201,18 +202,6 @@ func truncateUserNames(name string, i int) string { return name } -type FieldRow struct { - Cells []*model.SlackAttachmentField -} - -type EmailMessageAttachment struct { - model.SlackAttachment - - Pretext template.HTML - Text template.HTML - FieldRows []FieldRow -} - type postData struct { SenderName string ChannelName string @@ -223,7 +212,7 @@ type postData struct { Time string ShowChannelIcon bool OtherChannelMembersCount int - MessageAttachments []*EmailMessageAttachment + MessageAttachments []*email.EmailMessageAttachment } /** @@ -258,7 +247,7 @@ func (a *App) getNotificationEmailBody(c request.CTX, recipient *model.User, pos } pData.Message = template.HTML(normalizedPostMessage) pData.Time = translateFunc("app.notification.body.dm.time", messageTime) - pData.MessageAttachments = a.processMessageAttachments(post) + pData.MessageAttachments = email.ProcessMessageAttachments(post) } data := a.Srv().EmailService.NewEmailTemplateData(recipient.Locale) @@ -320,81 +309,6 @@ func (a *App) getNotificationEmailBody(c request.CTX, recipient *model.User, pos return a.Srv().TemplatesContainer().RenderToString("messages_notification", data) } -func (a *App) processMessageAttachments(post *model.Post) []*EmailMessageAttachment { - emailMessageAttachments := []*EmailMessageAttachment{} - - for _, messageAttachment := range post.Attachments() { - emailMessageAttachment := &EmailMessageAttachment{ - SlackAttachment: *messageAttachment, - Pretext: a.prepareTextForEmail(messageAttachment.Pretext), - Text: a.prepareTextForEmail(messageAttachment.Text), - } - - stripedTitle, err := utils.StripMarkdown(emailMessageAttachment.Title) - if err != nil { - mlog.Warn("Failed parse to markdown from messageatatchment title", mlog.String("post_id", post.Id), mlog.Err(err)) - stripedTitle = "" - } - - emailMessageAttachment.Title = stripedTitle - - shortFieldRow := FieldRow{} - - for i := range messageAttachment.Fields { - // Create a new instance to avoid altering the original pointer reference - // We update field value to parse markdown. - // If we do that on the original pointer, the rendered text in mattermost - // becomes invalid as its no longer a markdown string, but rather an HTML string. - field := &model.SlackAttachmentField{ - Title: messageAttachment.Fields[i].Title, - Value: messageAttachment.Fields[i].Value, - Short: messageAttachment.Fields[i].Short, - } - - if stringValue, ok := field.Value.(string); ok { - field.Value = a.prepareTextForEmail(stringValue) - } - - if !field.Short { - if len(shortFieldRow.Cells) > 0 { - emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) - shortFieldRow = FieldRow{} - } - - emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, FieldRow{[]*model.SlackAttachmentField{field}}) - } else { - shortFieldRow.Cells = append(shortFieldRow.Cells, field) - - if len(shortFieldRow.Cells) == 2 { - emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) - shortFieldRow = FieldRow{} - } - } - } - - // collect any leftover short fields - if len(shortFieldRow.Cells) > 0 { - emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) - shortFieldRow = FieldRow{} - } - - emailMessageAttachments = append(emailMessageAttachments, emailMessageAttachment) - } - - return emailMessageAttachments -} - -func (a *App) prepareTextForEmail(text string) template.HTML { - escapedText := html.EscapeString(text) - markdownText, err := utils.MarkdownToHTML(escapedText) - if err != nil { - mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(err)) - return template.HTML(text) - } - - return template.HTML(markdownText) -} - type formattedPostTime struct { Time time.Time Year string From 3059abdd88eaf74968d0249bde405c941d0538fd Mon Sep 17 00:00:00 2001 From: Konstantinos Pittas Date: Mon, 5 Dec 2022 16:04:44 +0200 Subject: [PATCH 57/80] [MM-44765] Add API method for getting post's information from permalink (#21518) * add api for getting post's information * add information about current user state Co-authored-by: Mattermod --- api4/post.go | 22 +++ api4/post_test.go | 256 +++++++++++++++++++++++++++ app/app_iface.go | 1 + app/opentracing/opentracing_layer.go | 22 +++ app/post.go | 68 +++++++ model/client4.go | 14 ++ model/post_info.go | 15 ++ 7 files changed, 398 insertions(+) create mode 100644 model/post_info.go diff --git a/api4/post.go b/api4/post.go index 6aa7049563..0d9f369731 100644 --- a/api4/post.go +++ b/api4/post.go @@ -23,6 +23,7 @@ func (api *API) InitPost() { api.BaseRoutes.Posts.Handle("/ids", api.APISessionRequired(getPostsByIds)).Methods("POST") api.BaseRoutes.Posts.Handle("/ephemeral", api.APISessionRequired(createEphemeralPost)).Methods("POST") api.BaseRoutes.Post.Handle("/thread", api.APISessionRequired(getPostThread)).Methods("GET") + api.BaseRoutes.Post.Handle("/info", api.APISessionRequired(getPostInfo)).Methods("GET") api.BaseRoutes.Post.Handle("/files/info", api.APISessionRequired(getFileInfosForPost)).Methods("GET") api.BaseRoutes.PostsForChannel.Handle("", api.APISessionRequired(getPostsForChannel)).Methods("GET") api.BaseRoutes.PostsForUser.Handle("/flagged", api.APISessionRequired(getFlaggedPostsForUser)).Methods("GET") @@ -1055,3 +1056,24 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set(model.HeaderEtagServer, model.GetEtagForFileInfos(infos)) w.Write(js) } + +func getPostInfo(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequirePostId() + if c.Err != nil { + return + } + + info, appErr := c.App.GetPostInfo(c.AppContext, c.Params.PostId) + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(info) + if err != nil { + c.Err = model.NewAppError("getPostInfo", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(js) +} diff --git a/api4/post_test.go b/api4/post_test.go index 3240347e1e..8594fae136 100644 --- a/api4/post_test.go +++ b/api4/post_test.go @@ -3343,6 +3343,262 @@ func TestPostReminder(t *testing.T) { require.Truef(t, caught, "User should have received %s event", model.WebsocketEventEphemeralMessage) } +func TestPostGetInfo(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + client := th.Client + sysadminClient := th.SystemAdminClient + sysadminClient.AddTeamMember(th.BasicTeam.Id, th.SystemAdminUser.Id) + + openChannel, _, err := client.CreateChannel(&model.Channel{TeamId: th.BasicTeam.Id, Type: model.ChannelTypeOpen, Name: "open-channel", DisplayName: "Open Channel"}) + require.NoError(t, err) + sysadminClient.AddChannelMember(openChannel.Id, th.SystemAdminUser.Id) + openPost, _, err := client.CreatePost(&model.Post{ChannelId: openChannel.Id}) + require.NoError(t, err) + + privateChannel, _, err := sysadminClient.CreateChannel(&model.Channel{TeamId: th.BasicTeam.Id, Type: model.ChannelTypePrivate, Name: "private-channel", DisplayName: "Private Channel"}) + require.NoError(t, err) + privatePost, _, err := sysadminClient.CreatePost(&model.Post{ChannelId: privateChannel.Id}) + require.NoError(t, err) + + privateChannelBasicUser, _, err := client.CreateChannel(&model.Channel{TeamId: th.BasicTeam.Id, Type: model.ChannelTypePrivate, Name: "private-channel-basic-user", DisplayName: "Private Channel - Basic User"}) + require.NoError(t, err) + privatePostBasicUser, _, err := client.CreatePost(&model.Post{ChannelId: privateChannelBasicUser.Id}) + require.NoError(t, err) + + user3 := th.CreateUser() + gmChannel, _, err := client.CreateGroupChannel([]string{th.BasicUser.Id, th.BasicUser2.Id, user3.Id}) + require.NoError(t, err) + gmPost, _, err := client.CreatePost(&model.Post{ChannelId: gmChannel.Id}) + require.NoError(t, err) + + dmChannel, _, err := client.CreateDirectChannel(th.BasicUser.Id, th.BasicUser2.Id) + require.NoError(t, err) + dmPost, _, err := client.CreatePost(&model.Post{ChannelId: dmChannel.Id}) + require.NoError(t, err) + + openTeam, _, err := sysadminClient.CreateTeam(&model.Team{Type: model.TeamOpen, Name: "open-team", DisplayName: "Open Team"}) + require.NoError(t, err) + openTeamOpenChannel, _, err := sysadminClient.CreateChannel(&model.Channel{TeamId: openTeam.Id, Type: model.ChannelTypeOpen, Name: "open-team-open-channel", DisplayName: "Open Team - Open Channel"}) + require.NoError(t, err) + openTeamOpenPost, _, err := sysadminClient.CreatePost(&model.Post{ChannelId: openTeamOpenChannel.Id}) + require.NoError(t, err) + + // Alt team is a team without the sysadmin in it. + altOpenTeam, _, err := client.CreateTeam(&model.Team{Type: model.TeamOpen, Name: "alt-open-team", DisplayName: "Alt Open Team"}) + require.NoError(t, err) + altOpenTeamOpenChannel, _, err := client.CreateChannel(&model.Channel{TeamId: altOpenTeam.Id, Type: model.ChannelTypeOpen, Name: "alt-open-team-open-channel", DisplayName: "Open Team - Open Channel"}) + require.NoError(t, err) + altOpenTeamOpenPost, _, err := client.CreatePost(&model.Post{ChannelId: altOpenTeamOpenChannel.Id}) + require.NoError(t, err) + + inviteTeam, _, err := sysadminClient.CreateTeam(&model.Team{Type: model.TeamInvite, Name: "invite-team", DisplayName: "Invite Team"}) + require.NoError(t, err) + inviteTeamOpenChannel, _, err := sysadminClient.CreateChannel(&model.Channel{TeamId: inviteTeam.Id, Type: model.ChannelTypeOpen, Name: "invite-team-open-channel", DisplayName: "Invite Team - Open Channel"}) + require.NoError(t, err) + inviteTeamOpenPost, _, err := sysadminClient.CreatePost(&model.Post{ChannelId: inviteTeamOpenChannel.Id}) + require.NoError(t, err) + + testCases := []struct { + name string + team *model.Team + hasJoinedTeam bool + channel *model.Channel + hasJoinedChannel bool + post *model.Post + client *model.Client4 + hasAccess bool + }{ + // Open channel - Current Team + { + name: "Open post - Current team - Basic user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: openChannel, + hasJoinedChannel: true, + post: openPost, + client: client, + hasAccess: true, + }, + { + name: "Open post - Current team - Sysadmin user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: openChannel, + hasJoinedChannel: true, + post: openPost, + client: sysadminClient, + hasAccess: true, + }, + + // Private channel - Current Team + { + name: "Private post by sysadmin - Current team - Basic user", + team: th.BasicTeam, + channel: privateChannel, + post: privatePost, + client: client, + hasAccess: false, + }, + { + name: "Private post by sysadmin - Current team - Sysadmin user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: privateChannel, + hasJoinedChannel: true, + post: privatePost, + client: sysadminClient, + hasAccess: true, + }, + { + name: "Private post by basic user - Current team - Basic user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: privateChannelBasicUser, + hasJoinedChannel: true, + post: privatePostBasicUser, + client: client, + hasAccess: true, + }, + { + name: "Private post by basic user - Current team - Sysadmin user", + team: th.BasicTeam, + hasJoinedTeam: true, + channel: privateChannelBasicUser, + hasJoinedChannel: false, + post: privatePostBasicUser, + client: sysadminClient, + hasAccess: true, + }, + + // GM channel + { + name: "GM post - Current team - Basic user", + team: nil, + channel: gmChannel, + hasJoinedChannel: true, + post: gmPost, + client: client, + hasAccess: true, + }, + { + name: "GM post - Current team - Sysadmin user", + team: nil, + channel: gmChannel, + post: gmPost, + client: sysadminClient, + hasAccess: false, + }, + + // DM channel + { + name: "DM post - Current team - Basic user", + team: nil, + channel: dmChannel, + hasJoinedChannel: true, + post: dmPost, + client: client, + hasAccess: true, + }, + { + name: "DM post - Current team - Sysadmin user", + team: nil, + channel: dmChannel, + post: dmPost, + client: sysadminClient, + hasAccess: false, + }, + + // Open channel - Open Team + { + name: "Open post - Open team - Basic user", + team: openTeam, + hasJoinedTeam: false, + channel: openTeamOpenChannel, + hasJoinedChannel: false, + post: openTeamOpenPost, + client: client, + hasAccess: true, + }, + { + name: "Open post - Open team - Sysadmin user", + team: openTeam, + hasJoinedTeam: true, + channel: openTeamOpenChannel, + hasJoinedChannel: true, + post: openTeamOpenPost, + client: sysadminClient, + hasAccess: true, + }, + + // Open channel - Alt Open Team + { + name: "Open post - Alt open team - Basic user", + team: altOpenTeam, + hasJoinedTeam: true, + channel: altOpenTeamOpenChannel, + hasJoinedChannel: true, + post: altOpenTeamOpenPost, + client: client, + hasAccess: true, + }, + { + name: "Open post - Alt open team - Sysadmin user", + team: altOpenTeam, + hasJoinedTeam: false, + channel: altOpenTeamOpenChannel, + hasJoinedChannel: false, + post: altOpenTeamOpenPost, + client: sysadminClient, + hasAccess: true, + }, + + // Open channel - Invite Team + { + name: "Open post - Invite team - Basic user", + team: inviteTeam, + channel: inviteTeamOpenChannel, + post: inviteTeamOpenPost, + client: client, + hasAccess: false, + }, + { + name: "Open post - Invite team - Sysadmin user", + team: inviteTeam, + hasJoinedTeam: true, + channel: inviteTeamOpenChannel, + hasJoinedChannel: true, + post: inviteTeamOpenPost, + client: sysadminClient, + hasAccess: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + info, resp, err := tc.client.GetPostInfo(tc.post.Id) + if !tc.hasAccess { + require.Error(t, err) + CheckNotFoundStatus(t, resp) + return + } + + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Equal(t, tc.channel.Id, info.ChannelId) + require.Equal(t, tc.channel.Type, info.ChannelType) + require.Equal(t, tc.channel.DisplayName, info.ChannelDisplayName) + require.Equal(t, tc.hasJoinedChannel, info.HasJoinedChannel) + if tc.team != nil { + require.Equal(t, tc.team.Id, info.TeamId) + require.Equal(t, tc.team.Type, info.TeamType) + require.Equal(t, tc.team.DisplayName, info.TeamDisplayName) + require.Equal(t, tc.hasJoinedTeam, info.HasJoinedTeam) + } + }) + } +} + func TestAcknowledgePost(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/app_iface.go b/app/app_iface.go index aca9f1e795..948060d0f6 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -712,6 +712,7 @@ type AppIface interface { GetPostIdAfterTime(channelID string, time int64, collapsedThreads bool) (string, *model.AppError) GetPostIdBeforeTime(channelID string, time int64, collapsedThreads bool) (string, *model.AppError) GetPostIfAuthorized(c request.CTX, postID string, session *model.Session, includeDeleted bool) (*model.Post, *model.AppError) + GetPostInfo(c request.CTX, postID string) (*model.PostInfo, *model.AppError) GetPostThread(postID string, opts model.GetPostsOptions, userID string) (*model.PostList, *model.AppError) GetPosts(channelID string, offset int, limit int) (*model.PostList, *model.AppError) GetPostsAfterPost(options model.GetPostsOptions) (*model.PostList, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 35664d638c..498451174b 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -7952,6 +7952,28 @@ func (a *OpenTracingAppLayer) GetPostIfAuthorized(c request.CTX, postID string, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetPostInfo(c request.CTX, postID string) (*model.PostInfo, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPostInfo") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetPostInfo(c, postID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetPostThread(postID string, opts model.GetPostsOptions, userID string) (*model.PostList, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetPostThread") diff --git a/app/post.go b/app/post.go index b9f1bf0ed3..9fafd61f9b 100644 --- a/app/post.go +++ b/app/post.go @@ -2151,6 +2151,74 @@ func (a *App) CheckPostReminders() { } +func (a *App) GetPostInfo(c request.CTX, postID string) (*model.PostInfo, *model.AppError) { + userID := c.Session().UserId + post, appErr := a.GetSinglePost(postID, false) + if appErr != nil { + return nil, appErr + } + + channel, appErr := a.GetChannel(c, post.ChannelId) + if appErr != nil { + return nil, appErr + } + + notFoundError := model.NewAppError("GetPostInfo", "app.post.get.app_error", nil, "", http.StatusNotFound) + + var team *model.Team + hasPermissionToAccessTeam := false + if channel.TeamId != "" { + team, appErr = a.GetTeam(channel.TeamId) + if appErr != nil { + return nil, appErr + } + + if team.Type == model.TeamOpen { + hasPermissionToAccessTeam = a.HasPermissionToTeam(userID, team.Id, model.PermissionJoinPublicTeams) + } else if team.Type == model.TeamInvite { + hasPermissionToAccessTeam = a.HasPermissionToTeam(userID, team.Id, model.PermissionJoinPrivateTeams) + } + } else { + // This happens in case of DMs and GMs. + hasPermissionToAccessTeam = true + } + + if !hasPermissionToAccessTeam { + return nil, notFoundError + } + + hasPermissionToAccessChannel := false + if channel.Type == model.ChannelTypeOpen { + hasPermissionToAccessChannel = true + } else if channel.Type == model.ChannelTypePrivate { + hasPermissionToAccessChannel = a.HasPermissionToChannel(c, userID, channel.Id, model.PermissionManagePrivateChannelMembers) + } else if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup { + hasPermissionToAccessChannel = a.HasPermissionToChannel(c, userID, channel.Id, model.PermissionReadChannel) + } + + if !hasPermissionToAccessChannel { + return nil, notFoundError + } + + _, channelMemberErr := a.GetChannelMember(c, channel.Id, userID) + + info := model.PostInfo{ + ChannelId: channel.Id, + ChannelType: channel.Type, + ChannelDisplayName: channel.DisplayName, + HasJoinedChannel: channelMemberErr == nil, + } + if team != nil { + _, teamMemberErr := a.GetTeamMember(team.Id, userID) + + info.TeamId = team.Id + info.TeamType = team.Type + info.TeamDisplayName = team.DisplayName + info.HasJoinedTeam = teamMemberErr == nil + } + return &info, nil +} + func includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThreadList, userID string) (*model.TopThreadList, error) { for _, topThread := range topThreadList.Items { topThread.Post = a.PreparePostForClientWithEmbedsAndImages(c, topThread.Post, false, false, true) diff --git a/model/client4.go b/model/client4.go index fded08a23d..5347048f17 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8540,6 +8540,20 @@ func (c *Client4) GetNewTeamMembersSince(teamID string, timeRange string, page i return newTeamMembersList, BuildResponse(r), nil } +func (c *Client4) GetPostInfo(postId string) (*PostInfo, *Response, error) { + r, err := c.DoAPIGet(c.postRoute(postId)+"/info", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var info *PostInfo + if err = json.NewDecoder(r.Body).Decode(&info); err != nil { + return nil, nil, NewAppError("GetPostInfo", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return info, BuildResponse(r), nil +} + func (c *Client4) AcknowledgePost(postId, userId string) (*PostAcknowledgement, *Response, error) { r, err := c.DoAPIPost(c.userRoute(userId)+c.postRoute(postId)+"/ack", "") if err != nil { diff --git a/model/post_info.go b/model/post_info.go new file mode 100644 index 0000000000..0a48ae9a36 --- /dev/null +++ b/model/post_info.go @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type PostInfo struct { + ChannelId string `json:"channel_id"` + ChannelType ChannelType `json:"channel_type"` + ChannelDisplayName string `json:"channel_display_name"` + HasJoinedChannel bool `json:"has_joined_channel"` + TeamId string `json:"team_id"` + TeamType string `json:"team_type"` + TeamDisplayName string `json:"team_display_name"` + HasJoinedTeam bool `json:"has_joined_team"` +} From 9e79ca71609fe94a441983ffd8ab5bcd9917dabe Mon Sep 17 00:00:00 2001 From: emmyni <44761757+emmyni@users.noreply.github.com> Date: Mon, 5 Dec 2022 12:10:00 -0500 Subject: [PATCH 58/80] [MM-48472]: Update the Upgrade Confirmation Email for yearly subscriptions (#21766) --- api4/cloud.go | 30 ++++++++++++++++++++-- app/app_iface.go | 2 +- app/cloud.go | 4 +-- app/email/email.go | 10 ++++++-- app/email/email_test.go | 38 ++++++++++++++++++++++++++-- app/email/mocks/ServiceInterface.go | 10 ++++---- app/email/service.go | 2 +- app/opentracing/opentracing_layer.go | 4 +-- i18n/en.json | 14 +++++++--- 9 files changed, 94 insertions(+), 20 deletions(-) diff --git a/api4/cloud.go b/api4/cloud.go index 173d975279..3f9106710c 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -136,9 +136,16 @@ func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { return } + product, err := c.App.Cloud().GetCloudProduct(c.AppContext.Session().UserId, subscriptionChange.ProductID) + if err != nil || product == nil { + c.Logger.Error("Error finding the new cloud product", mlog.Err(err)) + } + + isYearly := product.IsYearly() + // Log failures for purchase confirmation email, but don't show an error to the user so as not to confuse them // At this point, the upgrade is complete. - if appErr := c.App.SendUpgradeConfirmationEmail(); appErr != nil { + if appErr := c.App.SendUpgradeConfirmationEmail(isYearly); appErr != nil { c.Logger.Error("Error sending purchase confirmation email", mlog.Err(appErr)) } @@ -636,7 +643,26 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { return } case model.EventTypeSendUpgradeConfirmationEmail: - if nErr := c.App.SendUpgradeConfirmationEmail(); nErr != nil { + + // isYearly determines whether to send the yearly or monthly Upgrade email + isYearly := false + if event.Subscription != nil && event.CloudWorkspaceOwner != nil { + user, appErr := c.App.GetUserByUsername(event.CloudWorkspaceOwner.UserName) + if appErr != nil { + c.Err = model.NewAppError("Api4.handleCWSWebhook", appErr.Id, nil, appErr.Error(), appErr.StatusCode) + return + } + + // Get the current cloud product to determine whether it's a monthly or yearly product + product, err := c.App.Cloud().GetCloudProduct(user.Id, event.Subscription.ProductID) + if err != nil { + c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.request_error", nil, err.Error(), http.StatusInternalServerError) + return + } + isYearly = product.IsYearly() + } + + if nErr := c.App.SendUpgradeConfirmationEmail(isYearly); nErr != nil { c.Err = nErr return } diff --git a/app/app_iface.go b/app/app_iface.go index 948060d0f6..781f12b7e1 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -1038,7 +1038,7 @@ type AppIface interface { SendPasswordReset(email string, siteURL string) (bool, *model.AppError) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError SendTestPushNotification(deviceID string) string - SendUpgradeConfirmationEmail() *model.AppError + SendUpgradeConfirmationEmail(isYearly bool) *model.AppError ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) SessionHasPermissionTo(session model.Session, permission *model.Permission) bool SessionHasPermissionToAny(session model.Session, permissions []*model.Permission) bool diff --git a/app/cloud.go b/app/cloud.go index fd2d4fb23e..f045c57311 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -174,7 +174,7 @@ func getNextBillingDateString() string { return fmt.Sprintf("%s %d, %d", t.Month(), t.Day(), t.Year()) } -func (a *App) SendUpgradeConfirmationEmail() *model.AppError { +func (a *App) SendUpgradeConfirmationEmail(isYearly bool) *model.AppError { sysAdmins, e := a.getSysAdminsEmailRecipients() if e != nil { return e @@ -200,7 +200,7 @@ func (a *App) SendUpgradeConfirmationEmail() *model.AppError { name = admin.Username } - err := a.Srv().EmailService.SendCloudUpgradeConfirmationEmail(admin.Email, name, billingDate, admin.Locale, *a.Config().ServiceSettings.SiteURL, subscription.GetWorkSpaceNameFromDNS()) + err := a.Srv().EmailService.SendCloudUpgradeConfirmationEmail(admin.Email, name, billingDate, admin.Locale, *a.Config().ServiceSettings.SiteURL, subscription.GetWorkSpaceNameFromDNS(), isYearly) if err != nil { a.Log().Error("Error sending trial ended email to", mlog.String("email", admin.Email), mlog.Err(err)) countNotOks++ diff --git a/app/email/email.go b/app/email/email.go index b0cd66e7dd..d434d94935 100644 --- a/app/email/email.go +++ b/app/email/email.go @@ -233,13 +233,13 @@ func (es *Service) SendWelcomeEmail(userID string, email string, verified bool, return nil } -func (es *Service) SendCloudUpgradeConfirmationEmail(userEmail, name, date, locale, siteURL, workspaceName string) error { +func (es *Service) SendCloudUpgradeConfirmationEmail(userEmail, name, date, locale, siteURL, workspaceName string, isYearly bool) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.cloud_upgrade_confirmation.subject") data := es.NewEmailTemplateData(locale) data.Props["Title"] = T("api.templates.cloud_upgrade_confirmation.title") - data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation.subtitle", map[string]any{"WorkspaceName": workspaceName, "Date": date}) + data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation_monthly.subtitle", map[string]any{"WorkspaceName": workspaceName, "Date": date}) data.Props["SiteURL"] = siteURL data.Props["ButtonURL"] = siteURL data.Props["Button"] = T("api.templates.cloud_welcome_email.button") @@ -247,6 +247,12 @@ func (es *Service) SendCloudUpgradeConfirmationEmail(userEmail, name, date, loca data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail + if isYearly { + data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation_yearly.subtitle", map[string]any{"WorkspaceName": workspaceName}) + data.Props["ButtonURL"] = siteURL + "/admin_console/billing/billing_history" + data.Props["Button"] = T("api.templates.cloud_welcome_email.yearly_plan_button") + } + body, err := es.templatesContainer.RenderToString("cloud_upgrade_confirmation", data) if err != nil { return err diff --git a/app/email/email_test.go b/app/email/email_test.go index 0fe3b237e0..2ad00123c3 100644 --- a/app/email/email_test.go +++ b/app/email/email_test.go @@ -258,7 +258,7 @@ func TestSendCloudUpgradedEmail(t *testing.T) { emailTo := "testclouduser@example.com" emailToUsername := strings.Split(emailTo, "@")[0] - t.Run("SendCloudUpgradedEmail", func(t *testing.T) { + t.Run("SendCloudMonthlyUpgradedEmail", func(t *testing.T) { verifyMailbox := func(t *testing.T) { t.Helper() @@ -278,10 +278,44 @@ func TestSendCloudUpgradedEmail(t *testing.T) { require.NoError(t, err, "Could not get message from mailbox") require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text) require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "You'll be billed from", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "Open Mattermost", "Wrong received message %s", resultsEmail.Body.Text) } mail.DeleteMailBox(emailTo) - err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName") + // Send Update to Monthly Plan email + err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", false) + require.NoError(t, err) + + verifyMailbox(t) + }) + + t.Run("SendCloudYearlyUpgradedEmail", func(t *testing.T) { + verifyMailbox := func(t *testing.T) { + t.Helper() + + var resultsMailbox mail.JSONMessageHeaderInbucket + err2 := mail.RetryInbucket(5, func() error { + var err error + resultsMailbox, err = mail.GetMailBox(emailTo) + return err + }) + if err2 != nil { + t.Skipf("No email was received, maybe due load on the server: %v", err2) + } + + require.Len(t, resultsMailbox, 1) + require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient") + resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID) + require.NoError(t, err, "Could not get message from mailbox") + require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "View your invoice", "Wrong received message %s", resultsEmail.Body.Text) + } + mail.DeleteMailBox(emailTo) + + // Send Update to Monthly Plan email + err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", true) require.NoError(t, err) verifyMailbox(t) diff --git a/app/email/mocks/ServiceInterface.go b/app/email/mocks/ServiceInterface.go index 08cddbe4ad..98b39f90fe 100644 --- a/app/email/mocks/ServiceInterface.go +++ b/app/email/mocks/ServiceInterface.go @@ -125,13 +125,13 @@ func (_m *ServiceInterface) SendChangeUsernameEmail(newUsername string, _a1 stri return r0 } -// SendCloudUpgradeConfirmationEmail provides a mock function with given fields: userEmail, name, trialEndDate, locale, siteURL, workspaceName -func (_m *ServiceInterface) SendCloudUpgradeConfirmationEmail(userEmail string, name string, trialEndDate string, locale string, siteURL string, workspaceName string) error { - ret := _m.Called(userEmail, name, trialEndDate, locale, siteURL, workspaceName) +// SendCloudUpgradeConfirmationEmail provides a mock function with given fields: userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly +func (_m *ServiceInterface) SendCloudUpgradeConfirmationEmail(userEmail string, name string, trialEndDate string, locale string, siteURL string, workspaceName string, isYearly bool) error { + ret := _m.Called(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly) var r0 error - if rf, ok := ret.Get(0).(func(string, string, string, string, string, string) error); ok { - r0 = rf(userEmail, name, trialEndDate, locale, siteURL, workspaceName) + if rf, ok := ret.Get(0).(func(string, string, string, string, string, string, bool) error); ok { + r0 = rf(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly) } else { r0 = ret.Error(0) } diff --git a/app/email/service.go b/app/email/service.go index 0f9158e343..d8745a1735 100644 --- a/app/email/service.go +++ b/app/email/service.go @@ -129,7 +129,7 @@ type ServiceInterface interface { SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error SendSignInChangeEmail(email, method, locale, siteURL string) error SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error - SendCloudUpgradeConfirmationEmail(userEmail, name, trialEndDate, locale, siteURL, workspaceName string) error + SendCloudUpgradeConfirmationEmail(userEmail, name, trialEndDate, locale, siteURL, workspaceName string, isYearly bool) error SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error SendPasswordChangeEmail(email, method, locale, siteURL string) error SendUserAccessTokenAddedEmail(email, locale, siteURL string) error diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 498451174b..1e25574b70 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -15546,7 +15546,7 @@ func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string { return resultVar0 } -func (a *OpenTracingAppLayer) SendUpgradeConfirmationEmail() *model.AppError { +func (a *OpenTracingAppLayer) SendUpgradeConfirmationEmail(isYearly bool) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendUpgradeConfirmationEmail") @@ -15558,7 +15558,7 @@ func (a *OpenTracingAppLayer) SendUpgradeConfirmationEmail() *model.AppError { }() defer span.Finish() - resultVar0 := a.app.SendUpgradeConfirmationEmail() + resultVar0 := a.app.SendUpgradeConfirmationEmail(isYearly) if resultVar0 != nil { span.LogFields(spanlog.Error(resultVar0)) diff --git a/i18n/en.json b/i18n/en.json index 91c3351aaf..43b65baa82 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3164,12 +3164,16 @@ "translation": "Mattermost Upgrade Confirmation" }, { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", + "id": "api.templates.cloud_upgrade_confirmation.title", + "translation": "You are now upgraded!" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", "translation": "Your {{.WorkspaceName}} workspace has now been upgraded. You'll be billed from {{.Date}}" }, { - "id": "api.templates.cloud_upgrade_confirmation.title", - "translation": "You are now upgraded!" + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "Your {{.WorkspaceName}} workspace has now been upgraded." }, { "id": "api.templates.cloud_welcome_email.add_apps_info", @@ -3239,6 +3243,10 @@ "id": "api.templates.cloud_welcome_email.title", "translation": "Your workspace is ready to go!" }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "View your invoice" + }, { "id": "api.templates.copyright", "translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" From 43e26ccda2db693b10aa98cd5cf5d93c480abf1c Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Mon, 5 Dec 2022 22:16:35 +0300 Subject: [PATCH 59/80] MPA: move product hooks out of plugins environment (#21772) * product: add new hooks manager for porducts * move product hooks out of plugins environment * add hooks for plugin --- api4/websocket.go | 2 +- app/channel.go | 10 ++-- app/channels.go | 30 +++++++++++- app/cluster_handlers.go | 11 +++-- app/file.go | 2 +- app/login.go | 4 +- app/onboarding.go | 2 +- app/platform/service.go | 15 ++++-- app/platform/web_conn.go | 60 ++++++++++------------- app/platform/web_conn_test.go | 23 +++++++-- app/platform/web_hub_test.go | 3 +- app/plugin.go | 6 +-- app/plugin_hooks_test.go | 6 +-- app/post.go | 8 +-- app/reaction.go | 4 +- app/server.go | 4 ++ app/team.go | 4 +- app/upload.go | 2 +- app/user.go | 2 +- app/web_conn.go | 2 +- plugin/environment.go | 45 ----------------- plugin/interface_generator/main.go | 8 +-- plugin/product.go | 18 +++---- plugin/product_hooks_generated.go | 68 ++++++++++++------------- product/hooks.go | 79 ++++++++++++++++++++++++++++++ 25 files changed, 249 insertions(+), 169 deletions(-) create mode 100644 product/hooks.go diff --git a/api4/websocket.go b/api4/websocket.go index 5f1cb2cdd3..d2c1c10f44 100644 --- a/api4/websocket.go +++ b/api4/websocket.go @@ -61,7 +61,7 @@ func connectWebSocket(c *Context, w http.ResponseWriter, r *http.Request) { } } - wc := c.App.Srv().Platform().NewWebConn(cfg, c.App, c.App.Srv().Channels().GetPluginsEnvironment) + wc := c.App.Srv().Platform().NewWebConn(cfg, c.App, c.App.Srv().Channels()) if c.AppContext.Session().UserId != "" { c.App.Srv().Platform().HubRegister(wc) } diff --git a/app/channel.go b/app/channel.go index 0fe00de4e6..273555f6b8 100644 --- a/app/channel.go +++ b/app/channel.go @@ -346,7 +346,7 @@ func (a *App) CreateChannel(c request.CTX, channel *model.Channel, addMember boo if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.ChannelHasBeenCreated(pluginContext, sc) return true }, plugin.ChannelHasBeenCreatedID) @@ -432,7 +432,7 @@ func (a *App) handleCreationEvent(c request.CTX, userID, otherUserID string, cha if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.ChannelHasBeenCreated(pluginContext, channel) return true }, plugin.ChannelHasBeenCreatedID) @@ -1602,7 +1602,7 @@ func (a *App) AddChannelMember(c request.CTX, userID string, channel *model.Chan if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.UserHasJoinedChannel(pluginContext, cm, userRequestor) return true }, plugin.UserHasJoinedChannelID) @@ -2180,7 +2180,7 @@ func (a *App) JoinChannel(c request.CTX, channel *model.Channel, userID string) if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.UserHasJoinedChannel(pluginContext, cm, nil) return true }, plugin.UserHasJoinedChannelID) @@ -2492,7 +2492,7 @@ func (a *App) removeUserFromChannel(c request.CTX, userIDToRemove string, remove a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.UserHasLeftChannel(pluginContext, cm, actorUser) return true }, plugin.UserHasLeftChannelID) diff --git a/app/channels.go b/app/channels.go index 2c9f1cee51..31ef2d429c 100644 --- a/app/channels.go +++ b/app/channels.go @@ -322,5 +322,33 @@ func (s *hooksService) RegisterHooks(productID string, hooks any) error { return errors.New("could not find plugins environment") } - return s.ch.pluginsEnvironment.AddProduct(productID, hooks) + return s.ch.srv.hooksManager.AddProduct(productID, hooks) +} + +func (ch *Channels) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) { + if env := ch.pluginsEnvironment; env != nil { + env.RunMultiPluginHook(hookRunnerFunc, hookId) + } + + // run hook for the products + ch.srv.hooksManager.RunMultiHook(hookRunnerFunc, hookId) +} + +func (ch *Channels) HooksForPluginOrProduct(id string) (plugin.Hooks, error) { + var hooks plugin.Hooks + if env := ch.pluginsEnvironment; env != nil { + // we intentionally ignore the error here, because the id can be a product id + // we are going to check if we have the hooks or not + hooks, _ = env.HooksForPlugin(id) + if hooks != nil { + return hooks, nil + } + } + + hooks = ch.srv.hooksManager.HooksForProduct(id) + if hooks != nil { + return hooks, nil + } + + return nil, fmt.Errorf("could not find hooks for id %s", id) } diff --git a/app/cluster_handlers.go b/app/cluster_handlers.go index 182e6ede01..3fa90abf1e 100644 --- a/app/cluster_handlers.go +++ b/app/cluster_handlers.go @@ -28,10 +28,6 @@ func (s *Server) clusterRemovePluginHandler(msg *model.ClusterMessage) { } func (s *Server) clusterPluginEventHandler(msg *model.ClusterMessage) { - env := s.Channels().GetPluginsEnvironment() - if env == nil { - return - } if msg.Props == nil { mlog.Warn("ClusterMessage.Props for plugin event should not be nil") return @@ -48,7 +44,12 @@ func (s *Server) clusterPluginEventHandler(msg *model.ClusterMessage) { return } - hooks, err := env.HooksForPlugin(pluginID) + channels, ok := s.products["channels"].(*Channels) + if !ok { + return + } + + hooks, err := channels.HooksForPluginOrProduct(pluginID) if err != nil { mlog.Warn("Getting hooks for plugin failed", mlog.String("plugin_id", pluginID), mlog.Err(err)) return diff --git a/app/file.go b/app/file.go index 2f0050dff6..67a227cab0 100644 --- a/app/file.go +++ b/app/file.go @@ -898,7 +898,7 @@ func (a *App) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTe if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { var rejectionError *model.AppError pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { var newBytes bytes.Buffer replacementInfo, rejectionReason := hooks.FileWillBeUploaded(pluginContext, info, bytes.NewReader(data), &newBytes) if rejectionReason != "" { diff --git a/app/login.go b/app/login.go index bd63787546..98279d7f2f 100644 --- a/app/login.go +++ b/app/login.go @@ -160,7 +160,7 @@ func (a *App) DoLogin(c *request.Context, w http.ResponseWriter, r *http.Request if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { var rejectionReason string pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { rejectionReason = hooks.UserWillLogIn(pluginContext, user) return rejectionReason == "" }, plugin.UserWillLogInID) @@ -229,7 +229,7 @@ func (a *App) DoLogin(c *request.Context, w http.ResponseWriter, r *http.Request if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.UserHasLoggedIn(pluginContext, user) return true }, plugin.UserHasLoggedInID) diff --git a/app/onboarding.go b/app/onboarding.go index 9a9e25739c..d76525f017 100644 --- a/app/onboarding.go +++ b/app/onboarding.go @@ -53,7 +53,7 @@ func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnbo return } - hooks, err := pluginsEnvironment.HooksForPlugin(id) + hooks, err := a.ch.HooksForPluginOrProduct(id) if err != nil { mlog.Warn("Getting hooks for plugin failed", mlog.String("plugin_id", id), mlog.Err(err)) return diff --git a/app/platform/service.go b/app/platform/service.go index 0fa635d5d8..be3f4ed805 100644 --- a/app/platform/service.go +++ b/app/platform/service.go @@ -95,7 +95,12 @@ type PlatformService struct { additionalClusterHandlers map[model.ClusterEvent]einterfaces.ClusterMessageHandler sharedChannelService SharedChannelServiceIFace - pluginEnv *plugin.Environment + pluginEnv HookRunner +} + +type HookRunner interface { + RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) + GetPluginsEnvironment() *plugin.Environment } // New creates a new PlatformService. @@ -426,17 +431,17 @@ func (ps *PlatformService) SetSharedChannelService(s SharedChannelServiceIFace) ps.sharedChannelService = s } -func (ps *PlatformService) SetPluginsEnvironment(env *plugin.Environment) { - ps.pluginEnv = env +func (ps *PlatformService) SetPluginsEnvironment(runner HookRunner) { + ps.pluginEnv = runner } // GetPluginStatuses meant to be used by cluster implementation func (ps *PlatformService) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { - if ps.pluginEnv == nil { + if ps.pluginEnv == nil || ps.pluginEnv.GetPluginsEnvironment() == nil { return nil, model.NewAppError("GetPluginStatuses", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } - pluginStatuses, err := ps.pluginEnv.Statuses() + pluginStatuses, err := ps.pluginEnv.GetPluginsEnvironment().Statuses() if err != nil { return nil, model.NewAppError("GetPluginStatuses", "app.plugin.get_statuses.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/app/platform/web_conn.go b/app/platform/web_conn.go index d9fa60cd5d..4956d49a73 100644 --- a/app/platform/web_conn.go +++ b/app/platform/web_conn.go @@ -72,15 +72,15 @@ type WebConnConfig struct { // It contains all the necessary state to manage sending/receiving data to/from // a websocket. type WebConn struct { - sessionExpiresAt int64 // This should stay at the top for 64-bit alignment of 64-bit words accessed atomically - Platform *PlatformService - Suite SuiteIFace - PluginsEnvironment func() *plugin.Environment - WebSocket *websocket.Conn - T i18n.TranslateFunc - Locale string - Sequence int64 - UserId string + sessionExpiresAt int64 // This should stay at the top for 64-bit alignment of 64-bit words accessed atomically + Platform *PlatformService + Suite SuiteIFace + HookRunner HookRunner + WebSocket *websocket.Conn + T i18n.TranslateFunc + Locale string + Sequence int64 + UserId string allChannelMembers map[string]string lastAllChannelMembersTime int64 @@ -162,7 +162,7 @@ func (ps *PlatformService) PopulateWebConnConfig(s *model.Session, cfg *WebConnC } // NewWebConn returns a new WebConn instance. -func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, envFn func() *plugin.Environment) *WebConn { +func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, runner HookRunner) *WebConn { if cfg.Session.UserId != "" { ps.Go(func() { suite.SetStatusOnline(cfg.Session.UserId, false) @@ -200,7 +200,7 @@ func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, envF wc := &WebConn{ Platform: ps, Suite: suite, - PluginsEnvironment: envFn, + HookRunner: runner, send: cfg.activeQueue, deadQueue: cfg.deadQueue, deadQueuePointer: cfg.deadQueuePointer, @@ -222,14 +222,12 @@ func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, envF wc.SetSessionExpiresAt(cfg.Session.ExpiresAt) wc.SetConnectionID(cfg.ConnectionID) - if pluginsEnvironment := wc.PluginsEnvironment(); pluginsEnvironment != nil { - wc.Platform.Go(func() { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.OnWebSocketConnect(wc.GetConnectionID(), wc.UserId) - return true - }, plugin.OnWebSocketConnectID) - }) - } + wc.Platform.Go(func() { + wc.HookRunner.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.OnWebSocketConnect(wc.GetConnectionID(), wc.UserId) + return true + }, plugin.OnWebSocketConnectID) + }) return wc } @@ -238,12 +236,10 @@ func (wc *WebConn) pluginPostedConsumer(wg *sync.WaitGroup) { defer wg.Done() for msg := range wc.pluginPosted { - if pluginsEnvironment := wc.PluginsEnvironment(); pluginsEnvironment != nil { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.WebSocketMessageHasBeenPosted(msg.connectionID, msg.userID, msg.req) - return true - }, plugin.WebSocketMessageHasBeenPostedID) - } + wc.HookRunner.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.WebSocketMessageHasBeenPosted(msg.connectionID, msg.userID, msg.req) + return true + }, plugin.WebSocketMessageHasBeenPostedID) } } @@ -328,14 +324,12 @@ func (wc *WebConn) Pump() { wc.Platform.HubUnregister(wc) close(wc.pumpFinished) - if pluginsEnvironment := wc.PluginsEnvironment(); pluginsEnvironment != nil { - wc.Platform.Go(func() { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { - hooks.OnWebSocketDisconnect(wc.GetConnectionID(), wc.UserId) - return true - }, plugin.OnWebSocketDisconnectID) - }) - } + wc.Platform.Go(func() { + wc.HookRunner.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.OnWebSocketDisconnect(wc.GetConnectionID(), wc.UserId) + return true + }, plugin.OnWebSocketDisconnectID) + }) } func (wc *WebConn) readPump() { diff --git a/app/platform/web_conn_test.go b/app/platform/web_conn_test.go index b12232bd61..149f7d019d 100644 --- a/app/platform/web_conn_test.go +++ b/app/platform/web_conn_test.go @@ -5,6 +5,7 @@ package platform import ( "bytes" + "errors" "net" "net/http" "net/http/httptest" @@ -18,13 +19,27 @@ import ( "github.com/mattermost/mattermost-server/v6/plugin" ) +type hookRunner struct { +} + +func (h *hookRunner) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) { + +} +func (h *hookRunner) HooksForPlugin(id string) (plugin.Hooks, error) { + return nil, errors.New("not implemented") +} + +func (h *hookRunner) GetPluginsEnvironment() *plugin.Environment { + return nil +} + func TestWebConnAddDeadQueue(t *testing.T) { th := Setup(t) defer th.TearDown() wc := th.Service.NewWebConn(&WebConnConfig{ WebSocket: &websocket.Conn{}, - }, th.Suite, func() *plugin.Environment { return nil }) + }, th.Suite, &hookRunner{}) for i := 0; i < 2; i++ { msg := &model.WebSocketEvent{} @@ -53,7 +68,7 @@ func TestWebConnIsInDeadQueue(t *testing.T) { wc := th.Service.NewWebConn(&WebConnConfig{ WebSocket: &websocket.Conn{}, - }, th.Suite, func() *plugin.Environment { return nil }) + }, th.Suite, &hookRunner{}) var i int for ; i < 2; i++ { @@ -114,7 +129,7 @@ func TestWebConnClearDeadQueue(t *testing.T) { wc := th.Service.NewWebConn(&WebConnConfig{ WebSocket: &websocket.Conn{}, - }, th.Suite, func() *plugin.Environment { return nil }) + }, th.Suite, &hookRunner{}) var i int for ; i < 2; i++ { @@ -140,7 +155,7 @@ func TestWebConnDrainDeadQueue(t *testing.T) { cfg := &WebConnConfig{ WebSocket: c, } - return th.Service.NewWebConn(cfg, th.Suite, func() *plugin.Environment { return nil }) + return th.Service.NewWebConn(cfg, th.Suite, &hookRunner{}) } t.Run("Empty Queue", func(t *testing.T) { diff --git a/app/platform/web_hub_test.go b/app/platform/web_hub_test.go index f73836e1ad..e3f6e4ddbe 100644 --- a/app/platform/web_hub_test.go +++ b/app/platform/web_hub_test.go @@ -17,7 +17,6 @@ import ( platform_mocks "github.com/mattermost/mattermost-server/v6/app/platform/mocks" "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/shared/i18n" "github.com/mattermost/mattermost-server/v6/store/storetest/mocks" "github.com/mattermost/mattermost-server/v6/testlib" @@ -50,7 +49,7 @@ func registerDummyWebConn(t *testing.T, th *TestHelper, addr net.Addr, session * TFunc: i18n.IdentityTfunc(), Locale: "en", } - wc := th.Service.NewWebConn(cfg, th.Suite, func() *plugin.Environment { return nil }) + wc := th.Service.NewWebConn(cfg, th.Suite, &hookRunner{}) th.Service.HubRegister(wc) go wc.Pump() return wc diff --git a/app/plugin.go b/app/plugin.go index b48503de5d..93d47fd5b6 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -93,7 +93,7 @@ func (ch *Channels) SetPluginsEnvironment(pluginsEnvironment *plugin.Environment defer ch.pluginsLock.Unlock() ch.pluginsEnvironment = pluginsEnvironment - ch.srv.Platform().SetPluginsEnvironment(pluginsEnvironment) + ch.srv.Platform().SetPluginsEnvironment(ch) } func (ch *Channels) syncPluginsActiveState() { @@ -213,7 +213,7 @@ func (a *App) InitPlugins(c *request.Context, pluginDir, webappPluginDir string) func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir string) { // Acquiring lock manually, as plugins might be disabled. See GetPluginsEnvironment. defer func() { - ch.srv.Platform().SetPluginsEnvironment(ch.pluginsEnvironment) + ch.srv.Platform().SetPluginsEnvironment(ch) }() ch.pluginsLock.RLock() @@ -279,7 +279,7 @@ func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir s ch.syncPluginsActiveState() } if pluginsEnvironment := ch.GetPluginsEnvironment(); pluginsEnvironment != nil { - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + ch.RunMultiHook(func(hooks plugin.Hooks) bool { if err := hooks.OnConfigurationChange(); err != nil { ch.srv.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) } diff --git a/app/plugin_hooks_test.go b/app/plugin_hooks_test.go index 1b1673be93..7cd81f8fae 100644 --- a/app/plugin_hooks_test.go +++ b/app/plugin_hooks_test.go @@ -1234,7 +1234,7 @@ func TestHookRunDataRetention(t *testing.T) { require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) hookCalled := false - th.App.GetPluginsEnvironment().RunMultiPluginHook(func(hooks plugin.Hooks) bool { + th.App.Channels().RunMultiHook(func(hooks plugin.Hooks) bool { n, _ := hooks.RunDataRetention(0, 0) // Ensure return it correct assert.Equal(t, int64(100), n) @@ -1278,7 +1278,7 @@ func TestHookOnSendDailyTelemetry(t *testing.T) { require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) hookCalled := false - th.App.GetPluginsEnvironment().RunMultiPluginHook(func(hooks plugin.Hooks) bool { + th.App.Channels().RunMultiHook(func(hooks plugin.Hooks) bool { hooks.OnSendDailyTelemetry() hookCalled = true @@ -1322,7 +1322,7 @@ func TestHookOnCloudLimitsUpdated(t *testing.T) { require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) hookCalled := false - th.App.GetPluginsEnvironment().RunMultiPluginHook(func(hooks plugin.Hooks) bool { + th.App.Channels().RunMultiHook(func(hooks plugin.Hooks) bool { hooks.OnCloudLimitsUpdated(nil) hookCalled = true diff --git a/app/post.go b/app/post.go index 9fafd61f9b..ace950239e 100644 --- a/app/post.go +++ b/app/post.go @@ -270,7 +270,7 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel } var rejectionError *model.AppError pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { replacementPost, rejectionReason := hooks.MessageWillBePosted(pluginContext, post.ForPlugin()) if rejectionReason != "" { id := "Post rejected by plugin. " + rejectionReason @@ -332,7 +332,7 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel pluginPost := rpost.ForPlugin() a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.MessageHasBeenPosted(pluginContext, pluginPost) return true }, plugin.MessageHasBeenPostedID) @@ -661,7 +661,7 @@ func (a *App) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { var rejectionReason string pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { newPost, rejectionReason = hooks.MessageWillBeUpdated(pluginContext, newPost.ForPlugin(), oldPost.ForPlugin()) return post != nil }, plugin.MessageWillBeUpdatedID) @@ -689,7 +689,7 @@ func (a *App) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) pluginNewPost := newPost.ForPlugin() a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.MessageHasBeenUpdated(pluginContext, pluginNewPost, pluginOldPost) return true }, plugin.MessageHasBeenUpdatedID) diff --git a/app/reaction.go b/app/reaction.go index 0163c98519..80bc24b4b4 100644 --- a/app/reaction.go +++ b/app/reaction.go @@ -46,7 +46,7 @@ func (a *App) SaveReactionForPost(c *request.Context, reaction *model.Reaction) if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.ReactionHasBeenAdded(pluginContext, reaction) return true }, plugin.ReactionHasBeenAddedID) @@ -145,7 +145,7 @@ func (a *App) DeleteReactionForPost(c *request.Context, reaction *model.Reaction if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.ReactionHasBeenRemoved(pluginContext, reaction) return true }, plugin.ReactionHasBeenRemovedID) diff --git a/app/server.go b/app/server.go index 8699a013c6..a5e01db9b7 100644 --- a/app/server.go +++ b/app/server.go @@ -161,6 +161,8 @@ type Server struct { tracer *tracing.Tracer products map[string]Product + + hooksManager *product.HooksManager } func (s *Server) Store() store.Store { @@ -255,6 +257,8 @@ func NewServer(options ...Option) (*Server, error) { return nil, errors.Wrapf(err, "unable to create teams service") } + s.hooksManager = product.NewHooksManager(s.GetMetrics()) + // ensure app implements `product.UserService` var _ product.UserService = (*App)(nil) diff --git a/app/team.go b/app/team.go index 9c1d99ee6b..4376e2d77a 100644 --- a/app/team.go +++ b/app/team.go @@ -854,7 +854,7 @@ func (a *App) JoinUserToTeam(c request.CTX, team *model.Team, user *model.User, a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.UserHasJoinedTeam(pluginContext, teamMember, actor) return true }, plugin.UserHasJoinedTeamID) @@ -1228,7 +1228,7 @@ func (a *App) postProcessTeamMemberLeave(c request.CTX, teamMember *model.TeamMe a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.UserHasLeftTeam(pluginContext, teamMember, actor) return true }, plugin.UserHasLeftTeamID) diff --git a/app/upload.go b/app/upload.go index df5b329117..b63725b135 100644 --- a/app/upload.go +++ b/app/upload.go @@ -67,7 +67,7 @@ func (a *App) runPluginsHook(c *request.Context, info *model.FileInfo, file io.R var rejErr *model.AppError var once sync.Once pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { once.Do(func() { hookHasRunCh <- struct{}{} }) diff --git a/app/user.go b/app/user.go index ddc329ddc3..953446071d 100644 --- a/app/user.go +++ b/app/user.go @@ -311,7 +311,7 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { a.Srv().Go(func() { pluginContext := pluginContext(c) - pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { hooks.UserHasBeenCreated(pluginContext, ruser) return true }, plugin.UserHasBeenCreatedID) diff --git a/app/web_conn.go b/app/web_conn.go index 0edc3504ae..cdf59eb31e 100644 --- a/app/web_conn.go +++ b/app/web_conn.go @@ -16,5 +16,5 @@ func (a *App) PopulateWebConnConfig(s *model.Session, cfg *platform.WebConnConfi // NewWebConn returns a new WebConn instance. func (a *App) NewWebConn(cfg *platform.WebConnConfig) *platform.WebConn { - return a.Srv().Platform().NewWebConn(cfg, a, a.ch.GetPluginsEnvironment) + return a.Srv().Platform().NewWebConn(cfg, a, a.ch) } diff --git a/plugin/environment.go b/plugin/environment.go index 0b73b9c435..db07f051bd 100644 --- a/plugin/environment.go +++ b/plugin/environment.go @@ -50,7 +50,6 @@ type PrepackagedPlugin struct { // of active plugins. type Environment struct { registeredPlugins sync.Map - registeredProducts sync.Map pluginHealthCheckJob *PluginHealthCheckJob logger *mlog.Logger metrics einterfaces.MetricsInterface @@ -326,26 +325,6 @@ func (env *Environment) Activate(id string) (manifest *model.Manifest, activated return pluginInfo.Manifest, true, nil } -func (env *Environment) AddProduct(productID string, hooks any) error { - prod, err := newAdapter(hooks) - if err != nil { - return err - } - - rp := ®isteredProduct{ - productID: productID, - adapter: prod, - } - - env.registeredProducts.Store(productID, rp) - - return nil -} - -func (env *Environment) RemoveProduct(productID string) { - env.registeredProducts.Delete(productID) -} - func (env *Environment) RemovePlugin(id string) { if _, ok := env.registeredPlugins.Load(id); ok { env.registeredPlugins.Delete(id) @@ -499,12 +478,6 @@ func (env *Environment) HooksForPlugin(id string) (Hooks, error) { } } - if p, ok := env.registeredProducts.Load(id); ok { - rp := p.(*registeredProduct) - - return rp.adapter, nil - } - return nil, fmt.Errorf("plugin not found: %v", id) } @@ -533,24 +506,6 @@ func (env *Environment) RunMultiPluginHook(hookRunnerFunc func(hooks Hooks) bool return result }) - env.registeredProducts.Range(func(key, value any) bool { - rp := value.(*registeredProduct) - - if !rp.Implements(hookId) { - return true - } - - hookStartTime := time.Now() - result := hookRunnerFunc(rp.adapter) - - if env.metrics != nil { - elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second) - env.metrics.ObservePluginMultiHookIterationDuration(rp.productID, elapsedTime) - } - - return result - }) - if env.metrics != nil { elapsedTime := float64(time.Since(startTime)) / float64(time.Second) env.metrics.ObservePluginMultiHookDuration(elapsedTime) diff --git a/plugin/interface_generator/main.go b/plugin/interface_generator/main.go index 0dd367f9cd..3afcb4d559 100644 --- a/plugin/interface_generator/main.go +++ b/plugin/interface_generator/main.go @@ -399,13 +399,13 @@ type {{.Name}}IFace interface { {{end}} -type hooksAdapter struct { +type HooksAdapter struct { implemented map[int]struct{} productHooks any } -func newAdapter(productHooks any) (*hooksAdapter, error) { - a := &hooksAdapter{ +func NewAdapter(productHooks any) (*HooksAdapter, error) { + a := &HooksAdapter{ implemented: make(map[int]struct{}), productHooks: productHooks, } @@ -427,7 +427,7 @@ func newAdapter(productHooks any) (*hooksAdapter, error) { } {{range .HooksMethods}} -func (a *hooksAdapter) {{.Name}}{{funcStyle .Params}} {{funcStyle .Return}} { +func (a *HooksAdapter) {{.Name}}{{funcStyle .Params}} {{funcStyle .Return}} { if _, ok := a.implemented[{{.Name}}ID]; !ok { panic("product hooks must implement {{.Name}}") } diff --git a/plugin/product.go b/plugin/product.go index cd56dec569..557b7546af 100644 --- a/plugin/product.go +++ b/plugin/product.go @@ -7,13 +7,13 @@ import ( "net/http" ) -type registeredProduct struct { - productID string - adapter Hooks +type RegisteredProduct struct { + ProductID string + Adapter Hooks } -func (rp *registeredProduct) Implements(hookId int) bool { - adapter, ok := rp.adapter.(*hooksAdapter) +func (rp *RegisteredProduct) Implements(hookId int) bool { + adapter, ok := rp.Adapter.(*HooksAdapter) if !ok { return false } @@ -23,19 +23,19 @@ func (rp *registeredProduct) Implements(hookId int) bool { } // Implemented method is overridden intentionally to prevent calling it from outside. -func (a *hooksAdapter) Implemented() ([]string, error) { +func (a *HooksAdapter) Implemented() ([]string, error) { return nil, nil } // OnActivate is overridden intentionally as product should not call it. -func (a *hooksAdapter) OnActivate() error { +func (a *HooksAdapter) OnActivate() error { return nil } // OnDeactivate is overridden intentionally as product should not call it. -func (a *hooksAdapter) OnDeactivate() error { +func (a *HooksAdapter) OnDeactivate() error { return nil } // ServeHTTP is overridden intentionally as product should not call it. -func (a *hooksAdapter) ServeHTTP(c *Context, w http.ResponseWriter, r *http.Request) {} +func (a *HooksAdapter) ServeHTTP(c *Context, w http.ResponseWriter, r *http.Request) {} diff --git a/plugin/product_hooks_generated.go b/plugin/product_hooks_generated.go index 7f00e543aa..d3d51a930f 100644 --- a/plugin/product_hooks_generated.go +++ b/plugin/product_hooks_generated.go @@ -138,13 +138,13 @@ type GetTopicMetadataByIdsIFace interface { GetTopicMetadataByIds(c *Context, topicType string, topicIds []string) (map[string]*model.TopicMetadata, error) } -type hooksAdapter struct { +type HooksAdapter struct { implemented map[int]struct{} productHooks any } -func newAdapter(productHooks any) (*hooksAdapter, error) { - a := &hooksAdapter{ +func NewAdapter(productHooks any) (*HooksAdapter, error) { + a := &HooksAdapter{ implemented: make(map[int]struct{}), productHooks: productHooks, } @@ -433,7 +433,7 @@ func newAdapter(productHooks any) (*hooksAdapter, error) { return a, nil } -func (a *hooksAdapter) OnConfigurationChange() error { +func (a *HooksAdapter) OnConfigurationChange() error { if _, ok := a.implemented[OnConfigurationChangeID]; !ok { panic("product hooks must implement OnConfigurationChange") } @@ -442,7 +442,7 @@ func (a *hooksAdapter) OnConfigurationChange() error { } -func (a *hooksAdapter) ExecuteCommand(c *Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { +func (a *HooksAdapter) ExecuteCommand(c *Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { if _, ok := a.implemented[ExecuteCommandID]; !ok { panic("product hooks must implement ExecuteCommand") } @@ -451,7 +451,7 @@ func (a *hooksAdapter) ExecuteCommand(c *Context, args *model.CommandArgs) (*mod } -func (a *hooksAdapter) UserHasBeenCreated(c *Context, user *model.User) { +func (a *HooksAdapter) UserHasBeenCreated(c *Context, user *model.User) { if _, ok := a.implemented[UserHasBeenCreatedID]; !ok { panic("product hooks must implement UserHasBeenCreated") } @@ -460,7 +460,7 @@ func (a *hooksAdapter) UserHasBeenCreated(c *Context, user *model.User) { } -func (a *hooksAdapter) UserWillLogIn(c *Context, user *model.User) string { +func (a *HooksAdapter) UserWillLogIn(c *Context, user *model.User) string { if _, ok := a.implemented[UserWillLogInID]; !ok { panic("product hooks must implement UserWillLogIn") } @@ -469,7 +469,7 @@ func (a *hooksAdapter) UserWillLogIn(c *Context, user *model.User) string { } -func (a *hooksAdapter) UserHasLoggedIn(c *Context, user *model.User) { +func (a *HooksAdapter) UserHasLoggedIn(c *Context, user *model.User) { if _, ok := a.implemented[UserHasLoggedInID]; !ok { panic("product hooks must implement UserHasLoggedIn") } @@ -478,7 +478,7 @@ func (a *hooksAdapter) UserHasLoggedIn(c *Context, user *model.User) { } -func (a *hooksAdapter) MessageWillBePosted(c *Context, post *model.Post) (*model.Post, string) { +func (a *HooksAdapter) MessageWillBePosted(c *Context, post *model.Post) (*model.Post, string) { if _, ok := a.implemented[MessageWillBePostedID]; !ok { panic("product hooks must implement MessageWillBePosted") } @@ -487,7 +487,7 @@ func (a *hooksAdapter) MessageWillBePosted(c *Context, post *model.Post) (*model } -func (a *hooksAdapter) MessageWillBeUpdated(c *Context, newPost, oldPost *model.Post) (*model.Post, string) { +func (a *HooksAdapter) MessageWillBeUpdated(c *Context, newPost, oldPost *model.Post) (*model.Post, string) { if _, ok := a.implemented[MessageWillBeUpdatedID]; !ok { panic("product hooks must implement MessageWillBeUpdated") } @@ -496,7 +496,7 @@ func (a *hooksAdapter) MessageWillBeUpdated(c *Context, newPost, oldPost *model. } -func (a *hooksAdapter) MessageHasBeenPosted(c *Context, post *model.Post) { +func (a *HooksAdapter) MessageHasBeenPosted(c *Context, post *model.Post) { if _, ok := a.implemented[MessageHasBeenPostedID]; !ok { panic("product hooks must implement MessageHasBeenPosted") } @@ -505,7 +505,7 @@ func (a *hooksAdapter) MessageHasBeenPosted(c *Context, post *model.Post) { } -func (a *hooksAdapter) MessageHasBeenUpdated(c *Context, newPost, oldPost *model.Post) { +func (a *HooksAdapter) MessageHasBeenUpdated(c *Context, newPost, oldPost *model.Post) { if _, ok := a.implemented[MessageHasBeenUpdatedID]; !ok { panic("product hooks must implement MessageHasBeenUpdated") } @@ -514,7 +514,7 @@ func (a *hooksAdapter) MessageHasBeenUpdated(c *Context, newPost, oldPost *model } -func (a *hooksAdapter) ChannelHasBeenCreated(c *Context, channel *model.Channel) { +func (a *HooksAdapter) ChannelHasBeenCreated(c *Context, channel *model.Channel) { if _, ok := a.implemented[ChannelHasBeenCreatedID]; !ok { panic("product hooks must implement ChannelHasBeenCreated") } @@ -523,7 +523,7 @@ func (a *hooksAdapter) ChannelHasBeenCreated(c *Context, channel *model.Channel) } -func (a *hooksAdapter) UserHasJoinedChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) { +func (a *HooksAdapter) UserHasJoinedChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) { if _, ok := a.implemented[UserHasJoinedChannelID]; !ok { panic("product hooks must implement UserHasJoinedChannel") } @@ -532,7 +532,7 @@ func (a *hooksAdapter) UserHasJoinedChannel(c *Context, channelMember *model.Cha } -func (a *hooksAdapter) UserHasLeftChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) { +func (a *HooksAdapter) UserHasLeftChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) { if _, ok := a.implemented[UserHasLeftChannelID]; !ok { panic("product hooks must implement UserHasLeftChannel") } @@ -541,7 +541,7 @@ func (a *hooksAdapter) UserHasLeftChannel(c *Context, channelMember *model.Chann } -func (a *hooksAdapter) UserHasJoinedTeam(c *Context, teamMember *model.TeamMember, actor *model.User) { +func (a *HooksAdapter) UserHasJoinedTeam(c *Context, teamMember *model.TeamMember, actor *model.User) { if _, ok := a.implemented[UserHasJoinedTeamID]; !ok { panic("product hooks must implement UserHasJoinedTeam") } @@ -550,7 +550,7 @@ func (a *hooksAdapter) UserHasJoinedTeam(c *Context, teamMember *model.TeamMembe } -func (a *hooksAdapter) UserHasLeftTeam(c *Context, teamMember *model.TeamMember, actor *model.User) { +func (a *HooksAdapter) UserHasLeftTeam(c *Context, teamMember *model.TeamMember, actor *model.User) { if _, ok := a.implemented[UserHasLeftTeamID]; !ok { panic("product hooks must implement UserHasLeftTeam") } @@ -559,7 +559,7 @@ func (a *hooksAdapter) UserHasLeftTeam(c *Context, teamMember *model.TeamMember, } -func (a *hooksAdapter) FileWillBeUploaded(c *Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) { +func (a *HooksAdapter) FileWillBeUploaded(c *Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) { if _, ok := a.implemented[FileWillBeUploadedID]; !ok { panic("product hooks must implement FileWillBeUploaded") } @@ -568,7 +568,7 @@ func (a *hooksAdapter) FileWillBeUploaded(c *Context, info *model.FileInfo, file } -func (a *hooksAdapter) ReactionHasBeenAdded(c *Context, reaction *model.Reaction) { +func (a *HooksAdapter) ReactionHasBeenAdded(c *Context, reaction *model.Reaction) { if _, ok := a.implemented[ReactionHasBeenAddedID]; !ok { panic("product hooks must implement ReactionHasBeenAdded") } @@ -577,7 +577,7 @@ func (a *hooksAdapter) ReactionHasBeenAdded(c *Context, reaction *model.Reaction } -func (a *hooksAdapter) ReactionHasBeenRemoved(c *Context, reaction *model.Reaction) { +func (a *HooksAdapter) ReactionHasBeenRemoved(c *Context, reaction *model.Reaction) { if _, ok := a.implemented[ReactionHasBeenRemovedID]; !ok { panic("product hooks must implement ReactionHasBeenRemoved") } @@ -586,7 +586,7 @@ func (a *hooksAdapter) ReactionHasBeenRemoved(c *Context, reaction *model.Reacti } -func (a *hooksAdapter) OnPluginClusterEvent(c *Context, ev model.PluginClusterEvent) { +func (a *HooksAdapter) OnPluginClusterEvent(c *Context, ev model.PluginClusterEvent) { if _, ok := a.implemented[OnPluginClusterEventID]; !ok { panic("product hooks must implement OnPluginClusterEvent") } @@ -595,7 +595,7 @@ func (a *hooksAdapter) OnPluginClusterEvent(c *Context, ev model.PluginClusterEv } -func (a *hooksAdapter) OnWebSocketConnect(webConnID, userID string) { +func (a *HooksAdapter) OnWebSocketConnect(webConnID, userID string) { if _, ok := a.implemented[OnWebSocketConnectID]; !ok { panic("product hooks must implement OnWebSocketConnect") } @@ -604,7 +604,7 @@ func (a *hooksAdapter) OnWebSocketConnect(webConnID, userID string) { } -func (a *hooksAdapter) OnWebSocketDisconnect(webConnID, userID string) { +func (a *HooksAdapter) OnWebSocketDisconnect(webConnID, userID string) { if _, ok := a.implemented[OnWebSocketDisconnectID]; !ok { panic("product hooks must implement OnWebSocketDisconnect") } @@ -613,7 +613,7 @@ func (a *hooksAdapter) OnWebSocketDisconnect(webConnID, userID string) { } -func (a *hooksAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, req *model.WebSocketRequest) { +func (a *HooksAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, req *model.WebSocketRequest) { if _, ok := a.implemented[WebSocketMessageHasBeenPostedID]; !ok { panic("product hooks must implement WebSocketMessageHasBeenPosted") } @@ -622,7 +622,7 @@ func (a *hooksAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, r } -func (a *hooksAdapter) RunDataRetention(nowTime, batchSize int64) (int64, error) { +func (a *HooksAdapter) RunDataRetention(nowTime, batchSize int64) (int64, error) { if _, ok := a.implemented[RunDataRetentionID]; !ok { panic("product hooks must implement RunDataRetention") } @@ -631,7 +631,7 @@ func (a *hooksAdapter) RunDataRetention(nowTime, batchSize int64) (int64, error) } -func (a *hooksAdapter) OnInstall(c *Context, event model.OnInstallEvent) error { +func (a *HooksAdapter) OnInstall(c *Context, event model.OnInstallEvent) error { if _, ok := a.implemented[OnInstallID]; !ok { panic("product hooks must implement OnInstall") } @@ -640,7 +640,7 @@ func (a *hooksAdapter) OnInstall(c *Context, event model.OnInstallEvent) error { } -func (a *hooksAdapter) OnSendDailyTelemetry() { +func (a *HooksAdapter) OnSendDailyTelemetry() { if _, ok := a.implemented[OnSendDailyTelemetryID]; !ok { panic("product hooks must implement OnSendDailyTelemetry") } @@ -649,7 +649,7 @@ func (a *hooksAdapter) OnSendDailyTelemetry() { } -func (a *hooksAdapter) OnCloudLimitsUpdated(limits *model.ProductLimits) { +func (a *HooksAdapter) OnCloudLimitsUpdated(limits *model.ProductLimits) { if _, ok := a.implemented[OnCloudLimitsUpdatedID]; !ok { panic("product hooks must implement OnCloudLimitsUpdated") } @@ -658,7 +658,7 @@ func (a *hooksAdapter) OnCloudLimitsUpdated(limits *model.ProductLimits) { } -func (a *hooksAdapter) UserHasPermissionToCollection(c *Context, userID string, collectionType, collectionId string, permission *model.Permission) (bool, error) { +func (a *HooksAdapter) UserHasPermissionToCollection(c *Context, userID string, collectionType, collectionId string, permission *model.Permission) (bool, error) { if _, ok := a.implemented[UserHasPermissionToCollectionID]; !ok { panic("product hooks must implement UserHasPermissionToCollection") } @@ -667,7 +667,7 @@ func (a *hooksAdapter) UserHasPermissionToCollection(c *Context, userID string, } -func (a *hooksAdapter) GetAllCollectionIDsForUser(c *Context, userID, collectionType string) ([]string, error) { +func (a *HooksAdapter) GetAllCollectionIDsForUser(c *Context, userID, collectionType string) ([]string, error) { if _, ok := a.implemented[GetAllCollectionIDsForUserID]; !ok { panic("product hooks must implement GetAllCollectionIDsForUser") } @@ -676,7 +676,7 @@ func (a *hooksAdapter) GetAllCollectionIDsForUser(c *Context, userID, collection } -func (a *hooksAdapter) GetAllUserIdsForCollection(c *Context, collectionType, collectionID string) ([]string, error) { +func (a *HooksAdapter) GetAllUserIdsForCollection(c *Context, collectionType, collectionID string) ([]string, error) { if _, ok := a.implemented[GetAllUserIdsForCollectionID]; !ok { panic("product hooks must implement GetAllUserIdsForCollection") } @@ -685,7 +685,7 @@ func (a *hooksAdapter) GetAllUserIdsForCollection(c *Context, collectionType, co } -func (a *hooksAdapter) GetTopicRedirect(c *Context, topicType, topicID string) (string, error) { +func (a *HooksAdapter) GetTopicRedirect(c *Context, topicType, topicID string) (string, error) { if _, ok := a.implemented[GetTopicRedirectID]; !ok { panic("product hooks must implement GetTopicRedirect") } @@ -694,7 +694,7 @@ func (a *hooksAdapter) GetTopicRedirect(c *Context, topicType, topicID string) ( } -func (a *hooksAdapter) GetCollectionMetadataByIds(c *Context, collectionType string, collectionIds []string) (map[string]*model.CollectionMetadata, error) { +func (a *HooksAdapter) GetCollectionMetadataByIds(c *Context, collectionType string, collectionIds []string) (map[string]*model.CollectionMetadata, error) { if _, ok := a.implemented[GetCollectionMetadataByIdsID]; !ok { panic("product hooks must implement GetCollectionMetadataByIds") } @@ -703,7 +703,7 @@ func (a *hooksAdapter) GetCollectionMetadataByIds(c *Context, collectionType str } -func (a *hooksAdapter) GetTopicMetadataByIds(c *Context, topicType string, topicIds []string) (map[string]*model.TopicMetadata, error) { +func (a *HooksAdapter) GetTopicMetadataByIds(c *Context, topicType string, topicIds []string) (map[string]*model.TopicMetadata, error) { if _, ok := a.implemented[GetTopicMetadataByIdsID]; !ok { panic("product hooks must implement GetTopicMetadataByIds") } diff --git a/product/hooks.go b/product/hooks.go new file mode 100644 index 0000000000..a2515c9a7e --- /dev/null +++ b/product/hooks.go @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package product + +import ( + "sync" + "time" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/plugin" +) + +type HooksManager struct { + registeredProducts sync.Map + metrics einterfaces.MetricsInterface +} + +func NewHooksManager(metrics einterfaces.MetricsInterface) *HooksManager { + return &HooksManager{ + metrics: metrics, + } +} + +func (m *HooksManager) AddProduct(productID string, hooks any) error { + prod, err := plugin.NewAdapter(hooks) + if err != nil { + return err + } + + rp := &plugin.RegisteredProduct{ + ProductID: productID, + Adapter: prod, + } + + m.registeredProducts.Store(productID, rp) + + return nil +} + +func (m *HooksManager) RemoveProduct(productID string) { + m.registeredProducts.Delete(productID) +} + +func (m *HooksManager) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) { + startTime := time.Now() + + m.registeredProducts.Range(func(key, value any) bool { + rp := value.(*plugin.RegisteredProduct) + + if !rp.Implements(hookId) { + return true + } + + hookStartTime := time.Now() + result := hookRunnerFunc(rp.Adapter) + + if m.metrics != nil { + elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second) + m.metrics.ObservePluginMultiHookIterationDuration(rp.ProductID, elapsedTime) + } + + return result + }) + + if m.metrics != nil { + elapsedTime := float64(time.Since(startTime)) / float64(time.Second) + m.metrics.ObservePluginMultiHookDuration(elapsedTime) + } +} + +func (m *HooksManager) HooksForProduct(id string) plugin.Hooks { + if value, ok := m.registeredProducts.Load(id); ok { + rp := value.(*plugin.RegisteredProduct) + return rp.Adapter + } + + return nil +} From fc8268990e541ba9d798e2e0c0555e965c61efa3 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 5 Dec 2022 14:34:51 -0500 Subject: [PATCH 60/80] Add check for subscription change to product with sku cloud-starter. --- api4/cloud.go | 18 ++++++++++++++++-- model/cloud.go | 8 ++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/api4/cloud.go b/api4/cloud.go index 173d975279..0dd56c3055 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/binary" "encoding/json" + "fmt" "io" "net/http" "time" @@ -96,6 +97,8 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { } func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { + userId := c.AppContext.Session().UserId + if !c.App.Channels().License().IsCloud() { c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.license_error", nil, "", http.StatusInternalServerError) return @@ -118,13 +121,13 @@ func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { return } - currentSubscription, appErr := c.App.Cloud().GetSubscription(c.AppContext.Session().UserId) + currentSubscription, appErr := c.App.Cloud().GetSubscription(userId) if appErr != nil { c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) return } - changedSub, err := c.App.Cloud().ChangeSubscription(c.AppContext.Session().UserId, currentSubscription.ID, subscriptionChange) + changedSub, err := c.App.Cloud().ChangeSubscription(userId, currentSubscription.ID, subscriptionChange) if err != nil { c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) return @@ -136,6 +139,17 @@ func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { return } + changedProduct, err := c.App.Cloud().GetCloudProduct(userId, changedSub.ProductID) + if err != nil { + c.Err = model.NewAppError("Api4.changeSubscription", "api_cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + starterSku := fmt.Sprintf("%s-%s", model.SubscriptionFamilyCloud, model.ProductSkuStarter) + if changedProduct.SKU != starterSku { + w.Write(json) + return + } + // Log failures for purchase confirmation email, but don't show an error to the user so as not to confuse them // At this point, the upgrade is complete. if appErr := c.App.SendUpgradeConfirmationEmail(); appErr != nil { diff --git a/model/cloud.go b/model/cloud.go index 2985c929ea..d4e7d614e0 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -40,6 +40,14 @@ const ( SubscriptionFamilyOnPrem = SubscriptionFamily("on-prem") ) +type ProductSku string + +const ( + ProductSkuStarter = ProductSku("starter") + ProductSkuProfessional = ProductSku("professional") + ProductSkuEnterprise = ProductSku("enterprise") +) + // Product model represents a product on the cloud system. type Product struct { ID string `json:"id"` From 352eedf7c1bd90b88fc05a7a36e39a26c86acafa Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 5 Dec 2022 14:53:38 -0500 Subject: [PATCH 61/80] Fix app error id. --- api4/cloud.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api4/cloud.go b/api4/cloud.go index 0dd56c3055..7f4a013047 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -141,7 +141,7 @@ func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { changedProduct, err := c.App.Cloud().GetCloudProduct(userId, changedSub.ProductID) if err != nil { - c.Err = model.NewAppError("Api4.changeSubscription", "api_cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } starterSku := fmt.Sprintf("%s-%s", model.SubscriptionFamilyCloud, model.ProductSkuStarter) From 47b81256594124ef48f51e6ca00bf9462edde783 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 5 Dec 2022 15:28:36 -0500 Subject: [PATCH 62/80] Fix check for starter sku. --- api4/cloud.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api4/cloud.go b/api4/cloud.go index cf48f44d7f..20d9687946 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -145,7 +145,7 @@ func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { } starterSku := fmt.Sprintf("%s-%s", model.SubscriptionFamilyCloud, model.ProductSkuStarter) - if product.SKU != starterSku { + if product.SKU == starterSku { w.Write(json) return } From c3c81cb3d6a29ef8164a8e7ef4ba172d60117176 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Tue, 6 Dec 2022 12:44:48 -0500 Subject: [PATCH 63/80] MM-48120/MM-48623 Patch plugin bundle react-dom on webapp extract (#21171) Automatic Merge --- app/plugin.go | 10 +++- app/plugin_api_test.go | 12 ++--- app/plugin_hooks_test.go | 4 +- model/config.go | 5 ++ plugin/environment.go | 71 ++++++++++++++++++++++++++-- services/telemetry/telemetry.go | 1 + services/telemetry/telemetry_test.go | 1 + web/web_test.go | 2 +- 8 files changed, 93 insertions(+), 13 deletions(-) diff --git a/app/plugin.go b/app/plugin.go index 93d47fd5b6..7ce6c47fe6 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -243,7 +243,15 @@ func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir s return New(ServerConnector(ch)).NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newAPIFunc, NewDriverImpl(ch.srv), pluginDir, webappPluginDir, ch.srv.Log(), ch.srv.GetMetrics()) + env, err := plugin.NewEnvironment( + newAPIFunc, + NewDriverImpl(ch.srv), + pluginDir, + webappPluginDir, + *ch.cfgSvc.Config().ExperimentalSettings.PatchPluginsReactDOM, + ch.srv.Log(), + ch.srv.GetMetrics(), + ) if err != nil { mlog.Error("Failed to start up plugins", mlog.Err(err)) return diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index 9f864109bf..759613781b 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -92,7 +92,7 @@ func setupMultiPluginAPITest(t *testing.T, pluginCodes []string, pluginManifests return app.NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, false, app.Log(), nil) require.NoError(t, err) require.Equal(t, len(pluginCodes), len(pluginIDs)) @@ -849,7 +849,7 @@ func TestPluginAPIGetPlugins(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, false, th.App.Log(), nil) require.NoError(t, err) pluginIDs := []string{"pluginid1", "pluginid2", "pluginid3"} @@ -937,7 +937,7 @@ func TestInstallPlugin(t *testing.T) { return app.NewPluginAPI(c, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, false, app.Log(), nil) require.NoError(t, err) app.ch.SetPluginsEnvironment(env) @@ -1632,7 +1632,7 @@ func TestAPIMetrics(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, false, th.App.Log(), metricsMock) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) @@ -2079,7 +2079,7 @@ func TestRegisterCollectionAndTopic(t *testing.T) { return th.App.NewPluginAPI(th.Context, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, false, th.App.Log(), nil) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) @@ -2179,7 +2179,7 @@ func TestPluginUploadsAPI(t *testing.T) { newPluginAPI := func(manifest *model.Manifest) plugin.API { return th.App.NewPluginAPI(th.Context, manifest) } - env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, false, th.App.Log(), nil) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) diff --git a/app/plugin_hooks_test.go b/app/plugin_hooks_test.go index 7cd81f8fae..161994a1ab 100644 --- a/app/plugin_hooks_test.go +++ b/app/plugin_hooks_test.go @@ -33,7 +33,7 @@ func SetAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, a webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) - env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) + env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, false, app.Log(), nil) require.NoError(t, err) app.ch.SetPluginsEnvironment(env) @@ -1030,7 +1030,7 @@ func TestHookMetrics(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) + env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, false, th.App.Log(), metricsMock) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) diff --git a/model/config.go b/model/config.go index 058a7b7666..dd0ad5cfd3 100644 --- a/model/config.go +++ b/model/config.go @@ -968,6 +968,7 @@ type ExperimentalSettings struct { EnableSharedChannels *bool `access:"experimental_features"` EnableRemoteClusterService *bool `access:"experimental_features"` EnableAppBar *bool `access:"experimental_features"` + PatchPluginsReactDOM *bool `access:"experimental_features"` } func (s *ExperimentalSettings) SetDefaults() { @@ -1002,6 +1003,10 @@ func (s *ExperimentalSettings) SetDefaults() { if s.EnableAppBar == nil { s.EnableAppBar = NewBool(false) } + + if s.PatchPluginsReactDOM == nil { + s.PatchPluginsReactDOM = NewBool(false) + } } type AnalyticsSettings struct { diff --git a/plugin/environment.go b/plugin/environment.go index db07f051bd..4ccbbdf023 100644 --- a/plugin/environment.go +++ b/plugin/environment.go @@ -4,10 +4,12 @@ package plugin import ( + "bytes" "fmt" "hash/fnv" "os" "path/filepath" + "strings" "sync" "time" @@ -57,15 +59,20 @@ type Environment struct { dbDriver Driver pluginDir string webappPluginDir string + patchReactDOM bool prepackagedPlugins []*PrepackagedPlugin prepackagedPluginsLock sync.RWMutex } -func NewEnvironment(newAPIImpl apiImplCreatorFunc, +func NewEnvironment( + newAPIImpl apiImplCreatorFunc, dbDriver Driver, - pluginDir string, webappPluginDir string, + pluginDir string, + webappPluginDir string, + patchReactDOM bool, logger *mlog.Logger, - metrics einterfaces.MetricsInterface) (*Environment, error) { + metrics einterfaces.MetricsInterface, +) (*Environment, error) { return &Environment{ logger: logger, metrics: metrics, @@ -73,6 +80,7 @@ func NewEnvironment(newAPIImpl apiImplCreatorFunc, dbDriver: dbDriver, pluginDir: pluginDir, webappPluginDir: webappPluginDir, + patchReactDOM: patchReactDOM, }, nil } @@ -451,6 +459,17 @@ func (env *Environment) UnpackWebappBundle(id string) (*model.Manifest, error) { return nil, errors.Wrapf(err, "unable to read webapp bundle: %v", id) } + if env.patchReactDOM { + newContents, changed := patchReactDOM(sourceBundleFileContents) + if changed { + sourceBundleFileContents = newContents + err = os.WriteFile(sourceBundleFilepath, sourceBundleFileContents, 0644) + if err != nil { + return nil, errors.Wrapf(err, "unable to overwrite webapp bundle: %v", id) + } + } + } + hash := fnv.New64a() if _, err = hash.Write(sourceBundleFileContents); err != nil { return nil, errors.Wrapf(err, "unable to generate hash for webapp bundle: %v", id) @@ -467,6 +486,52 @@ func (env *Environment) UnpackWebappBundle(id string) (*model.Manifest, error) { return manifest, nil } +func patchReactDOM(initialBytes []byte) ([]byte, bool) { + if !bytes.Contains(initialBytes, []byte("react-dom.production.min.js")) { + return initialBytes, false + } + + initial := string(initialBytes) + nameIndex := strings.Index(initial, "react-dom.production.min.js") + + beginning := strings.LastIndex(initial[:nameIndex], "{") + var end int + + argDefBeginning := strings.LastIndex(initial[:beginning], "function") + 9 + argDefEnd := strings.LastIndex(initial[:beginning], ")") - 1 + argsNames := strings.Split(initial[argDefBeginning:argDefEnd], ",") + if len(argsNames) != 3 { + return initialBytes, false + } + + exportsArgName := strings.TrimSpace(argsNames[1]) + + numOpenBraces := 0 + for i, c := range initial[beginning:] { + if end != 0 { + break + } + switch c { + case '}': + numOpenBraces-- + + if numOpenBraces == 0 { + end = beginning + i + } + case '{': + numOpenBraces++ + } + } + + beforePatch := initial[:end] + afterPatch := initial[end:] + + patch := fmt.Sprintf("; Object.assign(%s, window.ReactDOM)", exportsArgName) + + result := fmt.Sprintf("%s%s%s", beforePatch, patch, afterPatch) + return []byte(result), true +} + // HooksForPlugin returns the hooks API for the plugin with the given id. // // Consider using RunMultiPluginHook instead. diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index ee8fea6f11..ce9eb79419 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -734,6 +734,7 @@ func (ts *TelemetryService) trackConfig() { "enable_shared_channels": *cfg.ExperimentalSettings.EnableSharedChannels, "enable_remote_cluster_service": *cfg.ExperimentalSettings.EnableRemoteClusterService && cfg.FeatureFlags.EnableRemoteClusterService, "enable_app_bar": *cfg.ExperimentalSettings.EnableAppBar, + "patch_plugins_react_dom": *cfg.ExperimentalSettings.PatchPluginsReactDOM, }) ts.SendTelemetry(TrackConfigAnalytics, map[string]any{ diff --git a/services/telemetry/telemetry_test.go b/services/telemetry/telemetry_test.go index 31ab7656ad..32a86602a3 100644 --- a/services/telemetry/telemetry_test.go +++ b/services/telemetry/telemetry_test.go @@ -165,6 +165,7 @@ func initializeMocks(cfg *model.Config, cloudLicense bool) (*mocks.ServerIface, func(m *model.Manifest) plugin.API { return pluginsAPIMock }, nil, pluginDir, webappPluginDir, + false, logger, nil) serverIfaceMock.On("GetPluginsEnvironment").Return(pluginEnv, nil) diff --git a/web/web_test.go b/web/web_test.go index 139753675d..ae8e322f91 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -280,7 +280,7 @@ func TestPublicFilesRequest(t *testing.T) { defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) - env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) + env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server), pluginDir, webappPluginDir, false, th.App.Log(), nil) require.NoError(t, err) pluginID := "com.mattermost.sample" From 82d9f2580764f6b3bec911b39dd4df822b05ad77 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 6 Dec 2022 13:03:00 -0500 Subject: [PATCH 64/80] Replase product skus with their full names. --- api4/cloud.go | 4 +--- model/cloud.go | 12 +++++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/api4/cloud.go b/api4/cloud.go index 20d9687946..d7a265e24f 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -7,7 +7,6 @@ import ( "bytes" "encoding/binary" "encoding/json" - "fmt" "io" "net/http" "time" @@ -144,8 +143,7 @@ func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { c.Logger.Error("Error finding the new cloud product", mlog.Err(err)) } - starterSku := fmt.Sprintf("%s-%s", model.SubscriptionFamilyCloud, model.ProductSkuStarter) - if product.SKU == starterSku { + if product.SKU == string(model.SkuCloudStarter) { w.Write(json) return } diff --git a/model/cloud.go b/model/cloud.go index d4e7d614e0..4494b33dea 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -43,9 +43,15 @@ const ( type ProductSku string const ( - ProductSkuStarter = ProductSku("starter") - ProductSkuProfessional = ProductSku("professional") - ProductSkuEnterprise = ProductSku("enterprise") + SkuStarterGov = ProductSku("starter-gov") + SkuProfessionalGov = ProductSku("professional-gov") + SkuEnterpriseGov = ProductSku("enterprise-gov") + SkuStarter = ProductSku("starter") + SkuProfessional = ProductSku("professional") + SkuEnterprise = ProductSku("enterprise") + SkuCloudStarter = ProductSku("cloud-starter") + SkuCloudProfessional = ProductSku("cloud-professional") + SkuCloudEnterprise = ProductSku("cloud-enterprise") ) // Product model represents a product on the cloud system. From 03a5b4a288ba60c52e604e5ef017cf5fc179db51 Mon Sep 17 00:00:00 2001 From: Shivashis Padhi Date: Wed, 7 Dec 2022 03:17:26 +0530 Subject: [PATCH 65/80] [MM-48727] Restore license checks for SSO in self-hosted deployments (#21781) * Revert license checks for sso integrations - Revert license check changes for open-id connect options - Add option to integrate openid-connect for all cloud offerings * Move enterprise SSO features back to enterprise repository --- cmd/mattermost/main.go | 3 - config/client.go | 31 +- model/license.go | 24 +- model/oauthproviders/google/google.go | 158 -------- model/oauthproviders/google/google_test.go | 61 --- model/oauthproviders/office365/office365.go | 116 ------ .../office365/office365_test.go | 43 --- model/oauthproviders/openid/openid.go | 243 ------------ model/oauthproviders/openid/openid_test.go | 352 ------------------ 9 files changed, 35 insertions(+), 996 deletions(-) delete mode 100644 model/oauthproviders/google/google.go delete mode 100644 model/oauthproviders/google/google_test.go delete mode 100644 model/oauthproviders/office365/office365.go delete mode 100644 model/oauthproviders/office365/office365_test.go delete mode 100644 model/oauthproviders/openid/openid.go delete mode 100644 model/oauthproviders/openid/openid_test.go diff --git a/cmd/mattermost/main.go b/cmd/mattermost/main.go index b5f73a31f9..441ed9a752 100644 --- a/cmd/mattermost/main.go +++ b/cmd/mattermost/main.go @@ -11,9 +11,6 @@ import ( _ "github.com/mattermost/mattermost-server/v6/app/slashcommands" // Plugins _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" - _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/google" - _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/office365" - _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/openid" // Enterprise Imports _ "github.com/mattermost/mattermost-server/v6/imports" diff --git a/config/client.go b/config/client.go index 7d05dda917..b8160993fd 100644 --- a/config/client.go +++ b/config/client.go @@ -303,11 +303,11 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["SamlLoginButtonColor"] = "" props["SamlLoginButtonBorderColor"] = "" props["SamlLoginButtonTextColor"] = "" - props["EnableSignUpWithOpenId"] = strconv.FormatBool(*c.OpenIdSettings.Enable) - props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor - props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText - props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable) - props["EnableSignUpWithOffice365"] = strconv.FormatBool(*c.Office365Settings.Enable) + props["EnableSignUpWithGoogle"] = "false" + props["EnableSignUpWithOffice365"] = "false" + props["EnableSignUpWithOpenId"] = "false" + props["OpenIdButtonText"] = "" + props["OpenIdButtonColor"] = "" props["CWSURL"] = "" props["EnableCustomBrand"] = strconv.FormatBool(*c.TeamSettings.EnableCustomBrand) props["CustomBrandText"] = *c.TeamSettings.CustomBrandText @@ -342,6 +342,27 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m if *license.Features.MFA { props["EnforceMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnforceMultifactorAuthentication) } + + if license.IsCloud() { + // MM-48727: enable SSO options for free cloud - not in self hosted + *license.Features.GoogleOAuth = true + *license.Features.Office365OAuth = true + *license.Features.OpenId = true + } + + if *license.Features.GoogleOAuth { + props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable) + } + + if *license.Features.Office365OAuth { + props["EnableSignUpWithOffice365"] = strconv.FormatBool(*c.Office365Settings.Enable) + } + + if *license.Features.OpenId { + props["EnableSignUpWithOpenId"] = strconv.FormatBool(*c.OpenIdSettings.Enable) + props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor + props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText + } } for key, value := range c.FeatureFlags.ToMap() { diff --git a/model/license.go b/model/license.go index d04a88acef..94f0b81da4 100644 --- a/model/license.go +++ b/model/license.go @@ -78,18 +78,12 @@ type TrialLicenseRequest struct { } type Features struct { - Users *int `json:"users"` - LDAP *bool `json:"ldap"` - LDAPGroups *bool `json:"ldap_groups"` - MFA *bool `json:"mfa"` - - // Deprecated: This feature will be removed from the license because it's available without a license. - GoogleOAuth *bool `json:"google_oauth"` - - // Deprecated: This feature will be removed from the license because it's available without a license. - Office365OAuth *bool `json:"office365_oauth"` - - // Deprecated: This feature will be removed from the license because it's available without a license. + Users *int `json:"users"` + LDAP *bool `json:"ldap"` + LDAPGroups *bool `json:"ldap_groups"` + MFA *bool `json:"mfa"` + GoogleOAuth *bool `json:"google_oauth"` + Office365OAuth *bool `json:"office365_oauth"` OpenId *bool `json:"openid"` Compliance *bool `json:"compliance"` Cluster *bool `json:"cluster"` @@ -171,15 +165,15 @@ func (f *Features) SetDefaults() { } if f.GoogleOAuth == nil { - f.GoogleOAuth = NewBool(true) + f.GoogleOAuth = NewBool(*f.FutureFeatures) } if f.Office365OAuth == nil { - f.Office365OAuth = NewBool(true) + f.Office365OAuth = NewBool(*f.FutureFeatures) } if f.OpenId == nil { - f.OpenId = NewBool(true) + f.OpenId = NewBool(*f.FutureFeatures) } if f.Compliance == nil { diff --git a/model/oauthproviders/google/google.go b/model/oauthproviders/google/google.go deleted file mode 100644 index 19c4cf3353..0000000000 --- a/model/oauthproviders/google/google.go +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package oauthgoogle - -import ( - "encoding/json" - "errors" - "io" - "strings" - - "github.com/mattermost/mattermost-server/v6/einterfaces" - "github.com/mattermost/mattermost-server/v6/model" -) - -type GoogleProvider struct { -} - -type SourceElement struct { - Type string `json:"type"` - ID string `json:"id"` - Etag string `json:"etag"` - ProfileMetadata ProfileMetadata `json:"profileMetadata"` -} - -type ProfileMetadata struct { - ObjectType string `json:"objectType"` - UserTypes []string `json:"userTypes"` -} - -type GoogleUserRootMetadata struct { - Sources []SourceElement `json:"sources"` -} - -type GoogleUserMetadata struct { - Source map[string]string `json:"source"` -} - -type GoogleUserNameNode struct { - Metadata GoogleUserMetadata `json:"metadata"` - GivenName string `json:"givenName"` - FamilyName string `json:"familyName"` -} - -type GoogleGenericInfoNode struct { - Metadata GoogleUserMetadata `json:"metadata"` - Value string `json:"value"` -} - -type GoogleUser struct { - Metadata GoogleUserRootMetadata `json:"metadata"` - Nicknames []GoogleGenericInfoNode `json:"nicknames"` - Emails []GoogleGenericInfoNode `json:"emailAddresses"` - Names []GoogleUserNameNode `json:"names"` -} - -func init() { - provider := &GoogleProvider{} - einterfaces.RegisterOAuthProvider(model.ServiceGoogle, provider) -} - -func userFromGoogleUser(gu *GoogleUser) *model.User { - user := &model.User{} - - for _, e := range gu.Emails { - if e.Metadata.Source["type"] == "ACCOUNT" || e.Metadata.Source["type"] == "DOMAIN_PROFILE" { - user.Email = e.Value - user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) - break - } - } - - for _, e := range gu.Names { - if e.Metadata.Source["type"] == "PROFILE" || e.Metadata.Source["type"] == "DOMAIN_PROFILE" { - user.FirstName = e.GivenName - user.LastName = e.FamilyName - break - } - } - - if len(gu.Nicknames) > 0 { - user.Nickname = gu.Nicknames[0].Value - } - - user.AuthData = new(string) - *user.AuthData = gu.getAuthData() - user.AuthService = model.ServiceGoogle - - return user -} - -func googleUserFromJSON(data io.Reader) (*GoogleUser, error) { - decoder := json.NewDecoder(data) - var gu GoogleUser - err := decoder.Decode(&gu) - if err != nil { - return nil, err - } - - return &gu, nil -} - -func (gu *GoogleUser) IsValid() error { - if len(gu.Metadata.Sources) == 0 { - return errors.New("invalid metadata sources") - } - - if len(gu.Emails) == 0 { - return errors.New("invalid emails") - } - - return nil -} - -func (gu *GoogleUser) getAuthData() string { - if len(gu.Metadata.Sources) > 0 { - return gu.Metadata.Sources[0].ID - } - - return "" -} - -func (m *GoogleProvider) GetIdentifier() string { - return model.ServiceGoogle -} - -func (m *GoogleProvider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { - gu, err := googleUserFromJSON(data) - if err != nil { - return nil, err - } - return userFromGoogleUser(gu), nil -} - -func (m *GoogleProvider) GetAuthDataFromJSON(data io.Reader) (string, error) { - gu, err := googleUserFromJSON(data) - if err != nil { - return "", err - } - - if err = gu.IsValid(); err != nil { - return "", err - } - - return gu.getAuthData(), nil -} - -func (m *GoogleProvider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { - return &config.GoogleSettings, nil -} - -func (m *GoogleProvider) GetUserFromIdToken(idToken string) (*model.User, error) { - return nil, nil -} - -func (m *GoogleProvider) IsSameUser(dbUser, oauthUser *model.User) bool { - return dbUser.AuthData == oauthUser.AuthData -} diff --git a/model/oauthproviders/google/google_test.go b/model/oauthproviders/google/google_test.go deleted file mode 100644 index 1f30efb8eb..0000000000 --- a/model/oauthproviders/google/google_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package oauthgoogle - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestGoogleUserFromJSON(t *testing.T) { - gu := GoogleUser{ - Metadata: GoogleUserRootMetadata{ - Sources: []SourceElement{ - { - Etag: "tag", - }, - }, - }, - Emails: []GoogleGenericInfoNode{ - { - Value: "ali@test.com", - }, - }, - Names: []GoogleUserNameNode{ - { - GivenName: "ali", - }, - }, - Nicknames: []GoogleGenericInfoNode{ - { - Value: "ila", - }, - }, - } - - provider := &GoogleProvider{} - - t.Run("valid google user", func(t *testing.T) { - b, err := json.Marshal(gu) - require.NoError(t, err) - - _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) - require.NoError(t, err) - - _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) - require.NoError(t, err) - }) - - t.Run("empty body should fail without panic", func(t *testing.T) { - _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) - require.NoError(t, err) - - _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) - require.Error(t, err) - }) -} diff --git a/model/oauthproviders/office365/office365.go b/model/oauthproviders/office365/office365.go deleted file mode 100644 index 7f23d9e51c..0000000000 --- a/model/oauthproviders/office365/office365.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package oauthoffice365 - -import ( - "encoding/json" - "errors" - "io" - "strings" - - "github.com/mattermost/mattermost-server/v6/einterfaces" - "github.com/mattermost/mattermost-server/v6/model" -) - -type Office365Provider struct { -} - -type Office365User struct { - Id string `json:"id"` - FirstName string `json:"givenName"` - LastName string `json:"surname"` - Mail string `json:"mail"` - UserPrincipalName string `json:"userPrincipalName"` -} - -func init() { - provider := &Office365Provider{} - einterfaces.RegisterOAuthProvider(model.ServiceOffice365, provider) -} - -func userFromOffice365User(of *Office365User) *model.User { - user := &model.User{} - user.FirstName = of.FirstName - user.LastName = of.LastName - - if of.Mail != "" { - user.Email = of.Mail - } else if strings.Contains(of.UserPrincipalName, "@") { - user.Email = of.UserPrincipalName - } - - if user.Email != "" { - user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) - } - - user.AuthData = new(string) - *user.AuthData = of.Id - user.AuthService = model.ServiceOffice365 - - return user -} - -func office365UserFromJSON(data io.Reader) (*Office365User, error) { - decoder := json.NewDecoder(data) - var of Office365User - err := decoder.Decode(&of) - if err != nil { - return nil, err - } - - return &of, nil -} - -func (of *Office365User) IsValid() error { - if of.Id == "" { - return errors.New("invalid user id") - } - - if of.Mail == "" && !strings.Contains(of.UserPrincipalName, "@") { - return errors.New("invalid email") - } - - return nil -} - -func (of *Office365User) getAuthData() string { - return of.Id -} - -func (m *Office365Provider) GetIdentifier() string { - return model.ServiceOffice365 -} - -func (m *Office365Provider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { - of, err := office365UserFromJSON(data) - if err != nil { - return nil, err - } - return userFromOffice365User(of), nil -} - -func (m *Office365Provider) GetAuthDataFromJSON(data io.Reader) (string, error) { - of, err := office365UserFromJSON(data) - if err != nil { - return "", err - } - - if err = of.IsValid(); err != nil { - return "", err - } - - return of.getAuthData(), nil -} - -func (m *Office365Provider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { - return config.Office365Settings.SSOSettings(), nil -} - -func (m *Office365Provider) GetUserFromIdToken(idToken string) (*model.User, error) { - return nil, nil -} - -func (m *Office365Provider) IsSameUser(dbUser, oauthUser *model.User) bool { - return dbUser.AuthData == oauthUser.AuthData -} diff --git a/model/oauthproviders/office365/office365_test.go b/model/oauthproviders/office365/office365_test.go deleted file mode 100644 index 85496dd7ab..0000000000 --- a/model/oauthproviders/office365/office365_test.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package oauthoffice365 - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestOffice365UserFromJSON(t *testing.T) { - ou := Office365User{ - FirstName: "ali", - Id: "12345", - LastName: "maya", - Mail: "ali@test.com", - } - - provider := &Office365Provider{} - - t.Run("valid office365 user", func(t *testing.T) { - b, err := json.Marshal(ou) - require.NoError(t, err) - - _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) - require.NoError(t, err) - - _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) - require.NoError(t, err) - }) - - t.Run("empty body should fail without panic", func(t *testing.T) { - _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) - require.NoError(t, err) - - _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) - require.Error(t, err) - }) -} diff --git a/model/oauthproviders/openid/openid.go b/model/oauthproviders/openid/openid.go deleted file mode 100644 index e48be5d913..0000000000 --- a/model/oauthproviders/openid/openid.go +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package oauthopenid - -import ( - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" - "strconv" - "strings" - "time" - - "github.com/mattermost/mattermost-server/v6/einterfaces" - "github.com/mattermost/mattermost-server/v6/model" -) - -type CacheData struct { - Service string - Expires int64 - Settings model.SSOSettings -} - -type OpenIdMetadata struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - UserEndpoint string `json:"userinfo_endpoint"` - JwksURI string `json:"jwks_uri"` - Algorithms []string `json:"id_token_signing_alg_values_supported"` -} - -type OpenIdProvider struct { - CacheData *CacheData -} - -type OpenIdUser struct { - Id string `json:"sub"` - Oid string `json:"oid"` //Office 365 only - FirstName string `json:"given_name"` - LastName string `json:"family_name"` - Name string `json:"name"` - Nickname string `json:"nickname"` - Email string `json:"email"` -} - -func init() { - provider := &OpenIdProvider{} - einterfaces.RegisterOAuthProvider(model.ServiceOpenid, provider) -} - -func (o *OpenIdProvider) userFromOpenIdUser(u *OpenIdUser) *model.User { - user := &model.User{} - - user.Email = u.Email - user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) - if o.CacheData.Service == model.ServiceGitlab && u.Nickname != "" { - user.Username = u.Nickname - } - - user.FirstName = u.FirstName - user.LastName = u.LastName - user.Nickname = u.Nickname - - user.AuthData = new(string) - *user.AuthData = o.getAuthData(u) - - return user -} - -func (o *OpenIdProvider) getAuthData(u *OpenIdUser) string { - if o.CacheData.Service == model.ServiceOffice365 { - if u.Oid != "" { - return u.Oid - } - } - return u.Id -} - -func openIDUserFromJSON(data io.Reader) (*OpenIdUser, error) { - decoder := json.NewDecoder(data) - var u OpenIdUser - err := decoder.Decode(&u) - if err != nil { - return nil, err - } - return &u, nil -} - -func (u *OpenIdUser) IsValid() error { - if u.Id == "" { - return errors.New("invalid id") - } - - if u.Email == "" { - return errors.New("invalid emails") - } - return nil -} - -func (u *OpenIdUser) GetIdentifier() string { - return model.ServiceOpenid -} - -func (o *OpenIdProvider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { - oid, err := openIDUserFromJSON(data) - if err != nil { - return nil, err - } - jsonUser := o.userFromOpenIdUser(oid) - - if tokenUser != nil { - jsonUser = o.combineUsers(jsonUser, tokenUser) - } - return jsonUser, nil -} - -func (o *OpenIdProvider) combineUsers(jsonUser *model.User, tokenUser *model.User) *model.User { - if o.CacheData.Service == model.ServiceOffice365 { - jsonUser.AuthData = tokenUser.AuthData - } - return jsonUser -} - -func (o *OpenIdProvider) GetAuthDataFromJSON(data io.Reader) (string, error) { - u, err := openIDUserFromJSON(data) - if err != nil { - return "", err - } - - err = u.IsValid() - if err != nil { - return "", err - } - return o.getAuthData(u), nil -} - -// GetSSOSettings returns SSO Settings from Cache or Discovery Document -func (o *OpenIdProvider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { - settings := config.OpenIdSettings - if service == model.ServiceOffice365 { - settings = *config.Office365Settings.SSOSettings() - } else if service == model.ServiceGoogle { - settings = config.GoogleSettings - } else if service == model.ServiceGitlab { - settings = config.GitLabSettings - } - - if o.CacheData != nil && !settingsChanged(*o.CacheData, service, settings) && o.CacheData.Expires > time.Now().Unix() { - return &o.CacheData.Settings, nil - } - - var age int64 = 0 - if *settings.DiscoveryEndpoint != "" { - response, err := http.Get(*settings.DiscoveryEndpoint) - if err != nil { - return nil, err - } - defer response.Body.Close() - - for _, v := range strings.Split(response.Header.Get("Cache-Control"), ",") { - if strings.Contains(v, "max-age") { - ageValue := strings.Split(v, "=")[1] - age, _ = strconv.ParseInt(ageValue, 10, 64) - } - } - responseData, err := io.ReadAll(response.Body) - if err != nil { - return nil, err - } - - var openIDResponse OpenIdMetadata - err = json.Unmarshal(responseData, &openIDResponse) - if err != nil { - return nil, err - } - - settings.AuthEndpoint = &openIDResponse.AuthorizationEndpoint - settings.TokenEndpoint = &openIDResponse.TokenEndpoint - settings.UserAPIEndpoint = &openIDResponse.UserEndpoint - } - expires := time.Now().Unix() + age - - o.CacheData = &CacheData{ - Service: service, - Expires: expires, - Settings: settings, - } - return &settings, nil -} - -func settingsChanged(cacheData CacheData, service string, configSettings model.SSOSettings) bool { - if cacheData.Service == service && - cacheData.Settings.DiscoveryEndpoint == configSettings.DiscoveryEndpoint && - cacheData.Settings.Secret == configSettings.Secret && - cacheData.Settings.Id == configSettings.Id { - return false - } - return true -} - -func (o *OpenIdProvider) GetUserFromIdToken(idToken string) (*model.User, error) { - parts := strings.Split(idToken, ".") - if len(parts) != 3 { - return nil, errors.New("invalid Id Token") - } - - b, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, err - } - - claims := &OpenIdUser{} - json.Unmarshal(b, &claims) - - return o.userFromOpenIdUser(claims), nil -} - -func (o *OpenIdProvider) IsSameUser(dbUser, oauthUser *model.User) bool { - // Office365 OAuth would store Ids without dashes. (ie. 0e8fddd450d344999a93a390ee8cb83d) - // Office365 OpenId will return as a formatted GUID (ie. '0e8fddd4-50d3-4499-9a93-a390ee8cb83d') - // If this is a UUID that starts with all zero. (ie. 00000000-0000-0000-be95-fe607df5dbeb) - // For backwards compatibility we store the auth data from OAuth as be95fe607df5dbeb - if dbUser.AuthData == nil || oauthUser.AuthData == nil { - return false - } - dbID := *dbUser.AuthData - oauthID := *oauthUser.AuthData - if dbID == "" || oauthID == "" { - return false - } - parts := strings.Split(oauthID, "-") - for _, part := range parts { - if strings.Count(part, "0") != len(part) { - if !strings.Contains(dbID, part) { - return false - } - } - } - return true -} diff --git a/model/oauthproviders/openid/openid_test.go b/model/oauthproviders/openid/openid_test.go deleted file mode 100644 index 7e157c6a88..0000000000 --- a/model/oauthproviders/openid/openid_test.go +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package oauthopenid - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/mattermost/mattermost-server/v6/model" -) - -func TestGetAuthData(t *testing.T) { - ou := OpenIdUser{ - Id: "12345", - FirstName: "firstname", - LastName: "lastname", - Nickname: "nickname", - Email: "name@test.com", - Oid: "0e8fddd4-50d3-4499-9a93-a390ee8cb83d", - } - - provider := &OpenIdProvider{ - CacheData: &CacheData{ - Service: model.ServiceGitlab, - }, - } - - t.Run("validate return id", func(t *testing.T) { - authData := provider.getAuthData(&ou) - assert.Equal(t, ou.Id, authData) - }) - - provider.CacheData.Service = model.ServiceOffice365 - - fmt.Println(provider.CacheData.Service) - t.Run("validate Oid return", func(t *testing.T) { - authData := provider.getAuthData(&ou) - assert.Equal(t, ou.Oid, authData) - }) -} -func TestOpenIdUserFromJSON(t *testing.T) { - ou := OpenIdUser{ - Id: "12345", - FirstName: "firstname", - LastName: "lastname", - Nickname: "nickname", - Email: "name@test.com", - } - - provider := &OpenIdProvider{ - CacheData: &CacheData{ - Service: model.ServiceOpenid, - }, - } - - t.Run("valid OpenId user", func(t *testing.T) { - b, err := json.Marshal(ou) - require.NoError(t, err) - - _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) - require.NoError(t, err) - - _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) - require.NoError(t, err) - }) - - t.Run("empty body should fail without panic", func(t *testing.T) { - _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) - require.NoError(t, err) - - _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) - require.Error(t, err) - }) - - t.Run("test getUserFromIdToken", func(t *testing.T) { - header := "dummyHeader" - payload := "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlNjb3R0IiwiZmFtaWx5X25hbWUiOiJCaXNoZWwiLCJsb2NhbGUiOiJlbiIsImlhdCI6MTYwODI0OTg5MSwiZXhwIjoxNjA4MjUzNDkxfQ" - signature := "dummysignature" - - testToken := header - _, err := provider.GetUserFromIdToken(testToken) - require.Error(t, err) - - testToken = header + "." + payload - _, err = provider.GetUserFromIdToken(testToken) - require.Error(t, err) - - t.Run("non ascii string encoded in the payload", func(t *testing.T) { - cases := []struct { - payload string - expectedName string - }{ - { - payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6InRlc3TFiMWhxb4iLCJmYW1pbHlfbmFtZSI6IkJpc2hlbCIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNjA4MjQ5ODkxLCJleHAiOjE2MDgyNTM0OTF9", - expectedName: "testňšž", - }, - { - payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlNjb3R0IiwiZmFtaWx5X25hbWUiOiJCaXNoZWwiLCJsb2NhbGUiOiJlbiIsImlhdCI6MTYwODI0OTg5MSwiZXhwIjoxNjA4MjUzNDkxfQ", - expectedName: "Scott", - }, - { - payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6InRlc3TEjcSNxI0iLCJmYW1pbHlfbmFtZSI6IkJpc2hlbCIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNjA4MjQ5ODkxLCJleHAiOjE2MDgyNTM0OTF9", - expectedName: "testččč", - }, - } - for _, c := range cases { - testToken = header + "." + c.payload + "." + signature - user, err := provider.GetUserFromIdToken(testToken) - require.NoError(t, err) - require.NotNil(t, user) - require.Equal(t, c.expectedName, user.FirstName) - } - }) - - }) -} - -func TestGetSSOSettings(t *testing.T) { - provider := &OpenIdProvider{ - CacheData: &CacheData{ - Service: model.ServiceOpenid, - }, - } - validJSON := `{ - "issuer": "issuer", - "authorization_endpoint": "authorization_endpoint", - "token_endpoint": "token_endpoint", - "userinfo_endpoint": "userinfo_endpoint", - "jwks_uri": "jwks_uri", - "id_token_signing_alg_values_supported": ["RS256"] - }` - var validFunctionCalled int - validServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Cache-Control", "max-age=3600") - fmt.Fprintln(w, validJSON) - validFunctionCalled++ - })) - defer validServer.Close() - - validConfig := model.Config{ - OpenIdSettings: model.SSOSettings{ - Enable: model.NewBool(true), - Secret: model.NewString("secret string"), - Id: model.NewString("id"), - Scope: model.NewString("profile openid email"), - AuthEndpoint: model.NewString(""), - TokenEndpoint: model.NewString(""), - UserAPIEndpoint: model.NewString(""), - DiscoveryEndpoint: model.NewString(validServer.URL), - }, - } - - t.Run("Error", func(t *testing.T) { - errorFunctionCalled := 0 - errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - errorFunctionCalled++ - w.Header().Add("Cache-Control", "max-age=3600") - http.Error(w, "Not found", 404) - })) - - errCfg := validConfig - errCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(errorServer.URL) - _, err := provider.GetSSOSettings(&errCfg, model.ServiceOpenid) - assert.Error(t, err) - assert.Equal(t, 1, errorFunctionCalled) - }) - - t.Run("UseCache", func(t *testing.T) { - validFunctionCalled = 0 - - settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) - assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) - assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) - assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) - assert.Equal(t, 1, validFunctionCalled) - // Should set cache - assert.Equal(t, provider.CacheData.Settings, *settings) - assert.True(t, provider.CacheData.Expires > 0) - currentCacheExpires := provider.CacheData.Expires - - // Call again should come from cache - settings, _ = provider.GetSSOSettings(&validConfig, model.ServiceOpenid) - assert.Equal(t, provider.CacheData.Settings, *settings) - assert.Equal(t, currentCacheExpires, provider.CacheData.Expires) - // should still be 1 - assert.Equal(t, 1, validFunctionCalled) - }) - - t.Run("CacheExpired", func(t *testing.T) { - // reset to original cache settings - settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) - // Should set cache - assert.Equal(t, provider.CacheData.Settings, *settings) - - // set cache to expired - provider.CacheData.Expires = time.Now().Add(time.Duration(-1) * time.Minute).Unix() - - // same config, should call endpoint - validFunctionCalled = 0 - provider.GetSSOSettings(&validConfig, model.ServiceOpenid) - assert.Equal(t, 1, validFunctionCalled) - assert.True(t, provider.CacheData.Expires > time.Now().Unix()) - }) - - t.Run("NoCache", func(t *testing.T) { - noCacheFunctionCalled := 0 - noCacheServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, validJSON) - noCacheFunctionCalled++ - })) - defer noCacheServer.Close() - - newCfg := validConfig - newCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(noCacheServer.URL) - - settings, err := provider.GetSSOSettings(&newCfg, model.ServiceOpenid) - require.NoError(t, err) - assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) - assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) - assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) - assert.Equal(t, 1, noCacheFunctionCalled) - // Should set cache - assert.Equal(t, provider.CacheData.Settings, *settings) - // Cache Expires, set, less than, equal now. - assert.True(t, provider.CacheData.Expires <= time.Now().Unix()) - - // Call again, should call server again - _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) - require.NoError(t, err) - assert.Equal(t, 2, noCacheFunctionCalled) - }) - - t.Run("ChangeService", func(t *testing.T) { - // reset to original cache settings - settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) - // Should set cache - assert.Equal(t, provider.CacheData.Settings, *settings) - assert.True(t, provider.CacheData.Expires > time.Now().Unix()) - - // create identical setting for Google - googleCfg := model.Config{ - GoogleSettings: model.SSOSettings{}, - } - googleCfg.GoogleSettings = validConfig.OpenIdSettings - - // call with different service, same config settings - validFunctionCalled = 0 - provider.GetSSOSettings(&googleCfg, model.ServiceGoogle) - assert.Equal(t, model.ServiceGoogle, provider.CacheData.Service) - assert.Equal(t, 1, validFunctionCalled) - }) - - t.Run("ChangeConfigSettings", func(t *testing.T) { - secondFunctionCalled := 0 - secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Cache-Control", "max-age=3600") - fmt.Fprintln(w, validJSON) - secondFunctionCalled++ - })) - defer secondServer.Close() - - newCfg := validConfig - newCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(secondServer.URL) - - // new URL - settings, err := provider.GetSSOSettings(&newCfg, model.ServiceOpenid) - require.NoError(t, err) - assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) - assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) - assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) - assert.Equal(t, 1, secondFunctionCalled) - - // new secret - newCfg.OpenIdSettings.Secret = model.NewString("NewSecret") - _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) - require.NoError(t, err) - assert.Equal(t, newCfg.OpenIdSettings.Secret, provider.CacheData.Settings.Secret) - assert.Equal(t, 2, secondFunctionCalled) - - // new Id - newCfg.OpenIdSettings.Id = model.NewString("NewId") - _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) - require.NoError(t, err) - assert.Equal(t, newCfg.OpenIdSettings.Id, provider.CacheData.Settings.Id) - assert.Equal(t, 3, secondFunctionCalled) - }) -} - -func TestCacheControlPanic(t *testing.T) { - provider := &OpenIdProvider{ - CacheData: &CacheData{ - Service: model.ServiceOpenid, - }, - } - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "no header") - })) - defer ts.Close() - - cfg := &model.Config{ - OpenIdSettings: model.SSOSettings{ - DiscoveryEndpoint: model.NewString(ts.URL), - }, - } - - require.NotPanics(t, func() { - provider.GetSSOSettings(cfg, model.ServiceOpenid) - }) -} - -func TestIsSameUser(t *testing.T) { - provider := &OpenIdProvider{ - CacheData: &CacheData{ - Service: model.ServiceOpenid, - }, - } - cases := []struct { - dbUser model.User - oauthUser model.User - verified bool - }{ - {model.User{AuthData: model.NewString("202993a800824dc1b4496d598d47c58a")}, model.User{AuthData: model.NewString("202993a8-0082-4dc1-b449-6d598d47c58a")}, true}, - {model.User{AuthData: model.NewString("202993a85a824dc1b4496d598d47c58a")}, model.User{AuthData: model.NewString("")}, false}, - {model.User{AuthData: model.NewString("")}, model.User{AuthData: model.NewString("202993a8-5a82-4dc1-b449-6d598d47c58a")}, false}, - {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be95-fe607df5dbeb")}, true}, - {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be90-fe607df5dbeb")}, false}, - {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be95-fe607df5dbe0")}, false}, - {model.User{AuthData: model.NewString("hello")}, model.User{}, false}, - } - for _, c := range cases { - verified := provider.IsSameUser(&c.dbUser, &c.oauthUser) - if verified != c.verified { - if c.verified { - t.Logf("'%v' should have matched '%v'", c.dbUser, c.oauthUser) - } else { - t.Logf("'%v' should not have matched '%v'", c.dbUser, c.oauthUser) - } - t.FailNow() - } - } -} From 617c0244765c33a981f6cce60756c9a10218435f Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Wed, 7 Dec 2022 10:00:47 +0300 Subject: [PATCH 66/80] app: avoid plugin env check for RunMultiHook and use pluginslock while accessing plugin env (#21803) * app/channels: use pluginslock while accessing plugins environment * when using RunMultiHook we don't need to do a nil check on plugin env * trigger ci --- app/channel.go | 88 ++++++++++++++++-------------------- app/channels.go | 4 +- app/file.go | 42 ++++++++--------- app/login.go | 33 ++++++-------- app/plugin.go | 15 +++--- app/post.go | 118 ++++++++++++++++++++++-------------------------- app/reaction.go | 32 ++++++------- app/team.go | 48 +++++++++----------- app/upload.go | 5 -- app/user.go | 16 +++---- 10 files changed, 179 insertions(+), 222 deletions(-) diff --git a/app/channel.go b/app/channel.go index 273555f6b8..fdc8d17da3 100644 --- a/app/channel.go +++ b/app/channel.go @@ -343,15 +343,13 @@ func (a *App) CreateChannel(c request.CTX, channel *model.Channel, addMember boo a.InvalidateCacheForUser(channel.CreatorId) } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.ChannelHasBeenCreated(pluginContext, sc) - return true - }, plugin.ChannelHasBeenCreatedID) - }) - } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.ChannelHasBeenCreated(pluginContext, sc) + return true + }, plugin.ChannelHasBeenCreatedID) + }) return sc, nil } @@ -429,15 +427,13 @@ func (a *App) handleCreationEvent(c request.CTX, userID, otherUserID string, cha a.InvalidateCacheForUser(userID) a.InvalidateCacheForUser(otherUserID) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.ChannelHasBeenCreated(pluginContext, channel) - return true - }, plugin.ChannelHasBeenCreatedID) - }) - } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.ChannelHasBeenCreated(pluginContext, channel) + return true + }, plugin.ChannelHasBeenCreatedID) + }) message := model.NewWebSocketEvent(model.WebsocketEventDirectAdded, "", channel.Id, "", nil, "") message.Add("creator_id", userID) @@ -1599,15 +1595,13 @@ func (a *App) AddChannelMember(c request.CTX, userID string, channel *model.Chan return nil, err } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.UserHasJoinedChannel(pluginContext, cm, userRequestor) - return true - }, plugin.UserHasJoinedChannelID) - }) - } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasJoinedChannel(pluginContext, cm, userRequestor) + return true + }, plugin.UserHasJoinedChannelID) + }) if opts.UserRequestorID == "" || userID == opts.UserRequestorID { if err := a.postJoinChannelMessage(c, user, channel); err != nil { @@ -2177,15 +2171,13 @@ func (a *App) JoinChannel(c request.CTX, channel *model.Channel, userID string) return err } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.UserHasJoinedChannel(pluginContext, cm, nil) - return true - }, plugin.UserHasJoinedChannelID) - }) - } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasJoinedChannel(pluginContext, cm, nil) + return true + }, plugin.UserHasJoinedChannelID) + }) if err := a.postJoinChannelMessage(c, user, channel); err != nil { return err @@ -2484,21 +2476,19 @@ func (a *App) removeUserFromChannel(c request.CTX, userIDToRemove string, remove a.InvalidateCacheForUser(userIDToRemove) a.invalidateCacheForChannelMembers(channel.Id) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var actorUser *model.User - if removerUserId != "" { - actorUser, _ = a.GetUser(removerUserId) - } - - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.UserHasLeftChannel(pluginContext, cm, actorUser) - return true - }, plugin.UserHasLeftChannelID) - }) + var actorUser *model.User + if removerUserId != "" { + actorUser, _ = a.GetUser(removerUserId) } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasLeftChannel(pluginContext, cm, actorUser) + return true + }, plugin.UserHasLeftChannelID) + }) + message := model.NewWebSocketEvent(model.WebsocketEventUserRemoved, "", channel.Id, "", nil, "") message.Add("user_id", userIDToRemove) message.Add("remover_id", removerUserId) diff --git a/app/channels.go b/app/channels.go index 31ef2d429c..e4a25f3749 100644 --- a/app/channels.go +++ b/app/channels.go @@ -326,7 +326,7 @@ func (s *hooksService) RegisterHooks(productID string, hooks any) error { } func (ch *Channels) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, hookId int) { - if env := ch.pluginsEnvironment; env != nil { + if env := ch.GetPluginsEnvironment(); env != nil { env.RunMultiPluginHook(hookRunnerFunc, hookId) } @@ -336,7 +336,7 @@ func (ch *Channels) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks) bool, h func (ch *Channels) HooksForPluginOrProduct(id string) (plugin.Hooks, error) { var hooks plugin.Hooks - if env := ch.pluginsEnvironment; env != nil { + if env := ch.GetPluginsEnvironment(); env != nil { // we intentionally ignore the error here, because the id can be a product id // we are going to check if we have the hooks or not hooks, _ = env.HooksForPlugin(id) diff --git a/app/file.go b/app/file.go index 67a227cab0..06a516bc86 100644 --- a/app/file.go +++ b/app/file.go @@ -895,29 +895,27 @@ func (a *App) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTe info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(info.MimeType) } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var rejectionError *model.AppError - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - var newBytes bytes.Buffer - replacementInfo, rejectionReason := hooks.FileWillBeUploaded(pluginContext, info, bytes.NewReader(data), &newBytes) - if rejectionReason != "" { - rejectionError = model.NewAppError("DoUploadFile", "File rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) - return false - } - if replacementInfo != nil { - info = replacementInfo - } - if newBytes.Len() != 0 { - data = newBytes.Bytes() - info.Size = int64(len(data)) - } - - return true - }, plugin.FileWillBeUploadedID) - if rejectionError != nil { - return nil, data, rejectionError + var rejectionError *model.AppError + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + var newBytes bytes.Buffer + replacementInfo, rejectionReason := hooks.FileWillBeUploaded(pluginContext, info, bytes.NewReader(data), &newBytes) + if rejectionReason != "" { + rejectionError = model.NewAppError("DoUploadFile", "File rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) + return false } + if replacementInfo != nil { + info = replacementInfo + } + if newBytes.Len() != 0 { + data = newBytes.Bytes() + info.Size = int64(len(data)) + } + + return true + }, plugin.FileWillBeUploadedID) + if rejectionError != nil { + return nil, data, rejectionError } if _, err := a.WriteFile(bytes.NewReader(data), info.Path); err != nil { diff --git a/app/login.go b/app/login.go index 98279d7f2f..e0854bef37 100644 --- a/app/login.go +++ b/app/login.go @@ -157,17 +157,15 @@ func (a *App) GetUserForLogin(id, loginId string) (*model.User, *model.AppError) } func (a *App) DoLogin(c *request.Context, w http.ResponseWriter, r *http.Request, user *model.User, deviceID string, isMobile, isOAuthUser, isSaml bool) *model.AppError { - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var rejectionReason string - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - rejectionReason = hooks.UserWillLogIn(pluginContext, user) - return rejectionReason == "" - }, plugin.UserWillLogInID) + var rejectionReason string + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + rejectionReason = hooks.UserWillLogIn(pluginContext, user) + return rejectionReason == "" + }, plugin.UserWillLogInID) - if rejectionReason != "" { - return model.NewAppError("DoLogin", "Login rejected by plugin: "+rejectionReason, nil, "", http.StatusBadRequest) - } + if rejectionReason != "" { + return model.NewAppError("DoLogin", "Login rejected by plugin: "+rejectionReason, nil, "", http.StatusBadRequest) } session := &model.Session{UserId: user.Id, Roles: user.GetRawRoles(), DeviceId: deviceID, IsOAuth: false, Props: map[string]string{ @@ -226,15 +224,12 @@ func (a *App) DoLogin(c *request.Context, w http.ResponseWriter, r *http.Request }) } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.UserHasLoggedIn(pluginContext, user) - return true - }, plugin.UserHasLoggedInID) - }) - } + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasLoggedIn(pluginContext, user) + return true + }, plugin.UserHasLoggedInID) + }) return nil } diff --git a/app/plugin.go b/app/plugin.go index 7ce6c47fe6..679576e723 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -286,14 +286,13 @@ func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir s ch.installFeatureFlagPlugins() ch.syncPluginsActiveState() } - if pluginsEnvironment := ch.GetPluginsEnvironment(); pluginsEnvironment != nil { - ch.RunMultiHook(func(hooks plugin.Hooks) bool { - if err := hooks.OnConfigurationChange(); err != nil { - ch.srv.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) - } - return true - }, plugin.OnConfigurationChangeID) - } + + ch.RunMultiHook(func(hooks plugin.Hooks) bool { + if err := hooks.OnConfigurationChange(); err != nil { + ch.srv.Log().Error("Plugin OnConfigurationChange hook failed", mlog.Err(err)) + } + return true + }, plugin.OnConfigurationChangeID) }) ch.pluginsLock.Unlock() diff --git a/app/post.go b/app/post.go index ace950239e..a0daea37c6 100644 --- a/app/post.go +++ b/app/post.go @@ -263,38 +263,36 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel post.Metadata.Priority = nil } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var metadata *model.PostMetadata - if post.Metadata != nil { - metadata = post.Metadata.Copy() - } - var rejectionError *model.AppError - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - replacementPost, rejectionReason := hooks.MessageWillBePosted(pluginContext, post.ForPlugin()) - if rejectionReason != "" { - id := "Post rejected by plugin. " + rejectionReason - if rejectionReason == plugin.DismissPostError { - id = plugin.DismissPostError - } - rejectionError = model.NewAppError("createPost", id, nil, "", http.StatusBadRequest) - return false + var metadata *model.PostMetadata + if post.Metadata != nil { + metadata = post.Metadata.Copy() + } + var rejectionError *model.AppError + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + replacementPost, rejectionReason := hooks.MessageWillBePosted(pluginContext, post.ForPlugin()) + if rejectionReason != "" { + id := "Post rejected by plugin. " + rejectionReason + if rejectionReason == plugin.DismissPostError { + id = plugin.DismissPostError } - if replacementPost != nil { - post = replacementPost - if post.Metadata != nil && metadata != nil { - post.Metadata.Priority = metadata.Priority - } else { - post.Metadata = metadata - } - } - - return true - }, plugin.MessageWillBePostedID) - - if rejectionError != nil { - return nil, rejectionError + rejectionError = model.NewAppError("createPost", id, nil, "", http.StatusBadRequest) + return false } + if replacementPost != nil { + post = replacementPost + if post.Metadata != nil && metadata != nil { + post.Metadata.Priority = metadata.Priority + } else { + post.Metadata = metadata + } + } + + return true + }, plugin.MessageWillBePostedID) + + if rejectionError != nil { + return nil, rejectionError } // Pre-fill the CreateAt field for link previews to get the correct timestamp. @@ -328,16 +326,13 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel // We make a copy of the post for the plugin hook to avoid a race condition, // and to remove the non-GOB-encodable Metadata from it. - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - pluginPost := rpost.ForPlugin() - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.MessageHasBeenPosted(pluginContext, pluginPost) - return true - }, plugin.MessageHasBeenPostedID) - }) - } + pluginPost := rpost.ForPlugin() + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.MessageHasBeenPosted(pluginContext, pluginPost) + return true + }, plugin.MessageHasBeenPostedID) + }) if a.Metrics() != nil { a.Metrics().IncrementPostCreate() @@ -658,20 +653,18 @@ func (a *App) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) oldPost.RemoteId = model.NewString(*post.RemoteId) } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var rejectionReason string - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - newPost, rejectionReason = hooks.MessageWillBeUpdated(pluginContext, newPost.ForPlugin(), oldPost.ForPlugin()) - return post != nil - }, plugin.MessageWillBeUpdatedID) - if newPost == nil { - return nil, model.NewAppError("UpdatePost", "Post rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) - } - // Restore the post metadata that was stripped by the plugin. Set it to - // the last known good. - newPost.Metadata = oldPost.Metadata + var rejectionReason string + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + newPost, rejectionReason = hooks.MessageWillBeUpdated(pluginContext, newPost.ForPlugin(), oldPost.ForPlugin()) + return post != nil + }, plugin.MessageWillBeUpdatedID) + if newPost == nil { + return nil, model.NewAppError("UpdatePost", "Post rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) } + // Restore the post metadata that was stripped by the plugin. Set it to + // the last known good. + newPost.Metadata = oldPost.Metadata rpost, nErr := a.Srv().Store().Post().Update(newPost, oldPost) if nErr != nil { @@ -684,17 +677,14 @@ func (a *App) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) } } - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - pluginOldPost := oldPost.ForPlugin() - pluginNewPost := newPost.ForPlugin() - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.MessageHasBeenUpdated(pluginContext, pluginNewPost, pluginOldPost) - return true - }, plugin.MessageHasBeenUpdatedID) - }) - } + pluginOldPost := oldPost.ForPlugin() + pluginNewPost := newPost.ForPlugin() + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.MessageHasBeenUpdated(pluginContext, pluginNewPost, pluginOldPost) + return true + }, plugin.MessageHasBeenUpdatedID) + }) rpost = a.PreparePostForClientWithEmbedsAndImages(c, rpost, false, true, true) diff --git a/app/reaction.go b/app/reaction.go index 80bc24b4b4..fc6d54699f 100644 --- a/app/reaction.go +++ b/app/reaction.go @@ -43,15 +43,13 @@ func (a *App) SaveReactionForPost(c *request.Context, reaction *model.Reaction) // The post is always modified since the UpdateAt always changes a.invalidateCacheForChannelPosts(post.ChannelId) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.ReactionHasBeenAdded(pluginContext, reaction) - return true - }, plugin.ReactionHasBeenAddedID) - }) - } + pluginContext := pluginContext(c) + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.ReactionHasBeenAdded(pluginContext, reaction) + return true + }, plugin.ReactionHasBeenAddedID) + }) a.Srv().Go(func() { a.sendReactionEvent(model.WebsocketEventReactionAdded, reaction, post) @@ -142,15 +140,13 @@ func (a *App) DeleteReactionForPost(c *request.Context, reaction *model.Reaction // The post is always modified since the UpdateAt always changes a.invalidateCacheForChannelPosts(post.ChannelId) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.ReactionHasBeenRemoved(pluginContext, reaction) - return true - }, plugin.ReactionHasBeenRemovedID) - }) - } + pluginContext := pluginContext(c) + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.ReactionHasBeenRemoved(pluginContext, reaction) + return true + }, plugin.ReactionHasBeenRemovedID) + }) a.Srv().Go(func() { a.sendReactionEvent(model.WebsocketEventReactionRemoved, reaction, post) diff --git a/app/team.go b/app/team.go index 4376e2d77a..222d10e442 100644 --- a/app/team.go +++ b/app/team.go @@ -846,21 +846,19 @@ func (a *App) JoinUserToTeam(c request.CTX, team *model.Team, user *model.User, a.InvalidateCacheForUser(user.Id) a.invalidateCacheForUserTeams(user.Id) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var actor *model.User - if userRequestorId != "" { - actor, _ = a.GetUser(userRequestorId) - } - - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.UserHasJoinedTeam(pluginContext, teamMember, actor) - return true - }, plugin.UserHasJoinedTeamID) - }) + var actor *model.User + if userRequestorId != "" { + actor, _ = a.GetUser(userRequestorId) } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasJoinedTeam(pluginContext, teamMember, actor) + return true + }, plugin.UserHasJoinedTeamID) + }) + message := model.NewWebSocketEvent(model.WebsocketEventAddedToTeam, "", "", user.Id, nil, "") message.Add("team_id", team.Id) message.Add("user_id", user.Id) @@ -1220,21 +1218,19 @@ func (a *App) RemoveUserFromTeam(c request.CTX, teamID string, userID string, re } func (a *App) postProcessTeamMemberLeave(c request.CTX, teamMember *model.TeamMember, requestorId string) *model.AppError { - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - var actor *model.User - if requestorId != "" { - actor, _ = a.GetUser(requestorId) - } - - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.UserHasLeftTeam(pluginContext, teamMember, actor) - return true - }, plugin.UserHasLeftTeamID) - }) + var actor *model.User + if requestorId != "" { + actor, _ = a.GetUser(requestorId) } + a.Srv().Go(func() { + pluginContext := pluginContext(c) + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasLeftTeam(pluginContext, teamMember, actor) + return true + }, plugin.UserHasLeftTeamID) + }) + user, nErr := a.Srv().Store().User().Get(context.Background(), teamMember.UserId) if nErr != nil { var nfErr *store.ErrNotFound diff --git a/app/upload.go b/app/upload.go index b63725b135..4da0b853b6 100644 --- a/app/upload.go +++ b/app/upload.go @@ -49,11 +49,6 @@ func (a *App) genFileInfoFromReader(name string, file io.ReadSeeker, size int64) } func (a *App) runPluginsHook(c *request.Context, info *model.FileInfo, file io.Reader) *model.AppError { - pluginsEnvironment := a.GetPluginsEnvironment() - if pluginsEnvironment == nil { - return nil - } - filePath := info.Path // using a pipe to avoid loading the whole file content in memory. r, w := io.Pipe() diff --git a/app/user.go b/app/user.go index 953446071d..a2f72eddb5 100644 --- a/app/user.go +++ b/app/user.go @@ -308,15 +308,13 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m message.Add("user_id", ruser.Id) a.Publish(message) - if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { - a.Srv().Go(func() { - pluginContext := pluginContext(c) - a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { - hooks.UserHasBeenCreated(pluginContext, ruser) - return true - }, plugin.UserHasBeenCreatedID) - }) - } + pluginContext := pluginContext(c) + a.Srv().Go(func() { + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.UserHasBeenCreated(pluginContext, ruser) + return true + }, plugin.UserHasBeenCreatedID) + }) _, cwsErr := a.SendSubscriptionHistoryEvent(ruser.Id) if cwsErr != nil { From 6e9dbbd237bd5f60d2727169dd8a6ba11837746b Mon Sep 17 00:00:00 2001 From: Konstantinos Pittas Date: Wed, 7 Dec 2022 11:16:56 +0200 Subject: [PATCH 67/80] [MM-46463] Return last_picture_update for new team members (#21758) * return last picture update * add API test Co-authored-by: Mattermod --- api4/insights_test.go | 41 ++++++++++++++++++++++++++++++++++-- model/insights.go | 15 +++++++------ store/sqlstore/team_store.go | 2 +- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/api4/insights_test.go b/api4/insights_test.go index e304a60a49..58a4f95d97 100644 --- a/api4/insights_test.go +++ b/api4/insights_test.go @@ -7,10 +7,12 @@ import ( "testing" "time" - "github.com/mattermost/mattermost-server/v6/model" - "github.com/mattermost/mattermost-server/v6/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/utils/testutils" ) // Top Reactions @@ -1157,6 +1159,41 @@ func TestNewTeamMembersSince(t *testing.T) { CheckNotImplementedStatus(t, resp) }) + t.Run("includes all data for the user", func(t *testing.T) { + checkUser := func(ntm *model.NewTeamMember, hasProfilePicture bool) { + require.Equal(t, th.BasicUser.Id, ntm.Id) + require.Equal(t, th.BasicUser.Username, ntm.Username) + require.Equal(t, th.BasicUser.FirstName, ntm.FirstName) + require.Equal(t, th.BasicUser.LastName, ntm.LastName) + require.Equal(t, th.BasicUser.Position, ntm.Position) + require.Equal(t, th.BasicUser.Nickname, ntm.Nickname) + member, err := th.App.GetTeamMember(team.Id, th.BasicUser.Id) + require.Nil(t, err) + require.Equal(t, member.CreateAt, ntm.CreateAt) + if hasProfilePicture { + require.Truef(t, ntm.LastPictureUpdate > int64(0), "should be greater than 0, but was %d", ntm.LastPictureUpdate) + } else { + require.Equal(t, int64(0), ntm.LastPictureUpdate) + } + } + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + list, resp, err := th.Client.GetNewTeamMembersSince(team.Id, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + CheckOKStatus(t, resp) + checkUser(list.Items[0], false) + + data, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) + _, err = th.Client.SetProfileImage(th.BasicUser.Id, data) + require.NoError(t, err) + + list, resp, err = th.Client.GetNewTeamMembersSince(team.Id, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + CheckOKStatus(t, resp) + checkUser(list.Items[0], true) + }) + t.Run("implements pagination", func(t *testing.T) { // check the first page of results list, resp, err := th.Client.GetNewTeamMembersSince(team.Id, model.TimeRangeToday, 0, 2) diff --git a/model/insights.go b/model/insights.go index d66d48c283..437cdeec37 100644 --- a/model/insights.go +++ b/model/insights.go @@ -118,13 +118,14 @@ type NewTeamMembersList struct { } type NewTeamMember struct { - Id string `json:"id"` - Username string `json:"username"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Position string `json:"position"` - Nickname string `json:"nickname"` - CreateAt int64 `json:"create_at"` + Id string `json:"id"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Position string `json:"position"` + Nickname string `json:"nickname"` + LastPictureUpdate int64 `json:"last_picture_update,omitempty"` + CreateAt int64 `json:"create_at"` } type DurationPostCount struct { diff --git a/store/sqlstore/team_store.go b/store/sqlstore/team_store.go index cbe8535a21..00b3d731ae 100644 --- a/store/sqlstore/team_store.go +++ b/store/sqlstore/team_store.go @@ -1675,7 +1675,7 @@ func (s SqlTeamStore) GetNewTeamMembersSince(teamID string, since int64, offset return nil, 0, errors.Wrap(err, "failed to count team members since") } - newTeamMembersBuilder := builderF("Users.Id, Users.Username, Users.FirstName, Users.LastName, Users.Position, TeamMembers.CreateAt, Users.Nickname"). + newTeamMembersBuilder := builderF("Users.Id, Users.Username, Users.FirstName, Users.LastName, Users.Position, Users.LastPictureUpdate, TeamMembers.CreateAt, Users.Nickname"). Limit(uint64(limit + 1)). Offset(uint64(offset)) query, args, err = newTeamMembersBuilder.ToSql() From b51c06e5a67e06ce5c26b9fb31c380b9499f90af Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Wed, 7 Dec 2022 16:44:48 +0200 Subject: [PATCH 68/80] Enables PostPriority FeatureFlag by default (#21720) Automatic Merge --- model/feature_flags.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/feature_flags.go b/model/feature_flags.go index ae1005b30d..9356df0b89 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -98,7 +98,7 @@ func (f *FeatureFlags) SetDefaults() { f.CallsEnabled = true f.BoardsProduct = false f.SendWelcomePost = true - f.PostPriority = false + f.PostPriority = true f.PeopleProduct = false f.WorkTemplate = false f.AnnualSubscription = false From 3872f24b0c2a5ed027897d5d0bb5d9196ebc2e23 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 7 Dec 2022 10:05:48 -0500 Subject: [PATCH 69/80] [MM-47566] Add upcominginvoice field to Subscription type (#21717) * Add upcominginvoice field to Subscription type * Add support for fetching upcoming invoices * Put back upcominginvoice Co-authored-by: Mattermod --- api4/cloud.go | 2 +- model/cloud.go | 3 +++ web/context.go | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api4/cloud.go b/api4/cloud.go index 3f9106710c..208fa4b6b1 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -39,7 +39,7 @@ func (api *API) InitCloud() { // GET /api/v4/cloud/subscription api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(getSubscription)).Methods("GET") api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.APISessionRequired(getInvoicesForSubscription)).Methods("GET") - api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods("GET") + api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods("GET") api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(changeSubscription)).Methods("PUT") // GET /api/v4/cloud/request-trial diff --git a/model/cloud.go b/model/cloud.go index 2985c929ea..a409c77f98 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -16,6 +16,8 @@ const ( EventTypeTriggerDelinquencyEmail = "trigger-delinquency-email" ) +const UpcomingInvoice = "upcoming" + var MockCWS string type BillingScheme string @@ -152,6 +154,7 @@ type Subscription struct { DNS string `json:"dns"` IsPaidTier string `json:"is_paid_tier"` LastInvoice *Invoice `json:"last_invoice"` + UpcomingInvoice *Invoice `json:"upcoming_invoice"` IsFreeTrial string `json:"is_free_trial"` TrialEndAt int64 `json:"trial_end_at"` DelinquentSince *int64 `json:"delinquent_since"` diff --git a/web/context.go b/web/context.go index 056dca354c..2286f806c6 100644 --- a/web/context.go +++ b/web/context.go @@ -743,7 +743,7 @@ func (c *Context) RequireInvoiceId() *Context { return c } - if len(c.Params.InvoiceId) != 27 { + if len(c.Params.InvoiceId) != 27 && c.Params.InvoiceId != model.UpcomingInvoice { c.SetInvalidURLParam("invoice_id") } From d8e2859b0bcc76fe2caf95df1751e296c8f83970 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Wed, 7 Dec 2022 20:31:03 +0300 Subject: [PATCH 70/80] Minimize app dependency on platform service (#21548) --- api4/channel.go | 2 +- api4/post.go | 2 +- api4/resolver_user.go | 2 +- api4/user.go | 4 +- app/app_iface.go | 2 - app/opentracing/opentracing_layer.go | 32 ----- app/platform/helper_test.go | 2 +- app/platform/mocks/SuiteIFace.go | 39 ------ app/platform/service.go | 4 +- app/platform/status.go | 195 +++++++++++++++++++++++++++ app/platform/web_conn.go | 6 +- app/platform/web_hub.go | 20 +-- app/platform/web_hub_test.go | 17 +-- app/platform/websocket_router.go | 4 +- app/server.go | 2 +- app/session.go | 17 --- app/status.go | 159 +--------------------- 17 files changed, 228 insertions(+), 281 deletions(-) diff --git a/api4/channel.go b/api4/channel.go index 1bc8c1db06..63eefb58af 100644 --- a/api4/channel.go +++ b/api4/channel.go @@ -1491,7 +1491,7 @@ func viewChannel(c *Context, w http.ResponseWriter, r *http.Request) { return } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) c.ExtendSessionExpiryIfNeeded(w, r) // Returning {"status": "OK", ...} for backwards compatibility diff --git a/api4/post.go b/api4/post.go index 0d9f369731..ff707c178c 100644 --- a/api4/post.go +++ b/api4/post.go @@ -103,7 +103,7 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { c.App.SetStatusOnline(c.AppContext.Session().UserId, false) } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) c.ExtendSessionExpiryIfNeeded(w, r) w.WriteHeader(http.StatusCreated) diff --git a/api4/resolver_user.go b/api4/resolver_user.go index a1058054cd..0d962a3fe0 100644 --- a/api4/resolver_user.go +++ b/api4/resolver_user.go @@ -56,7 +56,7 @@ func getGraphQLUser(ctx context.Context, id string) (*user, error) { } } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) return &user{*usr}, nil } diff --git a/api4/user.go b/api4/user.go index 9d3ca204d2..15af912ab7 100644 --- a/api4/user.go +++ b/api4/user.go @@ -221,7 +221,7 @@ func getUser(c *Context, w http.ResponseWriter, r *http.Request) { } else { c.App.SanitizeProfile(user, c.IsSystemAdmin()) } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) w.Header().Set(model.HeaderEtagServer, etag) if err := json.NewEncoder(w).Encode(user); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) @@ -864,7 +864,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { if etag != "" { w.Header().Set(model.HeaderEtagServer, etag) } - c.App.UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) + c.App.Srv().Platform().UpdateLastActivityAtIfNeeded(*c.AppContext.Session()) js, err := json.Marshal(profiles) if err != nil { diff --git a/app/app_iface.go b/app/app_iface.go index 781f12b7e1..c27ea210c1 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -886,7 +886,6 @@ type AppIface interface { IsLeader() bool IsPasswordValid(password string) *model.AppError IsPhase2MigrationCompleted() *model.AppError - IsUserAway(lastActivityAt int64) bool IsUserSignUpAllowed() *model.AppError JoinChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError JoinDefaultChannels(c request.CTX, teamID string, user *model.User, shouldBeAdmin bool, userRequestorId string) *model.AppError @@ -1113,7 +1112,6 @@ type AppIface interface { UpdateHashedPassword(user *model.User, newHashedPassword string) *model.AppError UpdateHashedPasswordByUserId(userID, newHashedPassword string) *model.AppError UpdateIncomingWebhook(oldHook, updatedHook *model.IncomingWebhook) (*model.IncomingWebhook, *model.AppError) - UpdateLastActivityAtIfNeeded(session model.Session) UpdateMfa(c request.CTX, activate bool, userID, token string) *model.AppError UpdateMobileAppBadge(userID string) UpdateOAuthApp(oldApp, updatedApp *model.OAuthApp) (*model.OAuthApp, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 1e25574b70..2d60825ebc 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -11941,23 +11941,6 @@ func (a *OpenTracingAppLayer) IsPhase2MigrationCompleted() *model.AppError { return resultVar0 } -func (a *OpenTracingAppLayer) IsUserAway(lastActivityAt int64) bool { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsUserAway") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0 := a.app.IsUserAway(lastActivityAt) - - return resultVar0 -} - func (a *OpenTracingAppLayer) IsUserSignUpAllowed() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsUserSignUpAllowed") @@ -17315,21 +17298,6 @@ func (a *OpenTracingAppLayer) UpdateIncomingWebhook(oldHook *model.IncomingWebho return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) UpdateLastActivityAtIfNeeded(session model.Session) { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateLastActivityAtIfNeeded") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - a.app.UpdateLastActivityAtIfNeeded(session) -} - func (a *OpenTracingAppLayer) UpdateMfa(c request.CTX, activate bool, userID string, token string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateMfa") diff --git a/app/platform/helper_test.go b/app/platform/helper_test.go index 9402f8def3..5b1a44d344 100644 --- a/app/platform/helper_test.go +++ b/app/platform/helper_test.go @@ -181,7 +181,7 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo th.Service.SetLicense(nil) } - err = th.Service.Start(th.Suite) + err = th.Service.Start() if err != nil { panic(err) } diff --git a/app/platform/mocks/SuiteIFace.go b/app/platform/mocks/SuiteIFace.go index 6b337a8059..b919999f6e 100644 --- a/app/platform/mocks/SuiteIFace.go +++ b/app/platform/mocks/SuiteIFace.go @@ -39,20 +39,6 @@ func (_m *SuiteIFace) GetSession(token string) (*model.Session, *model.AppError) return r0, r1 } -// IsUserAway provides a mock function with given fields: lastActivityAt -func (_m *SuiteIFace) IsUserAway(lastActivityAt int64) bool { - ret := _m.Called(lastActivityAt) - - var r0 bool - if rf, ok := ret.Get(0).(func(int64) bool); ok { - r0 = rf(lastActivityAt) - } else { - r0 = ret.Get(0).(bool) - } - - return r0 -} - // RolesGrantPermission provides a mock function with given fields: roleNames, permissionId func (_m *SuiteIFace) RolesGrantPermission(roleNames []string, permissionId string) bool { ret := _m.Called(roleNames, permissionId) @@ -67,31 +53,6 @@ func (_m *SuiteIFace) RolesGrantPermission(roleNames []string, permissionId stri return r0 } -// SetStatusAwayIfNeeded provides a mock function with given fields: userID, manual -func (_m *SuiteIFace) SetStatusAwayIfNeeded(userID string, manual bool) { - _m.Called(userID, manual) -} - -// SetStatusLastActivityAt provides a mock function with given fields: userID, activityAt -func (_m *SuiteIFace) SetStatusLastActivityAt(userID string, activityAt int64) { - _m.Called(userID, activityAt) -} - -// SetStatusOffline provides a mock function with given fields: userID, manual -func (_m *SuiteIFace) SetStatusOffline(userID string, manual bool) { - _m.Called(userID, manual) -} - -// SetStatusOnline provides a mock function with given fields: userID, manual -func (_m *SuiteIFace) SetStatusOnline(userID string, manual bool) { - _m.Called(userID, manual) -} - -// UpdateLastActivityAtIfNeeded provides a mock function with given fields: session -func (_m *SuiteIFace) UpdateLastActivityAtIfNeeded(session model.Session) { - _m.Called(session) -} - // UserCanSeeOtherUser provides a mock function with given fields: userID, otherUserId func (_m *SuiteIFace) UserCanSeeOtherUser(userID string, otherUserId string) (bool, *model.AppError) { ret := _m.Called(userID, otherUserId) diff --git a/app/platform/service.go b/app/platform/service.go index be3f4ed805..b2f02c2c77 100644 --- a/app/platform/service.go +++ b/app/platform/service.go @@ -304,8 +304,8 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { return ps, nil } -func (ps *PlatformService) Start(suite SuiteIFace) error { - ps.hubStart(suite) +func (ps *PlatformService) Start() error { + ps.hubStart() ps.configListenerId = ps.AddConfigListener(func(_, _ *model.Config) { ps.regenerateClientConfig() diff --git a/app/platform/status.go b/app/platform/status.go index b133d3e2f7..70074d5d0f 100644 --- a/app/platform/status.go +++ b/app/platform/status.go @@ -212,3 +212,198 @@ func (ps *PlatformService) GetStatus(userID string) (*model.Status, *model.AppEr return status, nil } + +// SetStatusLastActivityAt sets the last activity at for a user on the local app server and updates +// status to away if needed. Used by the WS to set status to away if an 'online' device disconnects +// while an 'away' device is still connected +func (ps *PlatformService) SetStatusLastActivityAt(userID string, activityAt int64) { + var status *model.Status + var err *model.AppError + if status, err = ps.GetStatus(userID); err != nil { + return + } + + status.LastActivityAt = activityAt + + ps.AddStatusCacheSkipClusterSend(status) + ps.SetStatusAwayIfNeeded(userID, false) +} + +func (ps *PlatformService) UpdateLastActivityAtIfNeeded(session model.Session) { + now := model.GetMillis() + + ps.UpdateWebConnUserActivity(session, now) + + if now-session.LastActivityAt < model.SessionActivityTimeout { + return + } + + if err := ps.Store.Session().UpdateLastActivityAt(session.Id, now); err != nil { + mlog.Warn("Failed to update LastActivityAt", mlog.String("user_id", session.UserId), mlog.String("session_id", session.Id), mlog.Err(err)) + } + + session.LastActivityAt = now + ps.AddSessionToCache(&session) +} + +func (ps *PlatformService) SetStatusOnline(userID string, manual bool) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + broadcast := false + + var oldStatus string = model.StatusOffline + var oldTime int64 + var oldManual bool + var status *model.Status + var err *model.AppError + + if status, err = ps.GetStatus(userID); err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""} + broadcast = true + } else { + if status.Manual && !manual { + return // manually set status always overrides non-manual one + } + + if status.Status != model.StatusOnline { + broadcast = true + } + + oldStatus = status.Status + oldTime = status.LastActivityAt + oldManual = status.Manual + + status.Status = model.StatusOnline + status.Manual = false // for "online" there's no manual setting + status.LastActivityAt = model.GetMillis() + } + + ps.AddStatusCache(status) + + // Only update the database if the status has changed, the status has been manually set, + // or enough time has passed since the previous action + if status.Status != oldStatus || status.Manual != oldManual || status.LastActivityAt-oldTime > model.StatusMinUpdateTime { + if broadcast { + if err := ps.Store.Status().SaveOrUpdate(status); err != nil { + mlog.Warn("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID)) + } + } else { + if err := ps.Store.Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt); err != nil { + mlog.Error("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID)) + } + } + } + + if broadcast { + ps.BroadcastStatus(status) + } +} + +func (ps *PlatformService) SetStatusOffline(userID string, manual bool) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userID) + if err == nil && status.Manual && !manual { + return // manually set status always overrides non-manual one + } + + status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: model.GetMillis(), ActiveChannel: ""} + + ps.SaveAndBroadcastStatus(status) +} + +func (ps *PlatformService) SetStatusAwayIfNeeded(userID string, manual bool) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userID) + + if err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: 0, ActiveChannel: ""} + } + + if !manual && status.Manual { + return // manually set status always overrides non-manual one + } + + if !manual { + if status.Status == model.StatusAway { + return + } + + if !ps.isUserAway(status.LastActivityAt) { + return + } + } + + status.Status = model.StatusAway + status.Manual = manual + status.ActiveChannel = "" + + ps.SaveAndBroadcastStatus(status) +} + +// SetStatusDoNotDisturbTimed takes endtime in unix epoch format in UTC +// and sets status of given userId to dnd which will be restored back after endtime +func (ps *PlatformService) SetStatusDoNotDisturbTimed(userId string, endtime int64) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userId) + + if err != nil { + status = &model.Status{UserId: userId, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + status.PrevStatus = status.Status + status.Status = model.StatusDnd + status.Manual = true + + status.DNDEndTime = endtime + + ps.SaveAndBroadcastStatus(status) +} + +func (ps *PlatformService) SetStatusDoNotDisturb(userID string) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userID) + + if err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + status.Status = model.StatusDnd + status.Manual = true + + ps.SaveAndBroadcastStatus(status) +} + +func (ps *PlatformService) SetStatusOutOfOffice(userID string) { + if !*ps.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := ps.GetStatus(userID) + + if err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOutOfOffice, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + status.Status = model.StatusOutOfOffice + status.Manual = true + + ps.SaveAndBroadcastStatus(status) +} + +func (ps *PlatformService) isUserAway(lastActivityAt int64) bool { + return model.GetMillis()-lastActivityAt >= *ps.Config().TeamSettings.UserStatusAwayTimeout*1000 +} diff --git a/app/platform/web_conn.go b/app/platform/web_conn.go index 4956d49a73..312221da50 100644 --- a/app/platform/web_conn.go +++ b/app/platform/web_conn.go @@ -165,8 +165,8 @@ func (ps *PlatformService) PopulateWebConnConfig(s *model.Session, cfg *WebConnC func (ps *PlatformService) NewWebConn(cfg *WebConnConfig, suite SuiteIFace, runner HookRunner) *WebConn { if cfg.Session.UserId != "" { ps.Go(func() { - suite.SetStatusOnline(cfg.Session.UserId, false) - suite.UpdateLastActivityAtIfNeeded(cfg.Session) + ps.SetStatusOnline(cfg.Session.UserId, false) + ps.UpdateLastActivityAtIfNeeded(cfg.Session) }) } @@ -344,7 +344,7 @@ func (wc *WebConn) readPump() { } if wc.IsAuthenticated() { wc.Platform.Go(func() { - wc.Suite.SetStatusAwayIfNeeded(wc.UserId, false) + wc.Platform.SetStatusAwayIfNeeded(wc.UserId, false) }) } return nil diff --git a/app/platform/web_hub.go b/app/platform/web_hub.go index dc83d574ce..9e62da4264 100644 --- a/app/platform/web_hub.go +++ b/app/platform/web_hub.go @@ -21,12 +21,6 @@ const ( ) type SuiteIFace interface { - SetStatusLastActivityAt(userID string, activityAt int64) - SetStatusOffline(userID string, manual bool) - IsUserAway(lastActivityAt int64) bool - SetStatusOnline(userID string, manual bool) - UpdateLastActivityAtIfNeeded(session model.Session) - SetStatusAwayIfNeeded(userID string, manual bool) GetSession(token string) (*model.Session, *model.AppError) RolesGrantPermission(roleNames []string, permissionId string) bool UserCanSeeOtherUser(userID string, otherUserId string) (bool, *model.AppError) @@ -95,7 +89,7 @@ func newWebHub(ps *PlatformService) *Hub { } // hubStart starts all the hubs. -func (ps *PlatformService) hubStart(suite SuiteIFace) { +func (ps *PlatformService) hubStart() { // Total number of hubs is twice the number of CPUs. numberOfHubs := runtime.NumCPU() * 2 ps.logger.Info("Starting websocket hubs", mlog.Int("number_of_hubs", numberOfHubs)) @@ -105,7 +99,7 @@ func (ps *PlatformService) hubStart(suite SuiteIFace) { for i := 0; i < numberOfHubs; i++ { hubs[i] = newWebHub(ps) hubs[i].connectionIndex = i - hubs[i].Start(suite) + hubs[i].Start() } // Assigning to the hubs slice without any mutex is fine because it is only assigned once // during the start of the program and always read from after that. @@ -366,7 +360,7 @@ func (h *Hub) Stop() { } // Start starts the hub. -func (h *Hub) Start(suite SuiteIFace) { +func (h *Hub) Start() { var doStart func() var doRecoverableStart func() var doRecover func() @@ -439,7 +433,7 @@ func (h *Hub) Start(suite SuiteIFace) { conns := connIndex.ForUser(webConn.UserId) if len(conns) == 0 || areAllInactive(conns) { h.platform.Go(func() { - suite.SetStatusOffline(webConn.UserId, false) + h.platform.SetStatusOffline(webConn.UserId, false) }) continue } @@ -453,9 +447,9 @@ func (h *Hub) Start(suite SuiteIFace) { } } - if suite.IsUserAway(latestActivity) { + if h.platform.isUserAway(latestActivity) { h.platform.Go(func() { - suite.SetStatusLastActivityAt(webConn.UserId, latestActivity) + h.platform.SetStatusLastActivityAt(webConn.UserId, latestActivity) }) } case userID := <-h.invalidateUser: @@ -522,7 +516,7 @@ func (h *Hub) Start(suite SuiteIFace) { case <-h.stop: for webConn := range connIndex.All() { webConn.Close() - suite.SetStatusOffline(webConn.UserId, false) + h.platform.SetStatusOffline(webConn.UserId, false) } h.explicitStop = true diff --git a/app/platform/web_hub_test.go b/app/platform/web_hub_test.go index e3f6e4ddbe..5092e6cb0f 100644 --- a/app/platform/web_hub_test.go +++ b/app/platform/web_hub_test.go @@ -67,7 +67,7 @@ func TestHubStopWithMultipleConnections(t *testing.T) { }) require.NoError(t, err) - th.Service.Start(th.Suite) + th.Service.Start() wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session) wc2 := registerDummyWebConn(t, th, s.Listener.Addr(), session) wc3 := registerDummyWebConn(t, th, s.Listener.Addr(), session) @@ -90,7 +90,7 @@ func TestHubStopRaceCondition(t *testing.T) { }) require.NoError(t, err) - th.Service.Start(th.Suite) + th.Service.Start() wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session) defer wc1.Close() @@ -467,18 +467,13 @@ func TestHubIsRegistered(t *testing.T) { require.NoError(t, err) mockSuite := &platform_mocks.SuiteIFace{} - mockSuite.On("SetStatusOnline", th.BasicUser.Id, false).Return() - mockSuite.On("UpdateLastActivityAtIfNeeded", *session).Return() mockSuite.On("GetSession", session.Token).Return(session, nil) - mockSuite.On("IsUserAway", mock.Anything).Return(false) - mockSuite.On("SetStatusOffline", th.BasicUser.Id, false).Return() - th.Suite = mockSuite s := httptest.NewServer(dummyWebsocketHandler(t)) defer s.Close() - th.Service.Start(th.Suite) + th.Service.Start() wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session) wc2 := registerDummyWebConn(t, th, s.Listener.Addr(), session) wc3 := registerDummyWebConn(t, th, s.Listener.Addr(), session) @@ -486,9 +481,7 @@ func TestHubIsRegistered(t *testing.T) { defer wc2.Close() defer wc3.Close() - session1 := wc1.session.Load().(*model.Session) - - assert.True(t, th.Service.SessionIsRegistered(*session1)) + assert.True(t, th.Service.SessionIsRegistered(*wc1.session.Load().(*model.Session))) assert.True(t, th.Service.SessionIsRegistered(*wc2.session.Load().(*model.Session))) assert.True(t, th.Service.SessionIsRegistered(*wc3.session.Load().(*model.Session))) @@ -551,7 +544,7 @@ func BenchmarkGetHubForUserId(b *testing.B) { th := Setup(b).InitBasic() defer th.TearDown() - th.Service.Start(th.Suite) + th.Service.Start() b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/app/platform/websocket_router.go b/app/platform/websocket_router.go index 2b465dffb6..862f220dfb 100644 --- a/app/platform/websocket_router.go +++ b/app/platform/websocket_router.go @@ -59,8 +59,8 @@ func (wr *WebSocketRouter) ServeWebSocket(conn *WebConn, r *model.WebSocketReque conn.Platform.HubRegister(conn) conn.Platform.Go(func() { - conn.Suite.SetStatusOnline(session.UserId, false) - conn.Suite.UpdateLastActivityAtIfNeeded(*session) + conn.Platform.SetStatusOnline(session.UserId, false) + conn.Platform.UpdateLastActivityAtIfNeeded(*session) }) resp := model.NewWebSocketResponse(model.StatusOk, r.Seq, nil) diff --git a/app/server.go b/app/server.go index a5e01db9b7..80b252caff 100644 --- a/app/server.go +++ b/app/server.go @@ -287,7 +287,7 @@ func NewServer(options ...Option) (*Server, error) { // It is important to initialize the hub only after the global logger is set // to avoid race conditions while logging from inside the hub. // Step 5: Start hub in platform which the hub depends on s.Channels() (step 4) - s.platform.Start(New(ServerConnector(s.Channels()))) + s.platform.Start() // ------------------------------------------------------------------------- // Everything below this is not order sensitive and safe to be moved around. diff --git a/app/session.go b/app/session.go index 86ed75166b..5b69258baf 100644 --- a/app/session.go +++ b/app/session.go @@ -235,23 +235,6 @@ func (a *App) AttachDeviceId(sessionID string, deviceID string, expiresAt int64) return nil } -func (a *App) UpdateLastActivityAtIfNeeded(session model.Session) { - now := model.GetMillis() - - a.UpdateWebConnUserActivity(session, now) - - if now-session.LastActivityAt < model.SessionActivityTimeout { - return - } - - if err := a.Srv().Store().Session().UpdateLastActivityAt(session.Id, now); err != nil { - mlog.Warn("Failed to update LastActivityAt", mlog.String("user_id", session.UserId), mlog.String("session_id", session.Id), mlog.Err(err)) - } - - session.LastActivityAt = now - a.ch.srv.platform.AddSessionToCache(&session) -} - // ExtendSessionExpiryIfNeeded extends Session.ExpiresAt based on session lengths in config. // A new ExpiresAt is only written if enough time has elapsed since last update. // Returns true only if the session was extended. diff --git a/app/status.go b/app/status.go index cdbd0759de..ecf17cedcc 100644 --- a/app/status.go +++ b/app/status.go @@ -21,174 +21,33 @@ func (a *App) GetUserStatusesByIds(userIDs []string) ([]*model.Status, *model.Ap // status to away if needed. Used by the WS to set status to away if an 'online' device disconnects // while an 'away' device is still connected func (a *App) SetStatusLastActivityAt(userID string, activityAt int64) { - var status *model.Status - var err *model.AppError - if status, err = a.GetStatus(userID); err != nil { - return - } - - status.LastActivityAt = activityAt - - a.Srv().Platform().AddStatusCacheSkipClusterSend(status) - a.SetStatusAwayIfNeeded(userID, false) + a.Srv().Platform().SetStatusLastActivityAt(userID, activityAt) } func (a *App) SetStatusOnline(userID string, manual bool) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - broadcast := false - - var oldStatus string = model.StatusOffline - var oldTime int64 - var oldManual bool - var status *model.Status - var err *model.AppError - - if status, err = a.GetStatus(userID); err != nil { - status = &model.Status{UserId: userID, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""} - broadcast = true - } else { - if status.Manual && !manual { - return // manually set status always overrides non-manual one - } - - if status.Status != model.StatusOnline { - broadcast = true - } - - oldStatus = status.Status - oldTime = status.LastActivityAt - oldManual = status.Manual - - status.Status = model.StatusOnline - status.Manual = false // for "online" there's no manual setting - status.LastActivityAt = model.GetMillis() - } - - a.Srv().Platform().AddStatusCache(status) - - // Only update the database if the status has changed, the status has been manually set, - // or enough time has passed since the previous action - if status.Status != oldStatus || status.Manual != oldManual || status.LastActivityAt-oldTime > model.StatusMinUpdateTime { - if broadcast { - if err := a.Srv().Store().Status().SaveOrUpdate(status); err != nil { - mlog.Warn("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID)) - } - } else { - if err := a.Srv().Store().Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt); err != nil { - mlog.Error("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID)) - } - } - } - - if broadcast { - a.Srv().Platform().BroadcastStatus(status) - } + a.Srv().Platform().SetStatusOnline(userID, manual) } func (a *App) SetStatusOffline(userID string, manual bool) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userID) - if err == nil && status.Manual && !manual { - return // manually set status always overrides non-manual one - } - - status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: model.GetMillis(), ActiveChannel: ""} - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusOffline(userID, manual) } func (a *App) SetStatusAwayIfNeeded(userID string, manual bool) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userID) - - if err != nil { - status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: 0, ActiveChannel: ""} - } - - if !manual && status.Manual { - return // manually set status always overrides non-manual one - } - - if !manual { - if status.Status == model.StatusAway { - return - } - - if !a.IsUserAway(status.LastActivityAt) { - return - } - } - - status.Status = model.StatusAway - status.Manual = manual - status.ActiveChannel = "" - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusAwayIfNeeded(userID, manual) } // SetStatusDoNotDisturbTimed takes endtime in unix epoch format in UTC // and sets status of given userId to dnd which will be restored back after endtime func (a *App) SetStatusDoNotDisturbTimed(userId string, endtime int64) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userId) - - if err != nil { - status = &model.Status{UserId: userId, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} - } - - status.PrevStatus = status.Status - status.Status = model.StatusDnd - status.Manual = true - - status.DNDEndTime = endtime - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusDoNotDisturbTimed(userId, endtime) } func (a *App) SetStatusDoNotDisturb(userID string) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userID) - - if err != nil { - status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} - } - - status.Status = model.StatusDnd - status.Manual = true - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusDoNotDisturb(userID) } func (a *App) SetStatusOutOfOffice(userID string) { - if !*a.Config().ServiceSettings.EnableUserStatuses { - return - } - - status, err := a.GetStatus(userID) - - if err != nil { - status = &model.Status{UserId: userID, Status: model.StatusOutOfOffice, Manual: false, LastActivityAt: 0, ActiveChannel: ""} - } - - status.Status = model.StatusOutOfOffice - status.Manual = true - - a.Srv().Platform().SaveAndBroadcastStatus(status) + a.Srv().Platform().SetStatusOutOfOffice(userID) } func (a *App) GetStatusFromCache(userID string) *model.Status { @@ -199,10 +58,6 @@ func (a *App) GetStatus(userID string) (*model.Status, *model.AppError) { return a.Srv().Platform().GetStatus(userID) } -func (a *App) IsUserAway(lastActivityAt int64) bool { - return model.GetMillis()-lastActivityAt >= *a.Config().TeamSettings.UserStatusAwayTimeout*1000 -} - // UpdateDNDStatusOfUsers is a recurring task which is started when server starts // which unsets dnd status of users if needed and saves and broadcasts it func (a *App) UpdateDNDStatusOfUsers() { From 8d554a3621b53389bc86909b128d3b5ba0fc7a85 Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Thu, 8 Dec 2022 15:00:39 +0200 Subject: [PATCH 71/80] Fixes wrong err check (#21823) Co-authored-by: Mattermod --- app/post.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/post.go b/app/post.go index a0daea37c6..095494d080 100644 --- a/app/post.go +++ b/app/post.go @@ -1845,7 +1845,7 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model if a.isPostPriorityEnabled() { priorityList, nErr := a.Srv().Store().PostPriority().GetForPosts(mentionPostIds) - if err != nil { + if nErr != nil { return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.get_priority_for_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } for _, priority := range priorityList { From 8c206c1fd4542ddd8c87125436ec3095cf0c3bf6 Mon Sep 17 00:00:00 2001 From: Mylon Suren <23694620+mylonsuren@users.noreply.github.com> Date: Thu, 8 Dec 2022 09:40:14 -0500 Subject: [PATCH 72/80] [MM-48921] Don't return error when deleting Draft if Draft DNE (#21818) * set global drafts feature flag to true * Don't return error on deleteDraft when draft doesn't exist in server * undo accidental feature flag change * address comments * change log level to debug Co-authored-by: Mattermod --- api4/drafts.go | 9 ++++++++- app/draft.go | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/api4/drafts.go b/api4/drafts.go index 92dd6f2c58..102c164b8f 100644 --- a/api4/drafts.go +++ b/api4/drafts.go @@ -121,7 +121,14 @@ func deleteDraft(c *Context, w http.ResponseWriter, r *http.Request) { draft, err := c.App.GetDraft(userID, channelID, rootID) if err != nil { - c.Err = err + switch { + case err.StatusCode == http.StatusNotFound: + // If the draft doesn't exist in the server, we don't need to delete. + mlog.Debug("Unable to find the draft", mlog.Err(err)) + ReturnStatusOK(w) + default: + c.Err = err + } return } diff --git a/app/draft.go b/app/draft.go index 46f96d7fae..18dd2bde00 100644 --- a/app/draft.go +++ b/app/draft.go @@ -25,7 +25,7 @@ func (a *App) GetDraft(userID, channelID, rootID string) (*model.Draft, *model.A var nfErr *store.ErrNotFound switch { case errors.As(err, &nfErr): - return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, nfErr.Error(), http.StatusNotFound) + return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, err.Error(), http.StatusNotFound) default: return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, err.Error(), http.StatusInternalServerError) } From 7606fd9725eb37e4546d9c1bd9325866b757dbd9 Mon Sep 17 00:00:00 2001 From: Agniva De Sarker Date: Thu, 8 Dec 2022 21:25:00 +0530 Subject: [PATCH 73/80] MM-48651: Log error while queuing telemetry (#21821) We didn't use to handle the error returned from the Enqueue method. We fix that. Additionally, we add a verbosity flag that can allow us to look into verbose logs from the rudder client. This can help us to debug issues regarding telemetry not being sent. https://mattermost.atlassian.net/browse/MM-48651 ```release-note NONE ``` --- app/server.go | 2 +- model/config.go | 5 +++ services/telemetry/telemetry.go | 57 +++++++++++++++------------- services/telemetry/telemetry_test.go | 8 ++-- 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/app/server.go b/app/server.go index 80b252caff..d39e66705c 100644 --- a/app/server.go +++ b/app/server.go @@ -369,7 +369,7 @@ func NewServer(options ...Option) (*Server, error) { }) s.htmlTemplateWatcher = htmlTemplateWatcher - s.telemetryService = telemetry.New(New(ServerConnector(s.Channels())), s.Store(), s.platform.SearchEngine, s.Log()) + s.telemetryService = telemetry.New(New(ServerConnector(s.Channels())), s.Store(), s.platform.SearchEngine, s.Log(), *s.Config().LogSettings.VerboseDiagnostics) s.platform.SetTelemetryId(s.TelemetryId()) // TODO: move this into platform once telemetry service moved to platform. emailService, err := email.NewService(email.ServiceConfig{ diff --git a/model/config.go b/model/config.go index dd0ad5cfd3..7b9913e976 100644 --- a/model/config.go +++ b/model/config.go @@ -1235,6 +1235,7 @@ type LogSettings struct { FileLocation *string `access:"environment_logging,write_restrictable,cloud_restrictable"` EnableWebhookDebugging *bool `access:"environment_logging,write_restrictable,cloud_restrictable"` EnableDiagnostics *bool `access:"environment_logging,write_restrictable,cloud_restrictable"` // telemetry: none + VerboseDiagnostics *bool `access:"environment_logging,write_restrictable,cloud_restrictable"` // telemetry: none EnableSentry *bool `access:"environment_logging,write_restrictable,cloud_restrictable"` // telemetry: none AdvancedLoggingConfig *string `access:"environment_logging,write_restrictable,cloud_restrictable"` } @@ -1278,6 +1279,10 @@ func (s *LogSettings) SetDefaults() { s.EnableDiagnostics = NewBool(true) } + if s.VerboseDiagnostics == nil { + s.VerboseDiagnostics = NewBool(false) + } + if s.EnableSentry == nil { s.EnableSentry = NewBool(*s.EnableDiagnostics) } diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index ce9eb79419..9db3a36d00 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -101,6 +101,7 @@ type TelemetryService struct { rudderClient rudder.Client TelemetryID string timestampLastTelemetrySent time.Time + verbose bool } type RudderConfig struct { @@ -108,12 +109,13 @@ type RudderConfig struct { DataplaneURL string } -func New(srv ServerIface, dbStore store.Store, searchEngine *searchengine.Broker, log *mlog.Logger) *TelemetryService { +func New(srv ServerIface, dbStore store.Store, searchEngine *searchengine.Broker, log *mlog.Logger, verbose bool) *TelemetryService { service := &TelemetryService{ srv: srv, dbStore: dbStore, searchEngine: searchEngine, log: log, + verbose: verbose, } service.ensureTelemetryID() return service @@ -128,7 +130,7 @@ func (ts *TelemetryService) ensureTelemetryID() { systemID := &model.System{Name: model.SystemTelemetryId, Value: id} systemID, err := ts.dbStore.System().InsertIfExists(systemID) if err != nil { - mlog.Error("unable to get the telemetry ID", mlog.Err(err)) + ts.log.Error("unable to get the telemetry ID", mlog.Err(err)) return } @@ -173,12 +175,15 @@ func (ts *TelemetryService) SendTelemetry(event string, properties map[string]an if installationId := os.Getenv("MM_CLOUD_INSTALLATION_ID"); installationId != "" { context = &rudder.Context{Traits: map[string]any{"installationId": installationId}} } - ts.rudderClient.Enqueue(rudder.Track{ + err := ts.rudderClient.Enqueue(rudder.Track{ Event: event, UserId: ts.TelemetryID, Properties: properties, Context: context, }) + if err != nil { + ts.log.Warn("Error sending telemetry", mlog.Err(err)) + } } } @@ -1145,62 +1150,62 @@ func (ts *TelemetryService) trackElasticsearch() { func (ts *TelemetryService) trackGroups() { groupCount, err := ts.dbStore.Group().GroupCount() if err != nil { - mlog.Debug("Could not get group_count", mlog.Err(err)) + ts.log.Debug("Could not get group_count", mlog.Err(err)) } ldapGroupCount, err := ts.dbStore.Group().GroupCountBySource(model.GroupSourceLdap) if err != nil { - mlog.Debug("Could not get group_count", mlog.Err(err)) + ts.log.Debug("Could not get group_count", mlog.Err(err)) } customGroupCount, err := ts.dbStore.Group().GroupCountBySource(model.GroupSourceCustom) if err != nil { - mlog.Debug("Could not get group_count", mlog.Err(err)) + ts.log.Debug("Could not get group_count", mlog.Err(err)) } groupTeamCount, err := ts.dbStore.Group().GroupTeamCount() if err != nil { - mlog.Debug("Could not get group_team_count", mlog.Err(err)) + ts.log.Debug("Could not get group_team_count", mlog.Err(err)) } groupChannelCount, err := ts.dbStore.Group().GroupChannelCount() if err != nil { - mlog.Debug("Could not get group_channel_count", mlog.Err(err)) + ts.log.Debug("Could not get group_channel_count", mlog.Err(err)) } groupSyncedTeamCount, nErr := ts.dbStore.Team().GroupSyncedTeamCount() if nErr != nil { - mlog.Debug("Could not get group_synced_team_count", mlog.Err(nErr)) + ts.log.Debug("Could not get group_synced_team_count", mlog.Err(nErr)) } groupSyncedChannelCount, nErr := ts.dbStore.Channel().GroupSyncedChannelCount() if nErr != nil { - mlog.Debug("Could not get group_synced_channel_count", mlog.Err(nErr)) + ts.log.Debug("Could not get group_synced_channel_count", mlog.Err(nErr)) } groupMemberCount, err := ts.dbStore.Group().GroupMemberCount() if err != nil { - mlog.Debug("Could not get group_member_count", mlog.Err(err)) + ts.log.Debug("Could not get group_member_count", mlog.Err(err)) } distinctGroupMemberCount, err := ts.dbStore.Group().DistinctGroupMemberCount() if err != nil { - mlog.Debug("Could not get distinct_group_member_count", mlog.Err(err)) + ts.log.Debug("Could not get distinct_group_member_count", mlog.Err(err)) } distinctCustomGroupMemberCount, err := ts.dbStore.Group().DistinctGroupMemberCountForSource(model.GroupSourceCustom) if err != nil { - mlog.Debug("Could not get distinct_custom_group_member_count", mlog.Err(err)) + ts.log.Debug("Could not get distinct_custom_group_member_count", mlog.Err(err)) } distinctLdapGroupMemberCount, err := ts.dbStore.Group().DistinctGroupMemberCountForSource(model.GroupSourceLdap) if err != nil { - mlog.Debug("Could not get distinct_ldap_group_member_count", mlog.Err(err)) + ts.log.Debug("Could not get distinct_ldap_group_member_count", mlog.Err(err)) } groupCountWithAllowReference, err := ts.dbStore.Group().GroupCountWithAllowReference() if err != nil { - mlog.Debug("Could not get group_count_with_allow_reference", mlog.Err(err)) + ts.log.Debug("Could not get group_count_with_allow_reference", mlog.Err(err)) } ts.SendTelemetry(TrackGroups, map[string]any{ @@ -1222,44 +1227,44 @@ func (ts *TelemetryService) trackGroups() { func (ts *TelemetryService) trackChannelModeration() { channelSchemeCount, err := ts.dbStore.Scheme().CountByScope(model.SchemeScopeChannel) if err != nil { - mlog.Debug("Could not get channel_scheme_count", mlog.Err(err)) + ts.log.Debug("Could not get channel_scheme_count", mlog.Err(err)) } createPostUser, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionCreatePost.Id, model.RoleScopeChannel, model.RoleTypeUser) if err != nil { - mlog.Debug("Could not get create_post_user_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get create_post_user_disabled_count", mlog.Err(err)) } createPostGuest, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionCreatePost.Id, model.RoleScopeChannel, model.RoleTypeGuest) if err != nil { - mlog.Debug("Could not get create_post_guest_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get create_post_guest_disabled_count", mlog.Err(err)) } // only need to track one of 'add_reaction' or 'remove_reaction` because they're both toggled together by the channel moderation feature postReactionsUser, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionAddReaction.Id, model.RoleScopeChannel, model.RoleTypeUser) if err != nil { - mlog.Debug("Could not get post_reactions_user_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get post_reactions_user_disabled_count", mlog.Err(err)) } postReactionsGuest, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionAddReaction.Id, model.RoleScopeChannel, model.RoleTypeGuest) if err != nil { - mlog.Debug("Could not get post_reactions_guest_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get post_reactions_guest_disabled_count", mlog.Err(err)) } // only need to track one of 'manage_public_channel_members' or 'manage_private_channel_members` because they're both toggled together by the channel moderation feature manageMembersUser, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionManagePublicChannelMembers.Id, model.RoleScopeChannel, model.RoleTypeUser) if err != nil { - mlog.Debug("Could not get manage_members_user_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get manage_members_user_disabled_count", mlog.Err(err)) } useChannelMentionsUser, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionUseChannelMentions.Id, model.RoleScopeChannel, model.RoleTypeUser) if err != nil { - mlog.Debug("Could not get use_channel_mentions_user_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get use_channel_mentions_user_disabled_count", mlog.Err(err)) } useChannelMentionsGuest, err := ts.dbStore.Scheme().CountWithoutPermission(model.SchemeScopeChannel, model.PermissionUseChannelMentions.Id, model.RoleScopeChannel, model.RoleTypeGuest) if err != nil { - mlog.Debug("Could not get use_channel_mentions_guest_disabled_count", mlog.Err(err)) + ts.log.Debug("Could not get use_channel_mentions_guest_disabled_count", mlog.Err(err)) } ts.SendTelemetry(TrackChannelModeration, map[string]any{ @@ -1283,14 +1288,14 @@ func (ts *TelemetryService) initRudder(endpoint string, rudderKey string) { config := rudder.Config{} config.Logger = rudder.StdLogger(ts.log.With(mlog.String("source", "rudder")).StdLogger(mlog.LvlDebug)) config.Endpoint = endpoint + config.Verbose = ts.verbose // For testing if endpoint != RudderDataplaneURL { - config.Verbose = true config.BatchSize = 1 } client, err := rudder.NewWithConfig(rudderKey, endpoint, config) if err != nil { - mlog.Error("Failed to create Rudder instance", mlog.Err(err)) + ts.log.Error("Failed to create Rudder instance", mlog.Err(err)) return } client.Enqueue(rudder.Identify{ @@ -1416,7 +1421,7 @@ func (ts *TelemetryService) trackPluginConfig(cfg *model.Config, marketplaceURL pluginsEnvironment := ts.srv.GetPluginsEnvironment() if pluginsEnvironment != nil { if plugins, appErr := pluginsEnvironment.Available(); appErr != nil { - mlog.Warn("Unable to add plugin versions to telemetry", mlog.Err(appErr)) + ts.log.Warn("Unable to add plugin versions to telemetry", mlog.Err(appErr)) } else { // If marketplace request failed, use predefined list if marketplacePlugins == nil { diff --git a/services/telemetry/telemetry_test.go b/services/telemetry/telemetry_test.go index 32a86602a3..389cea5f37 100644 --- a/services/telemetry/telemetry_test.go +++ b/services/telemetry/telemetry_test.go @@ -118,7 +118,7 @@ func makeTelemetryServiceAndReceiver(t *testing.T, cloudLicense bool) (*Telemetr pchan <- p })) - service := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger) + service := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger, false) service.TelemetryID = testTelemetryID service.rudderClient = nil service.initRudder(receiver.URL, RudderKey) @@ -295,7 +295,7 @@ func TestEnsureTelemetryID(t *testing.T) { testLogger, _ := mlog.NewLogger() - telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger) + telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger, false) assert.Equal(t, "test", telemetryService.TelemetryID) telemetryService.ensureTelemetryID() @@ -328,7 +328,7 @@ func TestEnsureTelemetryID(t *testing.T) { testLogger, _ := mlog.NewLogger() - telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger) + telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger, false) assert.Equal(t, generatedID, telemetryService.TelemetryID) }) @@ -348,7 +348,7 @@ func TestEnsureTelemetryID(t *testing.T) { testLogger, _ := mlog.NewLogger() - telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger) + telemetryService := New(serverIfaceMock, storeMock, searchengine.NewBroker(cfg), testLogger, false) assert.Equal(t, "", telemetryService.TelemetryID) }) } From 64add1a108c2034e4d7dab4f6e1b86bad67a0bec Mon Sep 17 00:00:00 2001 From: Jesse Hallam Date: Thu, 8 Dec 2022 14:34:56 -0400 Subject: [PATCH 74/80] Pre-package Playbooks v1.34.0 (#21824) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 923d0900ca..f97b3a173a 100644 --- a/Makefile +++ b/Makefile @@ -155,7 +155,7 @@ PLUGIN_PACKAGES += mattermost-plugin-confluence-v1.3.0 PLUGIN_PACKAGES += mattermost-plugin-custom-attributes-v1.3.1 PLUGIN_PACKAGES += mattermost-plugin-github-v2.1.4 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.5.2 -PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.32.6 +PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.34.0 PLUGIN_PACKAGES += mattermost-plugin-jenkins-v1.1.0 PLUGIN_PACKAGES += mattermost-plugin-jira-v3.2.2 PLUGIN_PACKAGES += mattermost-plugin-jitsi-v2.0.1 From 7c7acb476e15ea233a00502beea3e21cd8f4b3fa Mon Sep 17 00:00:00 2001 From: cyrilzhang-mm <112951043+cyrilzhang-mm@users.noreply.github.com> Date: Thu, 8 Dec 2022 15:38:29 -0500 Subject: [PATCH 75/80] [MM-45052] Mark method as deprecated (#21703) --- model/insights.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/model/insights.go b/model/insights.go index 437cdeec37..22d105f867 100644 --- a/model/insights.go +++ b/model/insights.go @@ -248,6 +248,9 @@ func ToDailyPostCountViewModel(dpc []*DurationPostCount, startTime *time.Time, n return viewModel } +// Deprecated: This method doesn't perform error checking. +// Use GetStartOfDayForTimeRange instead. +// // StartOfDayForTimeRange gets the unix start time in milliseconds from the given time range. // Time range can be one of: "today", "7_day", or "28_day". func StartOfDayForTimeRange(timeRange string, location *time.Location) *time.Time { From 671959333eada2a26fd915b123c92eda389b603a Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Thu, 8 Dec 2022 16:59:09 -0500 Subject: [PATCH 76/80] MM-48476 - Upgrade calls to v0.11.0 (#21834) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f97b3a173a..ae391ac8e8 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ TEMPLATES_DIR=templates PLUGIN_PACKAGES ?= mattermost-plugin-antivirus-v0.1.2 PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.2.2 PLUGIN_PACKAGES += mattermost-plugin-aws-SNS-v1.2.0 -PLUGIN_PACKAGES += mattermost-plugin-calls-v0.10.0 +PLUGIN_PACKAGES += mattermost-plugin-calls-v0.11.0 PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.0.0 PLUGIN_PACKAGES += mattermost-plugin-confluence-v1.3.0 PLUGIN_PACKAGES += mattermost-plugin-custom-attributes-v1.3.1 From a0fe5014a9adcf27de2dc90bc11bdd96098403ea Mon Sep 17 00:00:00 2001 From: emmyni <44761757+emmyni@users.noreply.github.com> Date: Thu, 8 Dec 2022 17:07:34 -0500 Subject: [PATCH 77/80] [MM-48649]: Call the SendSubscriptionHistoryEvent function in a GO routine (#21836) --- app/user.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/user.go b/app/user.go index a2f72eddb5..6e12bda34b 100644 --- a/app/user.go +++ b/app/user.go @@ -316,10 +316,13 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m }, plugin.UserHasBeenCreatedID) }) - _, cwsErr := a.SendSubscriptionHistoryEvent(ruser.Id) - if cwsErr != nil { - c.Logger().Error("Failed to create/update the SubscriptionHistoryEvent", mlog.Err(cwsErr)) - } + // Create/Update the subscriptionHistoryEvent + go func() { + _, err := a.SendSubscriptionHistoryEvent(ruser.Id) + if err != nil { + c.Logger().Error("Failed to create/update the SubscriptionHistoryEvent", mlog.Err(err)) + } + }() return ruser, nil } From fc31b1713e510eda2ac881a4f0c39b4c92165575 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Fri, 9 Dec 2022 08:28:22 +0300 Subject: [PATCH 78/80] [MM-48947] app/platform: do not restart metrics on every config save (#21831) --- app/platform/config.go | 6 ------ app/platform/config_test.go | 27 +++++++++++++++++++++++++++ app/platform/service.go | 8 ++++++++ app/platform/service_test.go | 14 +++++--------- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/app/platform/config.go b/app/platform/config.go index fd5bb05f76..e8eb425b96 100644 --- a/app/platform/config.go +++ b/app/platform/config.go @@ -75,12 +75,6 @@ func (ps *PlatformService) SaveConfig(newCfg *model.Config, sendConfigChangeClus return nil, nil, model.NewAppError("saveConfig", "app.save_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - if ps.startMetrics && *ps.Config().MetricsSettings.Enable { - ps.RestartMetrics() - } else { - ps.ShutdownMetrics() - } - if ps.clusterIFace != nil { err := ps.clusterIFace.ConfigChanged(ps.configStore.RemoveEnvironmentOverrides(oldCfg), ps.configStore.RemoveEnvironmentOverrides(newCfg), sendConfigChangeClusterMessage) diff --git a/app/platform/config_test.go b/app/platform/config_test.go index af99b47d89..8268b32929 100644 --- a/app/platform/config_test.go +++ b/app/platform/config_test.go @@ -70,4 +70,31 @@ func TestConfigSave(t *testing.T) { updatedCfg := th.Service.Config() assert.Equal(t, "http://newhost.me", *updatedCfg.ServiceSettings.SiteURL) }) + + t.Run("do not restart the metrics server on a different type of config change", func(t *testing.T) { + th := Setup(t, StartMetrics()) + defer th.TearDown() + + metricsMock := &mocks.MetricsInterface{} + metricsMock.On("IncrementWebsocketEvent", mock.AnythingOfType("string")).Return() + metricsMock.On("IncrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("DecrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("Register").Return() + th.Service.metricsIFace = metricsMock + + // Change a random config setting + cfg := th.Service.Config().Clone() + cfg.ThemeSettings.EnableThemeSelection = model.NewBool(!*cfg.ThemeSettings.EnableThemeSelection) + th.Service.SaveConfig(cfg, false) + metricsMock.AssertNumberOfCalls(t, "Register", 0) + + // Disable metrics + cfg.MetricsSettings.Enable = model.NewBool(false) + th.Service.SaveConfig(cfg, false) + + // Change the metrics setting + cfg.MetricsSettings.Enable = model.NewBool(true) + th.Service.SaveConfig(cfg, false) + metricsMock.AssertNumberOfCalls(t, "Register", 1) + }) } diff --git a/app/platform/service.go b/app/platform/service.go index b2f02c2c77..857e14e3c8 100644 --- a/app/platform/service.go +++ b/app/platform/service.go @@ -268,6 +268,14 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { if mErr := ps.resetMetrics(); mErr != nil { return nil, mErr } + + ps.configStore.AddListener(func(oldCfg, newCfg *model.Config) { + if *oldCfg.MetricsSettings.Enable != *newCfg.MetricsSettings.Enable || *oldCfg.MetricsSettings.ListenAddress != *newCfg.MetricsSettings.ListenAddress { + if mErr := ps.resetMetrics(); mErr != nil { + mlog.Warn("Failed to reset metrics", mlog.Err(mErr)) + } + } + }) } // Step 9: Init AsymmetricSigningKey depends on step 6 (store) diff --git a/app/platform/service_test.go b/app/platform/service_test.go index 11633b2d5b..8654b18685 100644 --- a/app/platform/service_test.go +++ b/app/platform/service_test.go @@ -105,10 +105,9 @@ func TestMetrics(t *testing.T) { // there is no config listener for the metrics // we handle it on config save step - th.Service.UpdateConfig(func(c *model.Config) { - c.MetricsSettings.Enable = model.NewBool(true) - }) - th.Service.SaveConfig(th.Service.Config(), false) + cfg := th.Service.Config().Clone() + cfg.MetricsSettings.Enable = model.NewBool(true) + th.Service.SaveConfig(cfg, false) require.NotNil(t, th.Service.metrics) metricsAddr := strings.Replace(th.Service.metrics.listenAddr, "[::]", "http://localhost", 1) @@ -117,17 +116,14 @@ func TestMetrics(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) - th.Service.UpdateConfig(func(c *model.Config) { - c.MetricsSettings.Enable = model.NewBool(false) - }) - th.Service.SaveConfig(th.Service.Config(), false) + cfg.MetricsSettings.Enable = model.NewBool(false) + th.Service.SaveConfig(cfg, false) _, err = http.Get(metricsAddr) require.Error(t, err) }) t.Run("ensure the metrics server is started with advanced metrics", func(t *testing.T) { - t.Skip("MM-47635") th := Setup(t, StartMetrics()) defer th.TearDown() From 0193bfd7de03a3e50118bf8ffdda419d9d64c53a Mon Sep 17 00:00:00 2001 From: Shivashis Padhi Date: Fri, 9 Dec 2022 11:43:13 +0530 Subject: [PATCH 79/80] [MM-47378] Respond with bad requests for wrong query parameters 'roles' in getUsers (#21569) * Respond with bad requests for wrong query parameters in roles * Revert "Respond with bad requests for wrong query parameters in roles" This reverts commit d8374d94e0b1f61ad445127010f9780475c48d1a. * Add GetUser client function to query with channel_id and roles * Return bad parameters error on invalid roles * Make client function generic, lint fixes * i18n strings addition * Validate 'role', add stricter check for comma separated roles --- api4/user.go | 40 +++++++++++++++++++++++++ api4/user_local.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++ api4/user_test.go | 14 +++++++++ i18n/en.json | 4 +++ model/client4.go | 18 ++++++++++++ 5 files changed, 149 insertions(+) diff --git a/api4/user.go b/api4/user.go index 15af912ab7..e0e32708b3 100644 --- a/api4/user.go +++ b/api4/user.go @@ -693,14 +693,44 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidURLParam("inactive") } + roleNamesAll := []string{} + // MM-47378: validate 'role' related parameters + if role != "" || rolesString != "" || channelRolesString != "" || teamRolesString != "" { + // fetch all role names + rolesAll, err := c.App.GetAllRoles() + if err != nil { + c.Err = model.NewAppError("Api4.getUsers", "api.user.get_users.validation.app_error", nil, "Error fetching roles during validation.", http.StatusBadRequest) + return + } + for _, role := range rolesAll { + roleNamesAll = append(roleNamesAll, role.Name) + } + } roles := []string{} var rolesValid bool + if role != "" { + roles, rolesValid = model.CleanRoleNames([]string{role}) + if !rolesValid { + c.SetInvalidParam("role") + return + } + roleValid := utils.StringInSlice(role, roleNamesAll) + if !roleValid { + c.SetInvalidParam("role") + return + } + } if rolesString != "" { roles, rolesValid = model.CleanRoleNames(strings.Split(rolesString, ",")) if !rolesValid { c.SetInvalidParam("roles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, roles) + if len(validRoleNames) != len(roles) { + c.SetInvalidParam("roles") + return + } } channelRoles := []string{} if channelRolesString != "" && inChannelId != "" { @@ -709,6 +739,11 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidParam("channelRoles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, channelRoles) + if len(validRoleNames) != len(channelRoles) { + c.SetInvalidParam("channelRoles") + return + } } teamRoles := []string{} if teamRolesString != "" && inTeamId != "" { @@ -717,6 +752,11 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidParam("teamRoles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, teamRoles) + if len(validRoleNames) != len(teamRoles) { + c.SetInvalidParam("teamRoles") + return + } } restrictions, appErr := c.App.GetViewUsersRestrictions(c.AppContext.Session().UserId) diff --git a/api4/user_local.go b/api4/user_local.go index 50a451ea96..79578f2484 100644 --- a/api4/user_local.go +++ b/api4/user_local.go @@ -7,11 +7,13 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/utils" ) func (api *API) InitUserLocal() { @@ -56,7 +58,78 @@ func localGetUsers(c *Context, w http.ResponseWriter, r *http.Request) { active := r.URL.Query().Get("active") inactive := r.URL.Query().Get("inactive") role := r.URL.Query().Get("role") + rolesString := r.URL.Query().Get("roles") + channelRolesString := r.URL.Query().Get("channel_roles") + teamRolesString := r.URL.Query().Get("team_roles") sort := r.URL.Query().Get("sort") + roleNamesAll := []string{} + // MM-47378: validate 'role' related parameters + if role != "" || rolesString != "" || channelRolesString != "" || teamRolesString != "" { + // fetch all role names + rolesAll, err := c.App.GetAllRoles() + if err != nil { + c.Err = model.NewAppError("Api4.getUsers", "api.user.get_users.validation.app_error", nil, "Error fetching roles during validation.", http.StatusBadRequest) + return + } + for _, role := range rolesAll { + roleNamesAll = append(roleNamesAll, role.Name) + } + } + + var roles []string + var rolesValid bool + + if role != "" { + _, rolesValid = model.CleanRoleNames([]string{role}) + if !rolesValid { + c.SetInvalidParam("role") + return + } + roleValid := utils.StringInSlice(role, roleNamesAll) + if !roleValid { + c.SetInvalidParam("role") + return + } + } + + if rolesString != "" { + roles, rolesValid = model.CleanRoleNames(strings.Split(rolesString, ",")) + if !rolesValid { + c.SetInvalidParam("roles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, roles) + if len(validRoleNames) != len(roles) { + c.SetInvalidParam("roles") + return + } + } + var channelRoles []string + if channelRolesString != "" && inChannelId != "" { + channelRoles, rolesValid = model.CleanRoleNames(strings.Split(channelRolesString, ",")) + if !rolesValid { + c.SetInvalidParam("channelRoles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, channelRoles) + if len(validRoleNames) != len(channelRoles) { + c.SetInvalidParam("channelRoles") + return + } + } + var teamRoles []string + if teamRolesString != "" && inTeamId != "" { + teamRoles, rolesValid = model.CleanRoleNames(strings.Split(teamRolesString, ",")) + if !rolesValid { + c.SetInvalidParam("teamRoles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, teamRoles) + if len(validRoleNames) != len(teamRoles) { + c.SetInvalidParam("teamRoles") + return + } + } if notInChannelId != "" && inTeamId == "" { c.SetInvalidURLParam("team_id") diff --git a/api4/user_test.go b/api4/user_test.go index 17195f7d6d..871f2c56e8 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -2410,6 +2410,20 @@ func TestGetUsers(t *testing.T) { // Check default params for page and per_page _, err = client.DoAPIGet("/users", "") require.NoError(t, err) + + // Check role params validity + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "in_channel=random_channel_id&channel_roles=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing channelRoles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "in_team=random_channel_id&team_roles=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing teamRoles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "roles=random_role_doesnt_exist%2Csystem_user", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing roles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "role=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing role in request body.") }) th.Client.Logout() diff --git a/i18n/en.json b/i18n/en.json index 43b65baa82..d1399190f6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4131,6 +4131,10 @@ "id": "api.user.get_user_by_email.permissions.app_error", "translation": "Unable to get user by email." }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Error fetching roles during validation." + }, { "id": "api.user.invalidate_verify_email_tokens.error", "translation": "Unable to get tokens by type when invalidating email verification tokens" diff --git a/model/client4.go b/model/client4.go index 5347048f17..511bc44a3f 100644 --- a/model/client4.go +++ b/model/client4.go @@ -1060,6 +1060,24 @@ func (c *Client4) GetUsers(page int, perPage int, etag string) ([]*User, *Respon return list, BuildResponse(r), nil } +// GetUsersWithChannelRoles returns a page of users on the system. Page counting starts at 0. +func (c *Client4) GetUsersWithCustomQueryParameters(page int, perPage int, queryParameters, etag string) ([]*User, *Response, error) { + query := fmt.Sprintf("?page=%v&per_page=%v&%v", page, perPage, queryParameters) + r, err := c.DoAPIGet(c.usersRoute()+query, etag) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var list []*User + if r.StatusCode == http.StatusNotModified { + return list, BuildResponse(r), nil + } + if err := json.NewDecoder(r.Body).Decode(&list); err != nil { + return nil, nil, NewAppError("GetUsers", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return list, BuildResponse(r), nil +} + // GetUsersInTeam returns a page of users on a team. Page counting starts at 0. func (c *Client4) GetUsersInTeam(teamId string, page int, perPage int, etag string) ([]*User, *Response, error) { query := fmt.Sprintf("?in_team=%v&page=%v&per_page=%v", teamId, page, perPage) From 3b043c1f126634d87327f9bc794fb171492bb5da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Garc=C3=ADa=20Montoro?= Date: Fri, 9 Dec 2022 11:03:03 +0100 Subject: [PATCH 80/80] Skip "block user by domain but allow bot" test (#21841) See https://mattermost.atlassian.net/browse/MM-48973 for more details. --- app/team_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/app/team_test.go b/app/team_test.go index 23fd15117b..e281a1feaa 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -102,6 +102,7 @@ func TestAddUserToTeam(t *testing.T) { }) t.Run("block user by domain but allow bot", func(t *testing.T) { + t.Skip("MM-48973") th.BasicTeam.AllowedDomains = "example.com" _, err := th.App.UpdateTeam(th.BasicTeam) require.Nil(t, err, "Should update the team")