diff --git a/app/app_iface.go b/app/app_iface.go index ef3cca2450..562a58600b 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -83,9 +83,6 @@ type AppIface interface { CreateBot(c *request.Context, bot *model.Bot) (*model.Bot, *model.AppError) // CreateChannelScheme creates a new Scheme of scope channel and assigns it to the channel. CreateChannelScheme(channel *model.Channel) (*model.Scheme, *model.AppError) - // CreateDefaultChannels creates channels in the given team for each channel returned by (*App).DefaultChannelNames. - // - CreateDefaultChannels(c *request.Context, teamID string) ([]*model.Channel, *model.AppError) // CreateDefaultMemberships adds users to teams and channels based on their group memberships and how those groups // are configured to sync with teams and channels for group members on or after the given timestamp. // If includeRemovedMembers is true, then members who left or were removed from a team/channel will @@ -435,7 +432,6 @@ type AppIface interface { CheckUserMfa(user *model.User, token string) *model.AppError CheckUserPostflightAuthenticationCriteria(user *model.User) *model.AppError CheckUserPreflightAuthenticationCriteria(user *model.User, mfaToken string) *model.AppError - CheckValidDomains(team *model.Team) *model.AppError CheckWebConn(userID, connectionID string) *CheckConnResult ClearChannelMembersCache(channelID string) ClearSessionCacheForAllUsers() @@ -912,7 +908,6 @@ type AppIface interface { RemoveSamlPrivateCertificate() *model.AppError RemoveSamlPublicCertificate() *model.AppError RemoveTeamIcon(teamID string) *model.AppError - RemoveTeamMemberFromTeam(c *request.Context, teamMember *model.TeamMember, requestorId string) *model.AppError RemoveTeamsFromRetentionPolicy(policyID string, teamIDs []string) *model.AppError RemoveUserFromChannel(c *request.Context, userIDToRemove string, removerUserId string, channel *model.Channel) *model.AppError RemoveUserFromTeam(c *request.Context, teamID string, userID string, requestorId string) *model.AppError diff --git a/app/channel.go b/app/channel.go index 171fe48ff9..97719784dd 100644 --- a/app/channel.go +++ b/app/channel.go @@ -21,26 +21,6 @@ import ( "github.com/mattermost/mattermost-server/v6/utils" ) -// CreateDefaultChannels creates channels in the given team for each channel returned by (*App).DefaultChannelNames. -// -func (a *App) CreateDefaultChannels(c *request.Context, teamID string) ([]*model.Channel, *model.AppError) { - displayNames := map[string]string{ - "town-square": i18n.T("api.channel.create_default_channels.town_square"), - "off-topic": i18n.T("api.channel.create_default_channels.off_topic"), - } - channels := []*model.Channel{} - defaultChannelNames := a.DefaultChannelNames() - for _, name := range defaultChannelNames { - displayName := i18n.TDefault(displayNames[name], name) - channel := &model.Channel{DisplayName: displayName, Name: name, Type: model.ChannelTypeOpen, TeamId: teamID} - if _, err := a.CreateChannel(c, channel, false); err != nil { - return nil, err - } - channels = append(channels, channel) - } - return channels, nil -} - // DefaultChannelNames returns the list of system-wide default channel names. // // By default the list will be (not necessarily in this order): @@ -2350,7 +2330,11 @@ func (a *App) removeUserFromChannel(c *request.Context, userIDToRemove string, r return model.NewAppError("removeUserFromChannel", "api.team.remove_user_from_team.missing.app_error", nil, err.Error(), http.StatusBadRequest) } - if err = a.RemoveTeamMemberFromTeam(c, teamMember, removerUserId); err != nil { + if err := a.ch.srv.teamService.RemoveTeamMember(teamMember); err != nil { + return model.NewAppError("removeUserFromChannel", "api.team.remove_user_from_team.missing.app_error", nil, err.Error(), http.StatusBadRequest) + } + + if err = a.postProcessTeamMemberLeave(c, teamMember, removerUserId); err != nil { return err } } diff --git a/app/import_functions.go b/app/import_functions.go index 78ef2692b7..3dd67aeec7 100644 --- a/app/import_functions.go +++ b/app/import_functions.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/app/teams" "github.com/mattermost/mattermost-server/v6/app/users" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" @@ -209,8 +210,17 @@ func (a *App) importTeam(c *request.Context, data *TeamImportData, dryRun bool) return err } } else { - if _, err := a.updateTeamUnsanitized(team); err != nil { - return err + if _, err := a.ch.srv.teamService.UpdateTeam(team, teams.UpdateOptions{Imported: true}); err != nil { + var invErr *store.ErrInvalidInput + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return model.NewAppError("BulkImport", "app.team.get.find.app_error", nil, nfErr.Error(), http.StatusNotFound) + case errors.As(err, &invErr): + return model.NewAppError("BulkImport", "app.team.update.find.app_error", nil, invErr.Error(), http.StatusBadRequest) + default: + return model.NewAppError("BulkImport", "app.team.update.updating.app_error", nil, err.Error(), http.StatusInternalServerError) + } } } diff --git a/app/import_functions_test.go b/app/import_functions_test.go index 0b1a384aca..833fa844e8 100644 --- a/app/import_functions_test.go +++ b/app/import_functions_test.go @@ -1859,7 +1859,8 @@ func TestImportUserChannels(t *testing.T) { for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { user := th.CreateUser() - th.App.joinUserToTeam(th.BasicTeam, user) + _, _, err := th.App.ch.srv.teamService.JoinUserToTeam(th.BasicTeam, user) + require.NoError(t, err) // Two times import must end with the same results for x := 0; x < 2; x++ { diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 45d798e935..7b727f5859 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1362,28 +1362,6 @@ func (a *OpenTracingAppLayer) CheckUserPreflightAuthenticationCriteria(user *mod return resultVar0 } -func (a *OpenTracingAppLayer) CheckValidDomains(team *model.Team) *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckValidDomains") - - 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.CheckValidDomains(team) - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) CheckWebConn(userID string, connectionID string) *app.CheckConnResult { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckWebConn") @@ -1888,28 +1866,6 @@ func (a *OpenTracingAppLayer) CreateCommandWebhook(commandID string, args *model return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) CreateDefaultChannels(c *request.Context, teamID string) ([]*model.Channel, *model.AppError) { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateDefaultChannels") - - 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.CreateDefaultChannels(c, teamID) - - if resultVar1 != nil { - span.LogFields(spanlog.Error(resultVar1)) - ext.Error.Set(span, true) - } - - return resultVar0, resultVar1 -} - func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, since int64, includeRemovedMembers bool) error { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateDefaultMemberships") @@ -12898,28 +12854,6 @@ func (a *OpenTracingAppLayer) RemoveTeamIcon(teamID string) *model.AppError { return resultVar0 } -func (a *OpenTracingAppLayer) RemoveTeamMemberFromTeam(c *request.Context, teamMember *model.TeamMember, requestorId string) *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RemoveTeamMemberFromTeam") - - 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.RemoveTeamMemberFromTeam(c, teamMember, requestorId) - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) RemoveTeamsFromRetentionPolicy(policyID string, teamIDs []string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RemoveTeamsFromRetentionPolicy") diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index 92c6a64383..16653f5619 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -419,20 +419,20 @@ func TestPluginAPIGetUsersInTeam(t *testing.T) { defer th.App.PermanentDeleteUser(th.Context, user4) // Add all users to team 1 - _, _, err = th.App.joinUserToTeam(team1, user1) - require.Nil(t, err) - _, _, err = th.App.joinUserToTeam(team1, user2) - require.Nil(t, err) - _, _, err = th.App.joinUserToTeam(team1, user3) - require.Nil(t, err) - _, _, err = th.App.joinUserToTeam(team1, user4) - require.Nil(t, err) + _, appErr := th.App.JoinUserToTeam(th.Context, team1, user1, "") + require.Nil(t, appErr) + _, appErr = th.App.JoinUserToTeam(th.Context, team1, user2, "") + require.Nil(t, appErr) + _, appErr = th.App.JoinUserToTeam(th.Context, team1, user3, "") + require.Nil(t, appErr) + _, appErr = th.App.JoinUserToTeam(th.Context, team1, user4, "") + require.Nil(t, appErr) // Add only user3 and user4 to team 2 - _, _, err = th.App.joinUserToTeam(team2, user3) - require.Nil(t, err) - _, _, err = th.App.joinUserToTeam(team2, user4) - require.Nil(t, err) + _, appErr = th.App.JoinUserToTeam(th.Context, team2, user3, "") + require.Nil(t, appErr) + _, appErr = th.App.JoinUserToTeam(th.Context, team2, user4, "") + require.Nil(t, appErr) testCases := []struct { Description string @@ -482,7 +482,14 @@ func TestPluginAPIGetUsersInTeam(t *testing.T) { t.Run(testCase.Description, func(t *testing.T) { users, err := api.GetUsersInTeam(testCase.TeamId, testCase.Page, testCase.PerPage) assert.Nil(t, err) - assert.Equal(t, testCase.ExpectedUsers, users) + usersMap := make(map[string]bool) + for _, user := range testCase.ExpectedUsers { + usersMap[user.Id] = true + } + for _, user := range users { + delete(usersMap, user.Id) + } + assert.Empty(t, usersMap) }) } } diff --git a/app/server.go b/app/server.go index 9393f4095d..ec58586642 100644 --- a/app/server.go +++ b/app/server.go @@ -40,6 +40,7 @@ import ( "github.com/mattermost/mattermost-server/v6/app/featureflag" "github.com/mattermost/mattermost-server/v6/app/imaging" "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/app/teams" "github.com/mattermost/mattermost-server/v6/app/users" "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/config" @@ -155,6 +156,7 @@ type Server struct { telemetryService *telemetry.TelemetryService userService *users.UserService + teamService *teams.TeamService serviceMux sync.RWMutex remoteClusterService remotecluster.RemoteClusterServiceIFace @@ -428,6 +430,19 @@ func NewServer(options ...Option) (*Server, error) { return nil, errors.Wrapf(err, "unable to create users service") } + s.teamService, err = teams.New(teams.ServiceConfig{ + TeamStore: s.Store.Team(), + ChannelStore: s.Store.Channel(), + GroupStore: s.Store.Group(), + Users: s.userService, + WebHub: s, + ConfigFn: s.Config, + LicenseFn: s.License, + }) + if err != nil { + return nil, errors.Wrapf(err, "unable to create teams service") + } + s.configListenerId = s.AddConfigListener(func(_, _ *model.Config) { s.configOrLicenseListener() diff --git a/app/team.go b/app/team.go index 73d13ae200..7dea4f9586 100644 --- a/app/team.go +++ b/app/team.go @@ -19,6 +19,7 @@ import ( "github.com/mattermost/mattermost-server/v6/app/email" "github.com/mattermost/mattermost-server/v6/app/imaging" "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/app/teams" "github.com/mattermost/mattermost-server/v6/app/users" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" @@ -29,14 +30,29 @@ import ( ) func (a *App) CreateTeam(c *request.Context, team *model.Team) (*model.Team, *model.AppError) { - team.InviteId = "" - rteam, err := a.Srv().Store.Team().Save(team) + rteam, err := a.ch.srv.teamService.CreateTeam(team) if err != nil { var invErr *store.ErrInvalidInput + + var cErr *store.ErrConflict + var ltErr *store.ErrLimitExceeded var appErr *model.AppError switch { case errors.As(err, &invErr): - return nil, model.NewAppError("CreateTeam", "app.team.save.existing.app_error", nil, invErr.Error(), http.StatusBadRequest) + switch { + case invErr.Entity == "Channel" && invErr.Field == "DeleteAt": + return nil, model.NewAppError("CreateTeam", "store.sql_channel.save.archived_channel.app_error", nil, "", http.StatusBadRequest) + case invErr.Entity == "Channel" && invErr.Field == "Type": + return nil, model.NewAppError("CreateTeam", "store.sql_channel.save.direct_channel.app_error", nil, "", http.StatusBadRequest) + case invErr.Entity == "Channel" && invErr.Field == "Id": + return nil, model.NewAppError("CreateTeam", "store.sql_channel.save_channel.existing.app_error", nil, "id="+invErr.Value.(string), http.StatusBadRequest) + default: + return nil, model.NewAppError("CreateTeam", "app.team.save.existing.app_error", nil, invErr.Error(), http.StatusBadRequest) + } + case errors.As(err, &cErr): + return nil, model.NewAppError("CreateTeam", store.ChannelExistsError, nil, cErr.Error(), http.StatusBadRequest) + case errors.As(err, <Err): + return nil, model.NewAppError("CreateTeam", "store.sql_channel.save_channel.limit.app_error", nil, ltErr.Error(), http.StatusBadRequest) case errors.As(err, &appErr): return nil, appErr default: @@ -44,10 +60,6 @@ func (a *App) CreateTeam(c *request.Context, team *model.Team) (*model.Team, *mo } } - if _, err := a.CreateDefaultChannels(c, rteam.Id); err != nil { - return nil, err - } - return rteam, nil } @@ -58,8 +70,8 @@ func (a *App) CreateTeamWithUser(c *request.Context, team *model.Team, userID st } team.Email = user.Email - if !a.isTeamEmailAllowed(user, team) { - return nil, model.NewAppError("isTeamEmailAllowed", "api.team.is_team_creation_allowed.domain.app_error", nil, "", http.StatusBadRequest) + if !a.ch.srv.teamService.IsTeamEmailAllowed(user, team) { + return nil, model.NewAppError("CreateTeamWithUser", "api.team.is_team_creation_allowed.domain.app_error", nil, "", http.StatusBadRequest) } rteam, err := a.CreateTeam(c, team) @@ -80,86 +92,25 @@ func (a *App) normalizeDomains(domains string) []string { return strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(strings.Replace(domains, "@", " ", -1), ",", " ", -1)))) } -func (a *App) isEmailAddressAllowed(email string, allowedDomains []string) bool { - for _, restriction := range allowedDomains { - domains := a.normalizeDomains(restriction) - if len(domains) <= 0 { - continue - } - matched := false - for _, d := range domains { - if strings.HasSuffix(email, "@"+d) { - matched = true - break - } - } - if !matched { - return false - } - } - - return true -} - -func (a *App) isTeamEmailAllowed(user *model.User, team *model.Team) bool { - if user.IsBot { - return true - } - email := strings.ToLower(user.Email) - allowedDomains := a.getAllowedDomains(user, team) - return a.isEmailAddressAllowed(email, allowedDomains) -} - -func (a *App) getAllowedDomains(user *model.User, team *model.Team) []string { - if user.IsGuest() { - return []string{*a.Config().GuestAccountsSettings.RestrictCreationToDomains} - } - // First check per team allowedDomains, then app wide restrictions - return []string{team.AllowedDomains, *a.Config().TeamSettings.RestrictCreationToDomains} -} - -func (a *App) CheckValidDomains(team *model.Team) *model.AppError { - validDomains := a.normalizeDomains(*a.Config().TeamSettings.RestrictCreationToDomains) - if len(validDomains) > 0 { - for _, domain := range a.normalizeDomains(team.AllowedDomains) { - matched := false - for _, d := range validDomains { - if domain == d { - matched = true - break - } - } - if !matched { - err := model.NewAppError("UpdateTeam", "api.team.update_restricted_domains.mismatch.app_error", map[string]interface{}{"Domain": domain}, "", http.StatusBadRequest) - return err - } - } - } - - return nil -} - func (a *App) UpdateTeam(team *model.Team) (*model.Team, *model.AppError) { - oldTeam, err := a.GetTeam(team.Id) + oldTeam, err := a.ch.srv.teamService.UpdateTeam(team, teams.UpdateOptions{Sanitized: true}) if err != nil { - return nil, err - } - - if err = a.CheckValidDomains(team); err != nil { - return nil, err - } - - oldTeam.DisplayName = team.DisplayName - oldTeam.Description = team.Description - oldTeam.AllowOpenInvite = team.AllowOpenInvite - oldTeam.CompanyName = team.CompanyName - oldTeam.AllowedDomains = team.AllowedDomains - oldTeam.LastTeamIconUpdate = team.LastTeamIconUpdate - oldTeam.GroupConstrained = team.GroupConstrained - - oldTeam, err = a.updateTeamUnsanitized(oldTeam) - if err != nil { - return team, err + var invErr *store.ErrInvalidInput + var appErr *model.AppError + var domErr *teams.DomainError + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("UpdateTeam", "app.team.get.find.app_error", nil, nfErr.Error(), http.StatusNotFound) + case errors.As(err, &invErr): + return nil, model.NewAppError("UpdateTeam", "app.team.update.find.app_error", nil, invErr.Error(), http.StatusBadRequest) + case errors.As(err, &appErr): + return nil, appErr + case errors.As(err, &domErr): + return nil, model.NewAppError("UpdateTeam", "api.team.update_restricted_domains.mismatch.app_error", map[string]interface{}{"Domain": domErr.Domain}, "", http.StatusBadRequest) + default: + return nil, model.NewAppError("UpdateTeam", "app.team.update.updating.app_error", nil, err.Error(), http.StatusInternalServerError) + } } a.sendTeamEvent(oldTeam, model.WebsocketEventUpdateTeam) @@ -167,24 +118,6 @@ func (a *App) UpdateTeam(team *model.Team) (*model.Team, *model.AppError) { return oldTeam, nil } -func (a *App) updateTeamUnsanitized(team *model.Team) (*model.Team, *model.AppError) { - team, err := a.Srv().Store.Team().Update(team) - if err != nil { - var invErr *store.ErrInvalidInput - var appErr *model.AppError - switch { - case errors.As(err, &invErr): - return nil, model.NewAppError("updateTeamUnsanitized", "app.team.update.find.app_error", nil, invErr.Error(), http.StatusBadRequest) - case errors.As(err, &appErr): - return nil, appErr - default: - return nil, model.NewAppError("updateTeamUnsanitized", "app.team.update.updating.app_error", nil, err.Error(), http.StatusInternalServerError) - } - } - - return team, nil -} - // RenameTeam is used to rename the team Name and the DisplayName fields func (a *App) RenameTeam(team *model.Team, newTeamName string, newDisplayName string) (*model.Team, *model.AppError) { @@ -205,9 +138,24 @@ func (a *App) RenameTeam(team *model.Team, newTeamName string, newDisplayName st team.DisplayName = newDisplayName } - newTeam, err := a.updateTeamUnsanitized(team) + newTeam, err := a.ch.srv.teamService.UpdateTeam(team, teams.UpdateOptions{}) if err != nil { - return nil, err + var invErr *store.ErrInvalidInput + var appErr *model.AppError + var domErr *teams.DomainError + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("RenameTeam", "app.team.get.find.app_error", nil, nfErr.Error(), http.StatusNotFound) + case errors.As(err, &invErr): + return nil, model.NewAppError("RenameTeam", "app.team.update.find.app_error", nil, invErr.Error(), http.StatusBadRequest) + case errors.As(err, &appErr): + return nil, appErr + case errors.As(err, &domErr): + return nil, model.NewAppError("RenameTeam", "api.team.update_restricted_domains.mismatch.app_error", map[string]interface{}{"Domain": domErr.Domain}, "", http.StatusBadRequest) + default: + return nil, model.NewAppError("RenameTeam", "app.team.update.updating.app_error", nil, err.Error(), http.StatusInternalServerError) + } } return newTeam, nil @@ -276,23 +224,24 @@ func (a *App) UpdateTeamPrivacy(teamID string, teamType string, allowOpenInvite } func (a *App) PatchTeam(teamID string, patch *model.TeamPatch) (*model.Team, *model.AppError) { - team, err := a.GetTeam(teamID) + team, err := a.ch.srv.teamService.PatchTeam(teamID, patch) if err != nil { - return nil, err - } - - team.Patch(patch) - if patch.AllowOpenInvite != nil && !*patch.AllowOpenInvite { - team.InviteId = model.NewId() - } - - if err = a.CheckValidDomains(team); err != nil { - return nil, err - } - - team, err = a.updateTeamUnsanitized(team) - if err != nil { - return team, err + var invErr *store.ErrInvalidInput + var appErr *model.AppError + var domErr *teams.DomainError + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("PatchTeam", "app.team.get.find.app_error", nil, nfErr.Error(), http.StatusNotFound) + case errors.As(err, &invErr): + return nil, model.NewAppError("PatchTeam", "app.team.update.find.app_error", nil, invErr.Error(), http.StatusBadRequest) + case errors.As(err, &appErr): + return nil, appErr + case errors.As(err, &domErr): + return nil, model.NewAppError("PatchTeam", "api.team.update_restricted_domains.mismatch.app_error", map[string]interface{}{"Domain": domErr.Domain}, "", http.StatusBadRequest) + default: + return nil, model.NewAppError("PatchTeam", "app.team.update.updating.app_error", nil, err.Error(), http.StatusInternalServerError) + } } a.sendTeamEvent(team, model.WebsocketEventUpdateTeam) @@ -696,88 +645,28 @@ func (a *App) AddUserToTeamByInviteId(c *request.Context, inviteId string, userI return team, teamMember, nil } -// Returns three values: -// 1. a pointer to the team member, if successful -// 2. a boolean: true if the user has a non-deleted team member for that team already, otherwise false. -// 3. a pointer to an AppError if something went wrong. -func (a *App) joinUserToTeam(team *model.Team, user *model.User) (*model.TeamMember, bool, *model.AppError) { - tm := &model.TeamMember{ - TeamId: team.Id, - UserId: user.Id, - SchemeGuest: user.IsGuest(), - SchemeUser: !user.IsGuest(), - } - - if !user.IsGuest() { - userShouldBeAdmin, err := a.UserIsInAdminRoleGroup(user.Id, team.Id, model.GroupSyncableTypeTeam) - if err != nil { - return nil, false, err - } - tm.SchemeAdmin = userShouldBeAdmin - } - - if team.Email == user.Email { - tm.SchemeAdmin = true - } - - rtm, err := a.Srv().Store.Team().GetMember(context.Background(), team.Id, user.Id) - if err != nil { - // Membership appears to be missing. Lets try to add. - tmr, nErr := a.Srv().Store.Team().SaveMember(tm, *a.Config().TeamSettings.MaxUsersPerTeam) - if nErr != nil { - var appErr *model.AppError - var conflictErr *store.ErrConflict - var limitExeededErr *store.ErrLimitExceeded - switch { - case errors.As(nErr, &appErr): // in case we haven't converted to plain error. - return nil, false, appErr - case errors.As(nErr, &conflictErr): - return nil, false, model.NewAppError("joinUserToTeam", "app.team.join_user_to_team.save_member.conflict.app_error", nil, nErr.Error(), http.StatusBadRequest) - case errors.As(nErr, &limitExeededErr): - return nil, false, model.NewAppError("joinUserToTeam", "app.team.join_user_to_team.save_member.max_accounts.app_error", nil, nErr.Error(), http.StatusBadRequest) - default: // last fallback in case it doesn't map to an existing app error. - return nil, false, model.NewAppError("joinUserToTeam", "app.team.join_user_to_team.save_member.app_error", nil, nErr.Error(), http.StatusInternalServerError) - } - } - return tmr, false, nil - } - - // Membership already exists. Check if deleted and update, otherwise do nothing - // Do nothing if already added - if rtm.DeleteAt == 0 { - return rtm, true, nil - } - - membersCount, err := a.Srv().Store.Team().GetActiveMemberCount(tm.TeamId, nil) - if err != nil { - return nil, false, model.NewAppError("joinUserToTeam", "app.team.get_active_member_count.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - if membersCount >= int64(*a.Config().TeamSettings.MaxUsersPerTeam) { - return nil, false, model.NewAppError("joinUserToTeam", "app.team.join_user_to_team.max_accounts.app_error", nil, "teamId="+tm.TeamId, http.StatusBadRequest) - } - - member, nErr := a.Srv().Store.Team().UpdateMember(tm) - if nErr != nil { - var appErr *model.AppError - switch { - case errors.As(nErr, &appErr): - return nil, false, appErr - default: - return nil, false, model.NewAppError("joinUserToTeam", "app.team.save_member.save.app_error", nil, nErr.Error(), http.StatusInternalServerError) - } - } - - return member, false, nil -} - func (a *App) JoinUserToTeam(c *request.Context, team *model.Team, user *model.User, userRequestorId string) (*model.TeamMember, *model.AppError) { - if !a.isTeamEmailAllowed(user, team) { - return nil, model.NewAppError("JoinUserToTeam", "api.team.join_user_to_team.allowed_domains.app_error", nil, "", http.StatusBadRequest) - } - teamMember, alreadyAdded, err := a.joinUserToTeam(team, user) + teamMember, alreadyAdded, err := a.ch.srv.teamService.JoinUserToTeam(team, user) if err != nil { - return nil, err + var appErr *model.AppError + var conflictErr *store.ErrConflict + var limitExeededErr *store.ErrLimitExceeded + switch { + case errors.Is(err, teams.AcceptedDomainError): + return nil, model.NewAppError("JoinUserToTeam", "api.team.join_user_to_team.allowed_domains.app_error", nil, "", http.StatusBadRequest) + case errors.Is(err, teams.MemberCountError): + return nil, model.NewAppError("JoinUserToTeam", "app.team.get_active_member_count.app_error", nil, err.Error(), http.StatusInternalServerError) + case errors.Is(err, teams.MaxMemberCountError): + return nil, model.NewAppError("JoinUserToTeam", "app.team.join_user_to_team.max_accounts.app_error", nil, "teamId="+team.Id, http.StatusBadRequest) + case errors.As(err, &appErr): // in case we haven't converted to plain error. + return nil, appErr + case errors.As(err, &conflictErr): + return nil, model.NewAppError("JoinUserToTeam", "app.team.join_user_to_team.save_member.conflict.app_error", nil, err.Error(), http.StatusBadRequest) + case errors.As(err, &limitExeededErr): + return nil, model.NewAppError("JoinUserToTeam", "app.team.join_user_to_team.save_member.max_accounts.app_error", nil, err.Error(), http.StatusBadRequest) + default: // last fallback in case it doesn't map to an existing app error. + return nil, model.NewAppError("JoinUserToTeam", "app.team.join_user_to_team.save_member.app_error", nil, err.Error(), http.StatusInternalServerError) + } } if alreadyAdded { return teamMember, nil @@ -838,7 +727,7 @@ func (a *App) JoinUserToTeam(c *request.Context, team *model.Team, user *model.U } func (a *App) GetTeam(teamID string) (*model.Team, *model.AppError) { - team, err := a.Srv().Store.Team().Get(teamID) + team, err := a.ch.srv.teamService.GetTeam(teamID) if err != nil { var nfErr *store.ErrNotFound switch { @@ -1172,37 +1061,7 @@ func (a *App) RemoveUserFromTeam(c *request.Context, teamID string, userID strin return nil } -func (a *App) RemoveTeamMemberFromTeam(c *request.Context, teamMember *model.TeamMember, requestorId string) *model.AppError { - // Send the websocket message before we actually do the remove so the user being removed gets it. - message := model.NewWebSocketEvent(model.WebsocketEventLeaveTeam, teamMember.TeamId, "", "", nil) - message.Add("user_id", teamMember.UserId) - message.Add("team_id", teamMember.TeamId) - a.Publish(message) - - user, nErr := a.Srv().Store.User().Get(context.Background(), teamMember.UserId) - if nErr != nil { - var nfErr *store.ErrNotFound - switch { - case errors.As(nErr, &nfErr): - return model.NewAppError("RemoveTeamMemberFromTeam", MissingAccountError, nil, nfErr.Error(), http.StatusNotFound) - default: - return model.NewAppError("RemoveTeamMemberFromTeam", "app.user.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) - } - } - - teamMember.Roles = "" - teamMember.DeleteAt = model.GetMillis() - - if _, nErr := a.Srv().Store.Team().UpdateMember(teamMember); nErr != nil { - var appErr *model.AppError - switch { - case errors.As(nErr, &appErr): - return appErr - default: - return model.NewAppError("RemoveTeamMemberFromTeam", "app.team.save_member.save.app_error", nil, nErr.Error(), http.StatusInternalServerError) - } - } - +func (a *App) postProcessTeamMemberLeave(c *request.Context, teamMember *model.TeamMember, requestorId string) *model.AppError { if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { var actor *model.User if requestorId != "" { @@ -1218,17 +1077,28 @@ func (a *App) RemoveTeamMemberFromTeam(c *request.Context, teamMember *model.Tea }) } + user, nErr := a.Srv().Store.User().Get(context.Background(), teamMember.UserId) + if nErr != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(nErr, &nfErr): + return model.NewAppError("postProcessTeamMemberLeave", MissingAccountError, nil, nfErr.Error(), http.StatusNotFound) + default: + return model.NewAppError("postProcessTeamMemberLeave", "app.user.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + } + if _, err := a.Srv().Store.User().UpdateUpdateAt(user.Id); err != nil { - return model.NewAppError("RemoveTeamMemberFromTeam", "app.user.update_update.app_error", nil, err.Error(), http.StatusInternalServerError) + return model.NewAppError("postProcessTeamMemberLeave", "app.user.update_update.app_error", nil, err.Error(), http.StatusInternalServerError) } if err := a.Srv().Store.Channel().ClearSidebarOnTeamLeave(user.Id, teamMember.TeamId); err != nil { - return model.NewAppError("RemoveTeamMemberFromTeam", "app.channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + return model.NewAppError("postProcessTeamMemberLeave", "app.channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) } // delete the preferences that set the last channel used in the team and other team specific preferences if err := a.Srv().Store.Preference().DeleteCategory(user.Id, teamMember.TeamId); err != nil { - return model.NewAppError("RemoveTeamMemberFromTeam", "app.preference.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + return model.NewAppError("postProcessTeamMemberLeave", "app.preference.delete.app_error", nil, err.Error(), http.StatusInternalServerError) } a.ClearSessionCacheForUser(user.Id) @@ -1264,18 +1134,18 @@ func (a *App) LeaveTeam(c *request.Context, team *model.Team, user *model.User, } } - channel, nErr := a.Srv().Store.Channel().GetByName(team.Id, model.DefaultChannelName, false) - if nErr != nil { - var nfErr *store.ErrNotFound - switch { - case errors.As(nErr, &nfErr): - return model.NewAppError("LeaveTeam", "app.channel.get_by_name.missing.app_error", nil, nfErr.Error(), http.StatusNotFound) - default: - return model.NewAppError("LeaveTeam", "app.channel.get_by_name.existing.app_error", nil, nErr.Error(), http.StatusInternalServerError) - } - } - if *a.Config().ServiceSettings.ExperimentalEnableDefaultChannelLeaveJoinMessages { + channel, cErr := a.Srv().Store.Channel().GetByName(team.Id, model.DefaultChannelName, false) + if cErr != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(cErr, &nfErr): + return model.NewAppError("LeaveTeam", "app.channel.get_by_name.missing.app_error", nil, nfErr.Error(), http.StatusNotFound) + default: + return model.NewAppError("LeaveTeam", "app.channel.get_by_name.existing.app_error", nil, cErr.Error(), http.StatusInternalServerError) + } + } + if requestorId == user.Id { if err = a.postLeaveTeamMessage(c, user, channel); err != nil { mlog.Warn("Failed to post join/leave message", mlog.Err(err)) @@ -1287,7 +1157,11 @@ func (a *App) LeaveTeam(c *request.Context, team *model.Team, user *model.User, } } - if err := a.RemoveTeamMemberFromTeam(c, teamMember, requestorId); err != nil { + if err := a.ch.srv.teamService.RemoveTeamMember(teamMember); err != nil { + return model.NewAppError("RemoveTeamMemberFromTeam", "app.team.save_member.save.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + if err := a.postProcessTeamMemberLeave(c, teamMember, requestorId); err != nil { return err } @@ -1425,7 +1299,7 @@ func (a *App) InviteNewUsersToTeamGracefully(emailList []string, teamID, senderI if err != nil { return nil, err } - allowedDomains := a.getAllowedDomains(user, team) + allowedDomains := a.ch.srv.teamService.GetAllowedDomains(user, team) var inviteListWithErrors []*model.EmailInviteWithError var goodEmails []string for _, email := range emailList { @@ -1433,7 +1307,7 @@ func (a *App) InviteNewUsersToTeamGracefully(emailList []string, teamID, senderI Email: email, Error: nil, } - if !a.isEmailAddressAllowed(email, allowedDomains) { + if !teams.IsEmailAddressAllowed(email, allowedDomains) { invite.Error = model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]interface{}{"Addresses": email}, "", http.StatusBadRequest) } else { goodEmails = append(goodEmails, email) @@ -1583,11 +1457,11 @@ func (a *App) InviteNewUsersToTeam(emailList []string, teamID, senderId string) return err } - allowedDomains := a.getAllowedDomains(user, team) + allowedDomains := a.ch.srv.teamService.GetAllowedDomains(user, team) var invalidEmailList []string for _, email := range emailList { - if !a.isEmailAddressAllowed(email, allowedDomains) { + if !teams.IsEmailAddressAllowed(email, allowedDomains) { invalidEmailList = append(invalidEmailList, email) } } diff --git a/app/team_test.go b/app/team_test.go index 99296ceb80..ff583f201f 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -740,37 +740,8 @@ func TestJoinUserToTeam(t *testing.T) { ruser, _ := th.App.CreateUser(th.Context, &user) defer th.App.PermanentDeleteUser(th.Context, &user) - var alreadyAdded bool - _, alreadyAdded, err = th.App.joinUserToTeam(team, ruser) - require.False(t, alreadyAdded, "Should return already added equal to false") - require.Nil(t, err, "Should return no error") - }) - - t.Run("join when you are a member", func(t *testing.T) { - user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} - ruser, _ := th.App.CreateUser(th.Context, &user) - defer th.App.PermanentDeleteUser(th.Context, &user) - - th.App.joinUserToTeam(team, ruser) - - var alreadyAdded bool - _, alreadyAdded, err = th.App.joinUserToTeam(team, ruser) - require.True(t, alreadyAdded, "Should return already added") - require.Nil(t, err, "Should return no error") - }) - - t.Run("re-join after leaving", func(t *testing.T) { - user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} - ruser, _ := th.App.CreateUser(th.Context, &user) - defer th.App.PermanentDeleteUser(th.Context, &user) - - th.App.joinUserToTeam(team, ruser) - th.App.LeaveTeam(th.Context, team, ruser, ruser.Id) - - var alreadyAdded bool - _, alreadyAdded, err = th.App.joinUserToTeam(team, ruser) - require.False(t, alreadyAdded, "Should return already added equal to false") - require.Nil(t, err, "Should return no error") + _, appErr := th.App.JoinUserToTeam(th.Context, team, ruser, "") + require.Nil(t, appErr, "Should return no error") }) t.Run("new join with limit problem", func(t *testing.T) { @@ -781,10 +752,12 @@ func TestJoinUserToTeam(t *testing.T) { defer th.App.PermanentDeleteUser(th.Context, &user1) defer th.App.PermanentDeleteUser(th.Context, &user2) - th.App.joinUserToTeam(team, ruser1) - _, _, err = th.App.joinUserToTeam(team, ruser2) - require.NotNil(t, err, "Should fail") + _, appErr := th.App.JoinUserToTeam(th.Context, team, ruser1, ruser2.Id) + require.Nil(t, appErr, "Should return no error") + + _, appErr = th.App.JoinUserToTeam(th.Context, team, ruser2, ruser1.Id) + require.NotNil(t, appErr, "Should fail") }) t.Run("re-join alfter leaving with limit problem", func(t *testing.T) { @@ -797,12 +770,15 @@ func TestJoinUserToTeam(t *testing.T) { defer th.App.PermanentDeleteUser(th.Context, &user1) defer th.App.PermanentDeleteUser(th.Context, &user2) - th.App.joinUserToTeam(team, ruser1) - th.App.LeaveTeam(th.Context, team, ruser1, ruser1.Id) - th.App.joinUserToTeam(team, ruser2) + _, appErr := th.App.JoinUserToTeam(th.Context, team, ruser1, ruser2.Id) + require.Nil(t, appErr, "Should return no error") + appErr = th.App.LeaveTeam(th.Context, team, ruser1, ruser1.Id) + require.Nil(t, appErr, "Should return no error") + _, appErr = th.App.JoinUserToTeam(th.Context, team, ruser2, ruser2.Id) + require.Nil(t, appErr, "Should return no error") - _, _, err = th.App.joinUserToTeam(team, ruser1) - require.NotNil(t, err, "Should fail") + _, appErr = th.App.JoinUserToTeam(th.Context, team, ruser1, ruser2.Id) + require.NotNil(t, appErr, "Should fail") }) t.Run("new join with correct scheme_admin value from group syncable", func(t *testing.T) { @@ -826,8 +802,8 @@ func TestJoinUserToTeam(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.MaxUsersPerTeam = model.NewInt(999) }) - tm1, _, err := th.App.joinUserToTeam(team, ruser1) - require.Nil(t, err) + tm1, appErr := th.App.JoinUserToTeam(th.Context, team, ruser1, "") + require.Nil(t, appErr) require.False(t, tm1.SchemeAdmin) user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} @@ -841,8 +817,8 @@ func TestJoinUserToTeam(t *testing.T) { _, err = th.App.UpdateGroupSyncable(gs) require.Nil(t, err) - tm2, _, err := th.App.joinUserToTeam(team, ruser2) - require.Nil(t, err) + tm2, appErr := th.App.JoinUserToTeam(th.Context, team, ruser2, "") + require.Nil(t, appErr) require.True(t, tm2.SchemeAdmin) }) } diff --git a/app/teams/errors.go b/app/teams/errors.go new file mode 100644 index 0000000000..c765c699a8 --- /dev/null +++ b/app/teams/errors.go @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package teams + +import "errors" + +var ( + AcceptedDomainError = errors.New("the user cannot be added as the domain associated with the account is not permitted") + MemberCountError = errors.New("unable to count the team members") + MaxMemberCountError = errors.New("reached to the maximum number of allowed accounts") +) + +type DomainError struct { + Domain string +} + +func (DomainError) Error() string { + return "restricting team to the domain, it is not allowed by the system config" +} diff --git a/app/teams/helper_test.go b/app/teams/helper_test.go new file mode 100644 index 0000000000..ce57919c45 --- /dev/null +++ b/app/teams/helper_test.go @@ -0,0 +1,143 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package teams + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "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/store" +) + +type TestHelper struct { + service *TeamService + configStore *config.Store + dbStore store.Store + workspace string + + Context *request.Context + LogBuffer *bytes.Buffer +} + +type mockWebHub struct{} + +func (mockWebHub) Publish(*model.WebSocketEvent) {} + +func Setup(tb testing.TB) *TestHelper { + if testing.Short() { + tb.SkipNow() + } + dbStore := mainHelper.GetStore() + dbStore.DropAllTables() + dbStore.MarkSystemRanUnitTests() + mainHelper.PreloadMigrations() + + return setupTestHelper(dbStore, false, tb) +} + +func setupTestHelper(s store.Store, includeCacheLayer bool, tb testing.TB) *TestHelper { + tempWorkspace, err := ioutil.TempDir("", "teamservicetest") + if err != nil { + panic(err) + } + + configStore := config.NewTestMemoryStore() + + config := configStore.Get() + *config.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins") + *config.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp") + *config.PluginSettings.AutomaticPrepackagedPlugins = false + *config.LogSettings.EnableSentry = false // disable error reporting during tests + *config.AnnouncementSettings.AdminNoticesEnabled = false + *config.AnnouncementSettings.UserNoticesEnabled = false + *config.TeamSettings.MaxUsersPerTeam = 50 + *config.RateLimitSettings.Enable = false + *config.TeamSettings.EnableOpenServer = true + // Disable strict password requirements for test + *config.PasswordSettings.MinimumLength = 5 + *config.PasswordSettings.Lowercase = false + *config.PasswordSettings.Uppercase = false + *config.PasswordSettings.Symbol = false + *config.PasswordSettings.Number = false + configStore.Set(config) + + buffer := &bytes.Buffer{} + + return &TestHelper{ + service: &TeamService{ + store: s.Team(), + channelStore: s.Channel(), + groupStore: s.Group(), + config: configStore.Get, + license: func() *model.License { + return model.NewTestLicense() + }, + wh: &mockWebHub{}, + }, + Context: &request.Context{}, + configStore: configStore, + dbStore: s, + LogBuffer: buffer, + workspace: tempWorkspace, + } +} + +func (th *TestHelper) TearDown() { + th.configStore.Close() + + th.dbStore.Close() + + if th.workspace != "" { + os.RemoveAll(th.workspace) + } +} + +func (th *TestHelper) UpdateConfig(f func(*model.Config)) { + if th.configStore.IsReadOnly() { + return + } + old := th.configStore.Get() + updated := old.Clone() + f(updated) + if _, _, err := th.configStore.Set(updated); err != nil { + panic(err) + } +} + +func (th *TestHelper) CreateUser(u *model.User) *model.User { + u.EmailVerified = true + user, err := th.dbStore.User().Save(u) + if err != nil { + panic(err) + } + + return user +} + +func (th *TestHelper) DeleteUser(u *model.User) { + err := th.dbStore.User().PermanentDelete(u.Id) + if err != nil { + panic(err) + } +} + +func (th *TestHelper) DeleteTeam(t *model.Team) { + if err := th.dbStore.Channel().PermanentDeleteByTeam(t.Id); err != nil { + panic(err) + } + + if err := th.dbStore.Team().RemoveAllMembersByTeam(t.Id); err != nil { + panic(err) + } + + if err := th.dbStore.Team().PermanentDelete(t.Id); err != nil { + panic(err) + } +} diff --git a/app/teams/main_test.go b/app/teams/main_test.go new file mode 100644 index 0000000000..9a136ec5c2 --- /dev/null +++ b/app/teams/main_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package teams + +import ( + "flag" + "testing" + + "github.com/mattermost/mattermost-server/v6/testlib" +) + +var mainHelper *testlib.MainHelper +var replicaFlag bool + +func TestMain(m *testing.M) { + if f := flag.Lookup("mysql-replica"); f == nil { + flag.BoolVar(&replicaFlag, "mysql-replica", false, "") + flag.Parse() + } + + var options = testlib.HelperOptions{ + EnableStore: true, + EnableResources: true, + WithReadReplica: replicaFlag, + } + + mainHelper = testlib.NewMainHelperWithOptions(&options) + defer mainHelper.Close() + + mainHelper.Main(m) +} diff --git a/app/teams/service.go b/app/teams/service.go new file mode 100644 index 0000000000..a818c7d8ce --- /dev/null +++ b/app/teams/service.go @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package teams + +import ( + "errors" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" +) + +type TeamService struct { + store store.TeamStore + groupStore store.GroupStore + channelStore store.ChannelStore // TODO: replace this with ChannelService in the future + users Users + wh WebHub + config func() *model.Config + license func() *model.License +} + +// ServiceConfig is used to initialize the TeamService. +type ServiceConfig struct { + // Mandatory fields + TeamStore store.TeamStore + GroupStore store.GroupStore + ChannelStore store.ChannelStore + Users Users + WebHub WebHub + ConfigFn func() *model.Config + LicenseFn func() *model.License +} + +// Users is a subset of UserService interface +type Users interface { + GetUser(userID string) (*model.User, error) +} + +// WebHub is used to publish events, the name should be given appropriately +// while developing the websocket or clustering service +type WebHub interface { + Publish(message *model.WebSocketEvent) +} + +func New(c ServiceConfig) (*TeamService, error) { + if err := c.validate(); err != nil { + return nil, err + } + + return &TeamService{ + store: c.TeamStore, + groupStore: c.GroupStore, + channelStore: c.ChannelStore, + users: c.Users, + config: c.ConfigFn, + license: c.LicenseFn, + wh: c.WebHub, + }, nil +} + +func (c *ServiceConfig) validate() error { + if c.ConfigFn == nil || c.TeamStore == nil || c.LicenseFn == nil || c.Users == nil || c.ChannelStore == nil || c.GroupStore == nil || c.WebHub == nil { + return errors.New("required parameters are not provided") + } + + return nil +} diff --git a/app/teams/teams.go b/app/teams/teams.go new file mode 100644 index 0000000000..4511f96417 --- /dev/null +++ b/app/teams/teams.go @@ -0,0 +1,198 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package teams + +import ( + "context" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/i18n" +) + +func (ts *TeamService) CreateTeam(team *model.Team) (*model.Team, error) { + team.InviteId = "" + rteam, err := ts.store.Save(team) + if err != nil { + return nil, err + } + + if _, err := ts.createDefaultChannels(rteam.Id); err != nil { + return nil, err + } + + return rteam, nil +} + +func (ts *TeamService) GetTeam(teamID string) (*model.Team, error) { + team, err := ts.store.Get(teamID) + if err != nil { + return nil, err + } + + return team, nil +} + +// 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"), + "off-topic": i18n.T("api.channel.create_default_channels.off_topic"), + } + channels := []*model.Channel{} + defaultChannelNames := ts.DefaultChannelNames() + for _, name := range defaultChannelNames { + displayName := i18n.TDefault(displayNames[name], name) + channel := &model.Channel{DisplayName: displayName, Name: name, Type: model.ChannelTypeOpen, TeamId: teamID} + // We should use the channel service here (coming soon). Ideally, we should just emit an event + // and let the subscribers do the job, in this case it would be the channels service. + // Currently we are adding services to the server and because of that we are using + // the channel store here. This should be replaced in the future. + if _, err := ts.channelStore.Save(channel, *ts.config().TeamSettings.MaxChannelsPerTeam); err != nil { + return nil, err + } + channels = append(channels, channel) + } + return channels, nil +} + +type UpdateOptions struct { + Sanitized bool + Imported bool +} + +func (ts *TeamService) UpdateTeam(team *model.Team, opts UpdateOptions) (*model.Team, error) { + oldTeam := team + var err error + + if !opts.Imported { + oldTeam, err = ts.store.Get(team.Id) + if err != nil { + return nil, err + } + + if err = ts.checkValidDomains(team); err != nil { + return nil, err + } + } + + if opts.Sanitized { + oldTeam.DisplayName = team.DisplayName + oldTeam.Description = team.Description + oldTeam.AllowOpenInvite = team.AllowOpenInvite + oldTeam.CompanyName = team.CompanyName + oldTeam.AllowedDomains = team.AllowedDomains + oldTeam.LastTeamIconUpdate = team.LastTeamIconUpdate + oldTeam.GroupConstrained = team.GroupConstrained + } + + oldTeam, err = ts.store.Update(oldTeam) + if err != nil { + return team, err + } + + return oldTeam, nil +} + +func (ts *TeamService) PatchTeam(teamID string, patch *model.TeamPatch) (*model.Team, error) { + team, err := ts.store.Get(teamID) + if err != nil { + return nil, err + } + + team.Patch(patch) + if patch.AllowOpenInvite != nil && !*patch.AllowOpenInvite { + team.InviteId = model.NewId() + } + + if err = ts.checkValidDomains(team); err != nil { + return nil, err + } + + team, err = ts.store.Update(team) + if err != nil { + return team, err + } + + return team, nil +} + +// JoinUserToTeam adds a user to the team and it returns three values: +// 1. a pointer to the team member, if successful +// 2. a boolean: true if the user has a non-deleted team member for that team already, otherwise false. +// 3. a pointer to an AppError if something went wrong. +func (ts *TeamService) JoinUserToTeam(team *model.Team, user *model.User) (*model.TeamMember, bool, error) { + if !ts.IsTeamEmailAllowed(user, team) { + return nil, false, AcceptedDomainError + } + + tm := &model.TeamMember{ + TeamId: team.Id, + UserId: user.Id, + SchemeGuest: user.IsGuest(), + SchemeUser: !user.IsGuest(), + } + + if !user.IsGuest() { + userShouldBeAdmin, err := ts.userIsInAdminRoleGroup(user.Id, team.Id, model.GroupSyncableTypeTeam) + if err != nil { + return nil, false, err + } + tm.SchemeAdmin = userShouldBeAdmin + } + + if team.Email == user.Email { + tm.SchemeAdmin = true + } + + rtm, err := ts.store.GetMember(context.Background(), team.Id, user.Id) + if err != nil { + // Membership appears to be missing. Lets try to add. + tmr, nErr := ts.store.SaveMember(tm, *ts.config().TeamSettings.MaxUsersPerTeam) + if nErr != nil { + return nil, false, nErr + } + return tmr, false, nil + } + + // Membership already exists. Check if deleted and update, otherwise do nothing + // Do nothing if already added + if rtm.DeleteAt == 0 { + return rtm, true, nil + } + + membersCount, err := ts.store.GetActiveMemberCount(tm.TeamId, nil) + if err != nil { + return nil, false, MemberCountError + } + + if membersCount >= int64(*ts.config().TeamSettings.MaxUsersPerTeam) { + return nil, false, MaxMemberCountError + } + + member, nErr := ts.store.UpdateMember(tm) + if nErr != nil { + return nil, false, nErr + } + + return member, false, nil +} + +// 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) + + teamMember.Roles = "" + teamMember.DeleteAt = model.GetMillis() + + if _, nErr := ts.store.UpdateMember(teamMember); nErr != nil { + return nErr + } + + return nil +} diff --git a/app/teams/teams_test.go b/app/teams/teams_test.go new file mode 100644 index 0000000000..935a3337dc --- /dev/null +++ b/app/teams/teams_test.go @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package teams + +import ( + "strings" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func TestCreateTeam(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + id := model.NewId() + team := &model.Team{ + DisplayName: "dn_" + id, + Name: "name" + id, + Email: "success+" + id + "@simulator.amazonses.com", + Type: model.TeamOpen, + } + + _, err := th.service.CreateTeam(team) + require.NoError(t, err, "Should create a new team") + + _, err = th.service.CreateTeam(team) + require.Error(t, err, "Should not create a new team - team already exist") +} + +func TestJoinUserToTeam(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + id := model.NewId() + team := &model.Team{ + DisplayName: "dn_" + id, + Name: "name" + id, + Email: "success+" + id + "@simulator.amazonses.com", + Type: model.TeamOpen, + } + + _, err := th.service.CreateTeam(team) + require.NoError(t, err, "Should create a new team") + + maxUsersPerTeam := th.service.config().TeamSettings.MaxUsersPerTeam + defer func() { + th.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.MaxUsersPerTeam = maxUsersPerTeam }) + th.DeleteTeam(team) + }() + one := 1 + th.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.MaxUsersPerTeam = &one }) + + t.Run("new join", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser := th.CreateUser(&user) + defer th.DeleteUser(&user) + + _, alreadyAdded, err := th.service.JoinUserToTeam(team, ruser) + require.False(t, alreadyAdded, "Should return already added equal to false") + require.NoError(t, err) + }) + + t.Run("join when you are a member", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser := th.CreateUser(&user) + defer th.DeleteUser(&user) + + _, _, err := th.service.JoinUserToTeam(team, ruser) + require.NoError(t, err) + + _, alreadyAdded, err := th.service.JoinUserToTeam(team, ruser) + require.True(t, alreadyAdded, "Should return already added") + require.NoError(t, err) + }) + + t.Run("re-join after leaving", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser := th.CreateUser(&user) + defer th.DeleteUser(&user) + + member, _, err := th.service.JoinUserToTeam(team, ruser) + require.NoError(t, err) + err = th.service.RemoveTeamMember(member) + require.NoError(t, err) + + _, alreadyAdded, err := th.service.JoinUserToTeam(team, ruser) + require.False(t, alreadyAdded, "Should return already added equal to false") + require.NoError(t, err) + }) + + t.Run("new join with limit problem", func(t *testing.T) { + user1 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser1 := th.CreateUser(&user1) + user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser2 := th.CreateUser(&user2) + + defer th.DeleteUser(&user1) + defer th.DeleteUser(&user2) + + _, _, err := th.service.JoinUserToTeam(team, ruser1) + require.NoError(t, err) + + _, _, err = th.service.JoinUserToTeam(team, ruser2) + require.Error(t, err, "Should fail") + }) + + t.Run("re-join alfter leaving with limit problem", func(t *testing.T) { + user1 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser1 := th.CreateUser(&user1) + + user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser2 := th.CreateUser(&user2) + + defer th.DeleteUser(&user1) + defer th.DeleteUser(&user2) + + member, _, err := th.service.JoinUserToTeam(team, ruser1) + require.NoError(t, err) + err = th.service.RemoveTeamMember(member) + require.NoError(t, err) + _, _, err = th.service.JoinUserToTeam(team, ruser2) + require.NoError(t, err) + + _, _, err = th.service.JoinUserToTeam(team, ruser1) + require.Error(t, err, "Should fail") + }) +} diff --git a/app/teams/utils.go b/app/teams/utils.go new file mode 100644 index 0000000000..2fc8e199d6 --- /dev/null +++ b/app/teams/utils.go @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package teams + +import ( + "strings" + + "github.com/mattermost/mattermost-server/v6/model" +) + +// 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'] +// +func (ts *TeamService) DefaultChannelNames() []string { + names := []string{"town-square"} + + if len(ts.config().TeamSettings.ExperimentalDefaultChannels) == 0 { + names = append(names, "off-topic") + } else { + seenChannels := map[string]bool{"town-square": true} + for _, channelName := range ts.config().TeamSettings.ExperimentalDefaultChannels { + if !seenChannels[channelName] { + names = append(names, channelName) + seenChannels[channelName] = true + } + } + } + + return names +} + +func IsEmailAddressAllowed(email string, allowedDomains []string) bool { + for _, restriction := range allowedDomains { + domains := normalizeDomains(restriction) + if len(domains) <= 0 { + continue + } + matched := false + for _, d := range domains { + if strings.HasSuffix(email, "@"+d) { + matched = true + break + } + } + if !matched { + return false + } + } + + return true +} + +func (ts *TeamService) IsTeamEmailAllowed(user *model.User, team *model.Team) bool { + if user.IsBot { + return true + } + email := strings.ToLower(user.Email) + allowedDomains := ts.GetAllowedDomains(user, team) + return IsEmailAddressAllowed(email, allowedDomains) +} + +func (ts *TeamService) GetAllowedDomains(user *model.User, team *model.Team) []string { + if user.IsGuest() { + return []string{*ts.config().GuestAccountsSettings.RestrictCreationToDomains} + } + // First check per team allowedDomains, then app wide restrictions + return []string{team.AllowedDomains, *ts.config().TeamSettings.RestrictCreationToDomains} +} + +func (ts *TeamService) checkValidDomains(team *model.Team) error { + validDomains := normalizeDomains(*ts.config().TeamSettings.RestrictCreationToDomains) + if len(validDomains) > 0 { + for _, domain := range normalizeDomains(team.AllowedDomains) { + matched := false + for _, d := range validDomains { + if domain == d { + matched = true + break + } + } + if !matched { + return &DomainError{Domain: domain} + } + } + } + + return nil +} + +func normalizeDomains(domains string) []string { + // commas and @ signs are optional + // can be in the form of "@corp.mattermost.com, mattermost.com mattermost.org" -> corp.mattermost.com mattermost.com mattermost.org + return strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(strings.Replace(domains, "@", " ", -1), ",", " ", -1)))) +} + +// UserIsInAdminRoleGroup returns true at least one of the user's groups are configured to set the members as +// admins in the given syncable. +func (ts *TeamService) userIsInAdminRoleGroup(userID, syncableID string, syncableType model.GroupSyncableType) (bool, error) { + groupIDs, err := ts.groupStore.AdminRoleGroupsForSyncableMember(userID, syncableID, syncableType) + if err != nil { + return false, err + } + + if len(groupIDs) == 0 { + return false, nil + } + + return true, nil +} diff --git a/testlib/store.go b/testlib/store.go index 2382164a67..51f5875128 100644 --- a/testlib/store.go +++ b/testlib/store.go @@ -89,6 +89,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { sessionStore := mocks.SessionStore{} oAuthStore := mocks.OAuthStore{} + groupStore := mocks.GroupStore{} mockStore.On("System").Return(&systemStore) mockStore.On("User").Return(&userStore) @@ -103,5 +104,6 @@ func GetMockStoreForSetupFunctions() *mocks.Store { mockStore.On("MarkSystemRanUnitTests").Return(nil) mockStore.On("Session").Return(&sessionStore) mockStore.On("OAuth").Return(&oAuthStore) + mockStore.On("Group").Return(&groupStore) return &mockStore }