diff options
Diffstat (limited to 'app')
39 files changed, 9271 insertions, 0 deletions
diff --git a/app/admin.go b/app/admin.go new file mode 100644 index 000000000..51e69da57 --- /dev/null +++ b/app/admin.go @@ -0,0 +1,191 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "bufio" + "os" + "strings" + "time" + + "runtime/debug" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +func GetLogs() ([]string, *model.AppError) { + lines, err := GetLogsSkipSend() + if err != nil { + return nil, err + } + + if einterfaces.GetClusterInterface() != nil { + clines, err := einterfaces.GetClusterInterface().GetLogs() + if err != nil { + return nil, err + } + + lines = append(lines, clines...) + } + + return lines, nil +} + +func GetLogsSkipSend() ([]string, *model.AppError) { + var lines []string + + if utils.Cfg.LogSettings.EnableFile { + file, err := os.Open(utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation)) + if err != nil { + return nil, model.NewLocAppError("getLogs", "api.admin.file_read_error", nil, err.Error()) + } + + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + } else { + lines = append(lines, "") + } + + return lines, nil +} + +func GetClusterStatus() []*model.ClusterInfo { + infos := make([]*model.ClusterInfo, 0) + + if einterfaces.GetClusterInterface() != nil { + infos = einterfaces.GetClusterInterface().GetClusterInfos() + } + + return infos +} + +func InvalidateAllCaches() *model.AppError { + debug.FreeOSMemory() + InvalidateAllCachesSkipSend() + + if einterfaces.GetClusterInterface() != nil { + err := einterfaces.GetClusterInterface().InvalidateAllCaches() + if err != nil { + return err + } + } + + return nil +} + +func InvalidateAllCachesSkipSend() { + l4g.Info(utils.T("api.context.invalidate_all_caches")) + sessionCache.Purge() + ClearStatusCache() + store.ClearChannelCaches() + store.ClearUserCaches() + store.ClearPostCaches() +} + +func GetConfig() *model.Config { + json := utils.Cfg.ToJson() + cfg := model.ConfigFromJson(strings.NewReader(json)) + cfg.Sanitize() + + return cfg +} + +func ReloadConfig() { + debug.FreeOSMemory() + utils.LoadConfig(utils.CfgFileName) + + // start/restart email batching job if necessary + InitEmailBatching() +} + +func SaveConfig(cfg *model.Config) *model.AppError { + cfg.SetDefaults() + utils.Desanitize(cfg) + + if err := cfg.IsValid(); err != nil { + return err + } + + if err := utils.ValidateLdapFilter(cfg); err != nil { + return err + } + + if *utils.Cfg.ClusterSettings.Enable { + return model.NewLocAppError("saveConfig", "ent.cluster.save_config.error", nil, "") + } + + //oldCfg := utils.Cfg + utils.SaveConfig(utils.CfgFileName, cfg) + utils.LoadConfig(utils.CfgFileName) + + if einterfaces.GetMetricsInterface() != nil { + if *utils.Cfg.MetricsSettings.Enable { + einterfaces.GetMetricsInterface().StartServer() + } else { + einterfaces.GetMetricsInterface().StopServer() + } + } + + // Future feature is to sync the configuration files + // if einterfaces.GetClusterInterface() != nil { + // err := einterfaces.GetClusterInterface().ConfigChanged(cfg, oldCfg, true) + // if err != nil { + // return err + // } + // } + + // start/restart email batching job if necessary + InitEmailBatching() + + return nil +} + +func RecycleDatabaseConnection() { + oldStore := Srv.Store + + l4g.Warn(utils.T("api.admin.recycle_db_start.warn")) + Srv.Store = store.NewSqlStore() + + time.Sleep(20 * time.Second) + oldStore.Close() + + l4g.Warn(utils.T("api.admin.recycle_db_end.warn")) +} + +func TestEmail(userId string, cfg *model.Config) *model.AppError { + if len(cfg.EmailSettings.SMTPServer) == 0 { + return model.NewLocAppError("testEmail", "api.admin.test_email.missing_server", nil, utils.T("api.context.invalid_param.app_error", map[string]interface{}{"Name": "SMTPServer"})) + } + + // if the user hasn't changed their email settings, fill in the actual SMTP password so that + // the user can verify an existing SMTP connection + if cfg.EmailSettings.SMTPPassword == model.FAKE_SETTING { + if cfg.EmailSettings.SMTPServer == utils.Cfg.EmailSettings.SMTPServer && + cfg.EmailSettings.SMTPPort == utils.Cfg.EmailSettings.SMTPPort && + cfg.EmailSettings.SMTPUsername == utils.Cfg.EmailSettings.SMTPUsername { + cfg.EmailSettings.SMTPPassword = utils.Cfg.EmailSettings.SMTPPassword + } else { + return model.NewLocAppError("testEmail", "api.admin.test_email.reenter_password", nil, "") + } + } + + if user, err := GetUser(userId); err != nil { + return err + } else { + T := utils.GetUserTranslations(user.Locale) + if err := utils.SendMailUsingConfig(user.Email, T("api.admin.test_email.subject"), T("api.admin.test_email.body"), cfg); err != nil { + return err + } + } + + return nil +} diff --git a/app/analytics.go b/app/analytics.go new file mode 100644 index 000000000..891c0dfae --- /dev/null +++ b/app/analytics.go @@ -0,0 +1,239 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +const ( + DAY_MILLISECONDS = 24 * 60 * 60 * 1000 + MONTH_MILLISECONDS = 31 * DAY_MILLISECONDS +) + +func GetAnalytics(name string, teamId string) (model.AnalyticsRows, *model.AppError) { + skipIntensiveQueries := false + var systemUserCount int64 + if r := <-Srv.Store.User().AnalyticsUniqueUserCount(""); r.Err != nil { + return nil, r.Err + } else { + systemUserCount = r.Data.(int64) + if systemUserCount > int64(*utils.Cfg.AnalyticsSettings.MaxUsersForStatistics) { + l4g.Debug("More than %v users on the system, intensive queries skipped", *utils.Cfg.AnalyticsSettings.MaxUsersForStatistics) + skipIntensiveQueries = true + } + } + + if name == "standard" { + var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 10) + rows[0] = &model.AnalyticsRow{"channel_open_count", 0} + rows[1] = &model.AnalyticsRow{"channel_private_count", 0} + rows[2] = &model.AnalyticsRow{"post_count", 0} + rows[3] = &model.AnalyticsRow{"unique_user_count", 0} + rows[4] = &model.AnalyticsRow{"team_count", 0} + rows[5] = &model.AnalyticsRow{"total_websocket_connections", 0} + rows[6] = &model.AnalyticsRow{"total_master_db_connections", 0} + rows[7] = &model.AnalyticsRow{"total_read_db_connections", 0} + rows[8] = &model.AnalyticsRow{"daily_active_users", 0} + rows[9] = &model.AnalyticsRow{"monthly_active_users", 0} + + openChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_OPEN) + privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE) + teamChan := Srv.Store.Team().AnalyticsTeamCount() + + var userChan store.StoreChannel + if teamId != "" { + userChan = Srv.Store.User().AnalyticsUniqueUserCount(teamId) + } + + var postChan store.StoreChannel + if !skipIntensiveQueries { + postChan = Srv.Store.Post().AnalyticsPostCount(teamId, false, false) + } + + dailyActiveChan := Srv.Store.User().AnalyticsActiveCount(DAY_MILLISECONDS) + monthlyActiveChan := Srv.Store.User().AnalyticsActiveCount(MONTH_MILLISECONDS) + + if r := <-openChan; r.Err != nil { + return nil, r.Err + } else { + rows[0].Value = float64(r.Data.(int64)) + } + + if r := <-privateChan; r.Err != nil { + return nil, r.Err + } else { + rows[1].Value = float64(r.Data.(int64)) + } + + if postChan == nil { + rows[2].Value = -1 + } else { + if r := <-postChan; r.Err != nil { + return nil, r.Err + } else { + rows[2].Value = float64(r.Data.(int64)) + } + } + + if userChan == nil { + rows[3].Value = float64(systemUserCount) + } else { + if r := <-userChan; r.Err != nil { + return nil, r.Err + } else { + rows[3].Value = float64(r.Data.(int64)) + } + } + + if r := <-teamChan; r.Err != nil { + return nil, r.Err + } else { + rows[4].Value = float64(r.Data.(int64)) + } + + // If in HA mode then aggregrate all the stats + if einterfaces.GetClusterInterface() != nil && *utils.Cfg.ClusterSettings.Enable { + stats, err := einterfaces.GetClusterInterface().GetClusterStats() + if err != nil { + return nil, err + } + + totalSockets := TotalWebsocketConnections() + totalMasterDb := Srv.Store.TotalMasterDbConnections() + totalReadDb := Srv.Store.TotalReadDbConnections() + + for _, stat := range stats { + totalSockets = totalSockets + stat.TotalWebsocketConnections + totalMasterDb = totalMasterDb + stat.TotalMasterDbConnections + totalReadDb = totalReadDb + stat.TotalReadDbConnections + } + + rows[5].Value = float64(totalSockets) + rows[6].Value = float64(totalMasterDb) + rows[7].Value = float64(totalReadDb) + + } else { + rows[5].Value = float64(TotalWebsocketConnections()) + rows[6].Value = float64(Srv.Store.TotalMasterDbConnections()) + rows[7].Value = float64(Srv.Store.TotalReadDbConnections()) + } + + if r := <-dailyActiveChan; r.Err != nil { + return nil, r.Err + } else { + rows[8].Value = float64(r.Data.(int64)) + } + + if r := <-monthlyActiveChan; r.Err != nil { + return nil, r.Err + } else { + rows[9].Value = float64(r.Data.(int64)) + } + + return rows, nil + } else if name == "post_counts_day" { + if skipIntensiveQueries { + rows := model.AnalyticsRows{&model.AnalyticsRow{"", -1}} + return rows, nil + } + + if r := <-Srv.Store.Post().AnalyticsPostCountsByDay(teamId); r.Err != nil { + return nil, r.Err + } else { + return r.Data.(model.AnalyticsRows), nil + } + } else if name == "user_counts_with_posts_day" { + if skipIntensiveQueries { + rows := model.AnalyticsRows{&model.AnalyticsRow{"", -1}} + return rows, nil + } + + if r := <-Srv.Store.Post().AnalyticsUserCountsWithPostsByDay(teamId); r.Err != nil { + return nil, r.Err + } else { + return r.Data.(model.AnalyticsRows), nil + } + } else if name == "extra_counts" { + var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 6) + rows[0] = &model.AnalyticsRow{"file_post_count", 0} + rows[1] = &model.AnalyticsRow{"hashtag_post_count", 0} + rows[2] = &model.AnalyticsRow{"incoming_webhook_count", 0} + rows[3] = &model.AnalyticsRow{"outgoing_webhook_count", 0} + rows[4] = &model.AnalyticsRow{"command_count", 0} + rows[5] = &model.AnalyticsRow{"session_count", 0} + + iHookChan := Srv.Store.Webhook().AnalyticsIncomingCount(teamId) + oHookChan := Srv.Store.Webhook().AnalyticsOutgoingCount(teamId) + commandChan := Srv.Store.Command().AnalyticsCommandCount(teamId) + sessionChan := Srv.Store.Session().AnalyticsSessionCount() + + var fileChan store.StoreChannel + var hashtagChan store.StoreChannel + if !skipIntensiveQueries { + fileChan = Srv.Store.Post().AnalyticsPostCount(teamId, true, false) + hashtagChan = Srv.Store.Post().AnalyticsPostCount(teamId, false, true) + } + + if fileChan == nil { + rows[0].Value = -1 + } else { + if r := <-fileChan; r.Err != nil { + return nil, r.Err + } else { + rows[0].Value = float64(r.Data.(int64)) + } + } + + if hashtagChan == nil { + rows[1].Value = -1 + } else { + if r := <-hashtagChan; r.Err != nil { + return nil, r.Err + } else { + rows[1].Value = float64(r.Data.(int64)) + } + } + + if r := <-iHookChan; r.Err != nil { + return nil, r.Err + } else { + rows[2].Value = float64(r.Data.(int64)) + } + + if r := <-oHookChan; r.Err != nil { + return nil, r.Err + } else { + rows[3].Value = float64(r.Data.(int64)) + } + + if r := <-commandChan; r.Err != nil { + return nil, r.Err + } else { + rows[4].Value = float64(r.Data.(int64)) + } + + if r := <-sessionChan; r.Err != nil { + return nil, r.Err + } else { + rows[5].Value = float64(r.Data.(int64)) + } + + return rows, nil + } + + return nil, nil +} + +func GetRecentlyActiveUsersForTeam(teamId string) (map[string]*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetRecentlyActiveUsersForTeam(teamId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(map[string]*model.User), nil + } +} diff --git a/app/app.go b/app/app.go new file mode 100644 index 000000000..8568c7bba --- /dev/null +++ b/app/app.go @@ -0,0 +1,16 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "io/ioutil" + "net/http" +) + +func CloseBody(r *http.Response) { + if r.Body != nil { + ioutil.ReadAll(r.Body) + r.Body.Close() + } +} diff --git a/app/apptestlib.go b/app/apptestlib.go new file mode 100644 index 000000000..41e234130 --- /dev/null +++ b/app/apptestlib.go @@ -0,0 +1,192 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "time" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + + l4g "github.com/alecthomas/log4go" +) + +type TestHelper struct { + BasicTeam *model.Team + BasicUser *model.User + BasicUser2 *model.User + BasicChannel *model.Channel + BasicPost *model.Post +} + +func SetupEnterprise() *TestHelper { + if Srv == nil { + utils.TranslationsPreInit() + utils.LoadConfig("config.json") + utils.InitTranslations(utils.Cfg.LocalizationSettings) + utils.Cfg.TeamSettings.MaxUsersPerTeam = 50 + *utils.Cfg.RateLimitSettings.Enable = false + utils.DisableDebugLogForTest() + utils.License.Features.SetDefaults() + NewServer() + InitStores() + StartServer() + utils.InitHTML() + utils.EnableDebugLogForTest() + Srv.Store.MarkSystemRanUnitTests() + + *utils.Cfg.TeamSettings.EnableOpenServer = true + } + + return &TestHelper{} +} + +func Setup() *TestHelper { + if Srv == nil { + utils.TranslationsPreInit() + utils.LoadConfig("config.json") + utils.InitTranslations(utils.Cfg.LocalizationSettings) + utils.Cfg.TeamSettings.MaxUsersPerTeam = 50 + *utils.Cfg.RateLimitSettings.Enable = false + utils.DisableDebugLogForTest() + NewServer() + InitStores() + StartServer() + utils.InitHTML() + utils.EnableDebugLogForTest() + Srv.Store.MarkSystemRanUnitTests() + + *utils.Cfg.TeamSettings.EnableOpenServer = true + } + + return &TestHelper{} +} + +func (me *TestHelper) InitBasic() *TestHelper { + me.BasicTeam = me.CreateTeam() + me.BasicUser = me.CreateUser() + LinkUserToTeam(me.BasicUser, me.BasicTeam) + me.BasicUser2 = me.CreateUser() + LinkUserToTeam(me.BasicUser2, me.BasicTeam) + me.BasicChannel = me.CreateChannel(me.BasicTeam) + me.BasicPost = me.CreatePost(me.BasicChannel) + + return me +} + +func (me *TestHelper) CreateTeam() *model.Team { + id := model.NewId() + team := &model.Team{ + DisplayName: "dn_" + id, + Name: "name" + id, + Email: "success+" + id + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + + utils.DisableDebugLogForTest() + var err *model.AppError + if team, err = CreateTeam(team); err != nil { + l4g.Error(err.Error()) + l4g.Close() + time.Sleep(time.Second) + panic(err) + } + utils.EnableDebugLogForTest() + return team +} + +func (me *TestHelper) CreateUser() *model.User { + id := model.NewId() + + user := &model.User{ + Email: "success+" + id + "@simulator.amazonses.com", + Username: "un_" + id, + Nickname: "nn_" + id, + Password: "Password1", + EmailVerified: true, + } + + utils.DisableDebugLogForTest() + var err *model.AppError + if user, err = CreateUser(user); err != nil { + l4g.Error(err.Error()) + l4g.Close() + time.Sleep(time.Second) + panic(err) + } + utils.EnableDebugLogForTest() + return user +} + +func (me *TestHelper) CreateChannel(team *model.Team) *model.Channel { + return me.createChannel(team, model.CHANNEL_OPEN) +} + +func (me *TestHelper) CreatePrivateChannel(team *model.Team) *model.Channel { + return me.createChannel(team, model.CHANNEL_PRIVATE) +} + +func (me *TestHelper) createChannel(team *model.Team, channelType string) *model.Channel { + id := model.NewId() + + channel := &model.Channel{ + DisplayName: "dn_" + id, + Name: "name_" + id, + Type: channelType, + TeamId: team.Id, + CreatorId: me.BasicUser.Id, + } + + utils.DisableDebugLogForTest() + var err *model.AppError + if channel, err = CreateChannel(channel, true); err != nil { + l4g.Error(err.Error()) + l4g.Close() + time.Sleep(time.Second) + panic(err) + } + utils.EnableDebugLogForTest() + return channel +} + +func (me *TestHelper) CreatePost(channel *model.Channel) *model.Post { + id := model.NewId() + + post := &model.Post{ + UserId: me.BasicUser.Id, + ChannelId: channel.Id, + Message: "message_" + id, + } + + utils.DisableDebugLogForTest() + var err *model.AppError + if post, err = CreatePost(post, channel.TeamId, false); err != nil { + l4g.Error(err.Error()) + l4g.Close() + time.Sleep(time.Second) + panic(err) + } + utils.EnableDebugLogForTest() + return post +} + +func LinkUserToTeam(user *model.User, team *model.Team) { + utils.DisableDebugLogForTest() + + err := JoinUserToTeam(team, user) + if err != nil { + l4g.Error(err.Error()) + l4g.Close() + time.Sleep(time.Second) + panic(err) + } + + utils.EnableDebugLogForTest() +} + +func TearDown() { + if Srv != nil { + StopServer() + } +} diff --git a/app/audit.go b/app/audit.go new file mode 100644 index 000000000..6978e9bc2 --- /dev/null +++ b/app/audit.go @@ -0,0 +1,16 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/platform/model" +) + +func GetAudits(userId string, limit int) (model.Audits, *model.AppError) { + if result := <-Srv.Store.Audit().Get(userId, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(model.Audits), nil + } +} diff --git a/app/authorization.go b/app/authorization.go new file mode 100644 index 000000000..b43d64341 --- /dev/null +++ b/app/authorization.go @@ -0,0 +1,197 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/model" +) + +func SessionHasPermissionTo(session model.Session, permission *model.Permission) bool { + return CheckIfRolesGrantPermission(session.GetUserRoles(), permission.Id) +} + +func SessionHasPermissionToTeam(session model.Session, teamId string, permission *model.Permission) bool { + if teamId == "" { + return false + } + + teamMember := session.GetTeamByTeamId(teamId) + if teamMember != nil { + if CheckIfRolesGrantPermission(teamMember.GetRoles(), permission.Id) { + return true + } + } + + return SessionHasPermissionTo(session, permission) +} + +func SessionHasPermissionToChannel(session model.Session, channelId string, permission *model.Permission) bool { + if channelId == "" { + return false + } + + channelMember, err := GetChannelMember(channelId, session.UserId) + if err == nil { + roles := channelMember.GetRoles() + if CheckIfRolesGrantPermission(roles, permission.Id) { + return true + } + } + + var channel *model.Channel + channel, err = GetChannel(channelId) + if err == nil { + return SessionHasPermissionToTeam(session, channel.TeamId, permission) + } + + return SessionHasPermissionTo(session, permission) +} + +func SessionHasPermissionToChannelByPost(session model.Session, postId string, permission *model.Permission) bool { + var channelMember *model.ChannelMember + if result := <-Srv.Store.Channel().GetMemberForPost(postId, session.UserId); result.Err == nil { + channelMember = result.Data.(*model.ChannelMember) + + if CheckIfRolesGrantPermission(channelMember.GetRoles(), permission.Id) { + return true + } + } + + if result := <-Srv.Store.Channel().GetForPost(postId); result.Err == nil { + channel := result.Data.(*model.Channel) + return SessionHasPermissionToTeam(session, channel.TeamId, permission) + } + + return SessionHasPermissionTo(session, permission) +} + +func SessionHasPermissionToUser(session model.Session, userId string) bool { + if userId == "" { + return false + } + + if session.UserId == userId { + return true + } + + if SessionHasPermissionTo(session, model.PERMISSION_EDIT_OTHER_USERS) { + return true + } + + return false +} + +func SessionHasPermissionToPost(session model.Session, postId string, permission *model.Permission) bool { + post, err := GetSinglePost(postId) + if err != nil { + return false + } + + if post.UserId == session.UserId { + return true + } + + return SessionHasPermissionToChannel(session, post.ChannelId, permission) +} + +func HasPermissionTo(askingUserId string, permission *model.Permission) bool { + user, err := GetUser(askingUserId) + if err != nil { + return false + } + + roles := user.GetRoles() + + return CheckIfRolesGrantPermission(roles, permission.Id) +} + +func HasPermissionToTeam(askingUserId string, teamId string, permission *model.Permission) bool { + if teamId == "" || askingUserId == "" { + return false + } + + teamMember, err := GetTeamMember(teamId, askingUserId) + if err != nil { + return false + } + + roles := teamMember.GetRoles() + + if CheckIfRolesGrantPermission(roles, permission.Id) { + return true + } + + return HasPermissionTo(askingUserId, permission) +} + +func HasPermissionToChannel(askingUserId string, channelId string, permission *model.Permission) bool { + if channelId == "" || askingUserId == "" { + return false + } + + channelMember, err := GetChannelMember(channelId, askingUserId) + if err == nil { + roles := channelMember.GetRoles() + if CheckIfRolesGrantPermission(roles, permission.Id) { + return true + } + } + + var channel *model.Channel + channel, err = GetChannel(channelId) + if err == nil { + return HasPermissionToTeam(askingUserId, channel.TeamId, permission) + } + + return HasPermissionTo(askingUserId, permission) +} + +func HasPermissionToChannelByPost(askingUserId string, postId string, permission *model.Permission) bool { + var channelMember *model.ChannelMember + if result := <-Srv.Store.Channel().GetMemberForPost(postId, askingUserId); result.Err == nil { + channelMember = result.Data.(*model.ChannelMember) + + if CheckIfRolesGrantPermission(channelMember.GetRoles(), permission.Id) { + return true + } + } + + if result := <-Srv.Store.Channel().GetForPost(postId); result.Err == nil { + channel := result.Data.(*model.Channel) + return HasPermissionToTeam(askingUserId, channel.TeamId, permission) + } + + return HasPermissionTo(askingUserId, permission) +} + +func HasPermissionToUser(askingUserId string, userId string) bool { + if askingUserId == userId { + return true + } + + if HasPermissionTo(askingUserId, model.PERMISSION_EDIT_OTHER_USERS) { + return true + } + + return false +} + +func CheckIfRolesGrantPermission(roles []string, permissionId string) bool { + for _, roleId := range roles { + if role, ok := model.BuiltInRoles[roleId]; !ok { + l4g.Debug("Bad role in system " + roleId) + return false + } else { + permissions := role.Permissions + for _, permission := range permissions { + if permission == permissionId { + return true + } + } + } + } + + return false +} diff --git a/app/authorization_test.go b/app/authorization_test.go new file mode 100644 index 000000000..049567483 --- /dev/null +++ b/app/authorization_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/platform/model" +) + +func TestCheckIfRolesGrantPermission(t *testing.T) { + Setup() + + cases := []struct { + roles []string + permissionId string + shouldGrant bool + }{ + {[]string{model.ROLE_SYSTEM_ADMIN.Id}, model.ROLE_SYSTEM_ADMIN.Permissions[0], true}, + {[]string{model.ROLE_SYSTEM_ADMIN.Id}, "non-existant-permission", false}, + {[]string{model.ROLE_CHANNEL_USER.Id}, model.ROLE_CHANNEL_USER.Permissions[0], true}, + {[]string{model.ROLE_CHANNEL_USER.Id}, model.PERMISSION_MANAGE_SYSTEM.Id, false}, + {[]string{model.ROLE_SYSTEM_ADMIN.Id, model.ROLE_CHANNEL_USER.Id}, model.PERMISSION_MANAGE_SYSTEM.Id, true}, + {[]string{model.ROLE_CHANNEL_USER.Id, model.ROLE_SYSTEM_ADMIN.Id}, model.PERMISSION_MANAGE_SYSTEM.Id, true}, + {[]string{model.ROLE_TEAM_USER.Id, model.ROLE_TEAM_ADMIN.Id}, model.PERMISSION_MANAGE_SLASH_COMMANDS.Id, true}, + {[]string{model.ROLE_TEAM_ADMIN.Id, model.ROLE_TEAM_USER.Id}, model.PERMISSION_MANAGE_SLASH_COMMANDS.Id, true}, + } + + for testnum, testcase := range cases { + if CheckIfRolesGrantPermission(testcase.roles, testcase.permissionId) != testcase.shouldGrant { + t.Fatal("Failed test case ", testnum) + } + } + +} diff --git a/app/brand.go b/app/brand.go new file mode 100644 index 000000000..aeecc6972 --- /dev/null +++ b/app/brand.go @@ -0,0 +1,49 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "mime/multipart" + "net/http" + + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func SaveBrandImage(imageData *multipart.FileHeader) *model.AppError { + brandInterface := einterfaces.GetBrandInterface() + if brandInterface == nil { + err := model.NewLocAppError("SaveBrandImage", "api.admin.upload_brand_image.not_available.app_error", nil, "") + err.StatusCode = http.StatusNotImplemented + return err + } + + if err := brandInterface.SaveBrandImage(imageData); err != nil { + return err + } + + return nil +} + +func GetBrandImage() ([]byte, *model.AppError) { + if len(utils.Cfg.FileSettings.DriverName) == 0 { + err := model.NewLocAppError("GetBrandImage", "api.admin.get_brand_image.storage.app_error", nil, "") + err.StatusCode = http.StatusNotImplemented + return nil, err + } + + brandInterface := einterfaces.GetBrandInterface() + if brandInterface == nil { + err := model.NewLocAppError("GetBrandImage", "api.admin.get_brand_image.not_available.app_error", nil, "") + err.StatusCode = http.StatusNotImplemented + return nil, err + } + + if img, err := brandInterface.GetBrandImage(); err != nil { + return nil, err + } else { + return img, nil + } +} diff --git a/app/channel.go b/app/channel.go new file mode 100644 index 000000000..1844e3177 --- /dev/null +++ b/app/channel.go @@ -0,0 +1,766 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "fmt" + "net/http" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +func MakeDirectChannelVisible(channelId string) *model.AppError { + var members []model.ChannelMember + if result := <-Srv.Store.Channel().GetMembers(channelId); result.Err != nil { + return result.Err + } else { + members = result.Data.([]model.ChannelMember) + } + + if len(members) != 2 { + return model.NewLocAppError("MakeDirectChannelVisible", "api.post.make_direct_channel_visible.get_2_members.error", map[string]interface{}{"ChannelId": channelId}, "") + } + + // make sure the channel is visible to both members + for i, member := range members { + otherUserId := members[1-i].UserId + + if result := <-Srv.Store.Preference().Get(member.UserId, model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId); result.Err != nil { + // create a new preference since one doesn't exist yet + preference := &model.Preference{ + UserId: member.UserId, + Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, + Name: otherUserId, + Value: "true", + } + + if saveResult := <-Srv.Store.Preference().Save(&model.Preferences{*preference}); saveResult.Err != nil { + return saveResult.Err + } else { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PREFERENCE_CHANGED, "", "", member.UserId, nil) + message.Add("preference", preference.ToJson()) + + go Publish(message) + } + } else { + preference := result.Data.(model.Preference) + + if preference.Value != "true" { + // update the existing preference to make the channel visible + preference.Value = "true" + + if updateResult := <-Srv.Store.Preference().Save(&model.Preferences{preference}); updateResult.Err != nil { + return updateResult.Err + } else { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PREFERENCE_CHANGED, "", "", member.UserId, nil) + message.Add("preference", preference.ToJson()) + + go Publish(message) + } + } + } + } + + return nil +} + +func CreateDefaultChannels(teamId string) ([]*model.Channel, *model.AppError) { + townSquare := &model.Channel{DisplayName: utils.T("api.channel.create_default_channels.town_square"), Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: teamId} + + if _, err := CreateChannel(townSquare, false); err != nil { + return nil, err + } + + offTopic := &model.Channel{DisplayName: utils.T("api.channel.create_default_channels.off_topic"), Name: "off-topic", Type: model.CHANNEL_OPEN, TeamId: teamId} + + if _, err := CreateChannel(offTopic, false); err != nil { + return nil, err + } + + channels := []*model.Channel{townSquare, offTopic} + return channels, nil +} + +func JoinDefaultChannels(teamId string, user *model.User, channelRole string) *model.AppError { + var err *model.AppError = nil + + if result := <-Srv.Store.Channel().GetByName(teamId, "town-square"); result.Err != nil { + err = result.Err + } else { + cm := &model.ChannelMember{ChannelId: result.Data.(*model.Channel).Id, UserId: user.Id, + Roles: channelRole, NotifyProps: model.GetDefaultChannelNotifyProps()} + + if cmResult := <-Srv.Store.Channel().SaveMember(cm); cmResult.Err != nil { + err = cmResult.Err + } + + post := &model.Post{ + ChannelId: result.Data.(*model.Channel).Id, + Message: fmt.Sprintf(utils.T("api.channel.join_channel.post_and_forget"), user.Username), + Type: model.POST_JOIN_LEAVE, + UserId: user.Id, + } + + InvalidateCacheForChannel(result.Data.(*model.Channel).Id) + + if _, err := CreatePost(post, teamId, false); err != nil { + l4g.Error(utils.T("api.channel.post_user_add_remove_message_and_forget.error"), err) + } + } + + if result := <-Srv.Store.Channel().GetByName(teamId, "off-topic"); result.Err != nil { + err = result.Err + } else { + cm := &model.ChannelMember{ChannelId: result.Data.(*model.Channel).Id, UserId: user.Id, + Roles: channelRole, NotifyProps: model.GetDefaultChannelNotifyProps()} + + if cmResult := <-Srv.Store.Channel().SaveMember(cm); cmResult.Err != nil { + err = cmResult.Err + } + + post := &model.Post{ + ChannelId: result.Data.(*model.Channel).Id, + Message: fmt.Sprintf(utils.T("api.channel.join_channel.post_and_forget"), user.Username), + Type: model.POST_JOIN_LEAVE, + UserId: user.Id, + } + + InvalidateCacheForChannel(result.Data.(*model.Channel).Id) + + if _, err := CreatePost(post, teamId, false); err != nil { + l4g.Error(utils.T("api.channel.post_user_add_remove_message_and_forget.error"), err) + } + } + + return err +} + +func CreateChannel(channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) { + if result := <-Srv.Store.Channel().Save(channel); result.Err != nil { + return nil, result.Err + } else { + sc := result.Data.(*model.Channel) + + if addMember { + cm := &model.ChannelMember{ + ChannelId: sc.Id, + UserId: channel.CreatorId, + Roles: model.ROLE_CHANNEL_USER.Id + " " + model.ROLE_CHANNEL_ADMIN.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + if cmresult := <-Srv.Store.Channel().SaveMember(cm); cmresult.Err != nil { + return nil, cmresult.Err + } + + InvalidateCacheForUser(channel.CreatorId) + } + + return sc, nil + } +} + +func CreateDirectChannel(userId string, otherUserId string) (*model.Channel, *model.AppError) { + uc := Srv.Store.User().Get(otherUserId) + + if uresult := <-uc; uresult.Err != nil { + return nil, model.NewLocAppError("CreateDirectChannel", "api.channel.create_direct_channel.invalid_user.app_error", nil, otherUserId) + } + + if result := <-Srv.Store.Channel().CreateDirectChannel(userId, otherUserId); result.Err != nil { + if result.Err.Id == store.CHANNEL_EXISTS_ERROR { + return result.Data.(*model.Channel), nil + } else { + return nil, result.Err + } + } else { + channel := result.Data.(*model.Channel) + + InvalidateCacheForUser(userId) + InvalidateCacheForUser(otherUserId) + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_DIRECT_ADDED, "", channel.Id, "", nil) + message.Add("teammate_id", otherUserId) + Publish(message) + + return channel, nil + } +} + +func UpdateChannel(channel *model.Channel) (*model.Channel, *model.AppError) { + if result := <-Srv.Store.Channel().Update(channel); result.Err != nil { + return nil, result.Err + } else { + InvalidateCacheForChannel(channel.Id) + return channel, nil + } +} + +func UpdateChannelMemberRoles(channelId string, userId string, newRoles string) (*model.ChannelMember, *model.AppError) { + var member *model.ChannelMember + var err *model.AppError + if member, err = GetChannelMember(channelId, userId); err != nil { + return nil, err + } + + member.Roles = newRoles + + if result := <-Srv.Store.Channel().UpdateMember(member); result.Err != nil { + return nil, result.Err + } + + InvalidateCacheForUser(userId) + return member, nil +} + +func UpdateChannelMemberNotifyProps(data map[string]string, channelId string, userId string) (*model.ChannelMember, *model.AppError) { + var member *model.ChannelMember + var err *model.AppError + if member, err = GetChannelMember(channelId, userId); err != nil { + return nil, err + } + + // update whichever notify properties have been provided, but don't change the others + if markUnread, exists := data["mark_unread"]; exists { + member.NotifyProps["mark_unread"] = markUnread + } + + if desktop, exists := data["desktop"]; exists { + member.NotifyProps["desktop"] = desktop + } + + if result := <-Srv.Store.Channel().UpdateMember(member); result.Err != nil { + return nil, result.Err + } else { + InvalidateCacheForUser(userId) + return member, nil + } +} + +func DeleteChannel(channel *model.Channel, userId string) *model.AppError { + uc := Srv.Store.User().Get(userId) + ihc := Srv.Store.Webhook().GetIncomingByChannel(channel.Id) + ohc := Srv.Store.Webhook().GetOutgoingByChannel(channel.Id) + + if uresult := <-uc; uresult.Err != nil { + return uresult.Err + } else if ihcresult := <-ihc; ihcresult.Err != nil { + return ihcresult.Err + } else if ohcresult := <-ohc; ohcresult.Err != nil { + return ohcresult.Err + } else { + user := uresult.Data.(*model.User) + incomingHooks := ihcresult.Data.([]*model.IncomingWebhook) + outgoingHooks := ohcresult.Data.([]*model.OutgoingWebhook) + + if channel.DeleteAt > 0 { + err := model.NewLocAppError("deleteChannel", "api.channel.delete_channel.deleted.app_error", nil, "") + err.StatusCode = http.StatusBadRequest + return err + } + + if channel.Name == model.DEFAULT_CHANNEL { + err := model.NewLocAppError("deleteChannel", "api.channel.delete_channel.cannot.app_error", map[string]interface{}{"Channel": model.DEFAULT_CHANNEL}, "") + err.StatusCode = http.StatusBadRequest + return err + } + + T := utils.GetUserTranslations(user.Locale) + + post := &model.Post{ + ChannelId: channel.Id, + Message: fmt.Sprintf(T("api.channel.delete_channel.archived"), user.Username), + Type: model.POST_CHANNEL_DELETED, + UserId: userId, + } + + if _, err := CreatePost(post, channel.TeamId, false); err != nil { + l4g.Error(utils.T("api.channel.delete_channel.failed_post.error"), err) + } + + now := model.GetMillis() + for _, hook := range incomingHooks { + if result := <-Srv.Store.Webhook().DeleteIncoming(hook.Id, now); result.Err != nil { + l4g.Error(utils.T("api.channel.delete_channel.incoming_webhook.error"), hook.Id) + } + } + + for _, hook := range outgoingHooks { + if result := <-Srv.Store.Webhook().DeleteOutgoing(hook.Id, now); result.Err != nil { + l4g.Error(utils.T("api.channel.delete_channel.outgoing_webhook.error"), hook.Id) + } + } + + if dresult := <-Srv.Store.Channel().Delete(channel.Id, model.GetMillis()); dresult.Err != nil { + return dresult.Err + } + InvalidateCacheForChannel(channel.Id) + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_DELETED, channel.TeamId, "", "", nil) + message.Add("channel_id", channel.Id) + + Publish(message) + } + + return nil +} + +func AddUserToChannel(user *model.User, channel *model.Channel) (*model.ChannelMember, *model.AppError) { + if channel.DeleteAt > 0 { + return nil, model.NewLocAppError("AddUserToChannel", "api.channel.add_user_to_channel.deleted.app_error", nil, "") + } + + if channel.Type != model.CHANNEL_OPEN && channel.Type != model.CHANNEL_PRIVATE { + return nil, model.NewLocAppError("AddUserToChannel", "api.channel.add_user_to_channel.type.app_error", nil, "") + } + + tmchan := Srv.Store.Team().GetMember(channel.TeamId, user.Id) + cmchan := Srv.Store.Channel().GetMember(channel.Id, user.Id) + + if result := <-tmchan; result.Err != nil { + return nil, result.Err + } else { + teamMember := result.Data.(*model.TeamMember) + if teamMember.DeleteAt > 0 { + return nil, model.NewLocAppError("AddUserToChannel", "api.channel.add_user.to.channel.failed.deleted.app_error", nil, "") + } + } + + if result := <-cmchan; result.Err != nil { + if result.Err.Id != store.MISSING_CHANNEL_MEMBER_ERROR { + return nil, result.Err + } + } else { + channelMember := result.Data.(*model.ChannelMember) + return channelMember, nil + } + + newMember := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + Roles: model.ROLE_CHANNEL_USER.Id, + } + if result := <-Srv.Store.Channel().SaveMember(newMember); result.Err != nil { + l4g.Error("Failed to add member user_id=%v channel_id=%v err=%v", user.Id, channel.Id, result.Err) + return nil, model.NewLocAppError("AddUserToChannel", "api.channel.add_user.to.channel.failed.app_error", nil, "") + } + + InvalidateCacheForUser(user.Id) + InvalidateCacheForChannel(channel.Id) + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_ADDED, "", channel.Id, "", nil) + message.Add("user_id", user.Id) + message.Add("team_id", channel.TeamId) + Publish(message) + + return newMember, nil +} + +func AddDirectChannels(teamId string, user *model.User) *model.AppError { + var profiles map[string]*model.User + if result := <-Srv.Store.User().GetProfiles(teamId, 0, 100); result.Err != nil { + return model.NewLocAppError("AddDirectChannels", "api.user.add_direct_channels_and_forget.failed.error", map[string]interface{}{"UserId": user.Id, "TeamId": teamId, "Error": result.Err.Error()}, "") + } else { + profiles = result.Data.(map[string]*model.User) + } + + var preferences model.Preferences + + for id := range profiles { + if id == user.Id { + continue + } + + profile := profiles[id] + + preference := model.Preference{ + UserId: user.Id, + Category: model.PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW, + Name: profile.Id, + Value: "true", + } + + preferences = append(preferences, preference) + + if len(preferences) >= 10 { + break + } + } + + if result := <-Srv.Store.Preference().Save(&preferences); result.Err != nil { + return model.NewLocAppError("AddDirectChannels", "api.user.add_direct_channels_and_forget.failed.error", map[string]interface{}{"UserId": user.Id, "TeamId": teamId, "Error": result.Err.Error()}, "") + } + + return nil +} + +func PostUpdateChannelHeaderMessage(userId string, channelId string, teamId string, oldChannelHeader, newChannelHeader string) *model.AppError { + uc := Srv.Store.User().Get(userId) + + if uresult := <-uc; uresult.Err != nil { + return model.NewLocAppError("PostUpdateChannelHeaderMessage", "api.channel.post_update_channel_header_message_and_forget.retrieve_user.error", nil, uresult.Err.Error()) + } else { + user := uresult.Data.(*model.User) + + var message string + if oldChannelHeader == "" { + message = fmt.Sprintf(utils.T("api.channel.post_update_channel_header_message_and_forget.updated_to"), user.Username, newChannelHeader) + } else if newChannelHeader == "" { + message = fmt.Sprintf(utils.T("api.channel.post_update_channel_header_message_and_forget.removed"), user.Username, oldChannelHeader) + } else { + message = fmt.Sprintf(utils.T("api.channel.post_update_channel_header_message_and_forget.updated_from"), user.Username, oldChannelHeader, newChannelHeader) + } + + post := &model.Post{ + ChannelId: channelId, + Message: message, + Type: model.POST_HEADER_CHANGE, + UserId: userId, + Props: model.StringInterface{ + "old_header": oldChannelHeader, + "new_header": newChannelHeader, + }, + } + + if _, err := CreatePost(post, teamId, false); err != nil { + return model.NewLocAppError("", "api.channel.post_update_channel_header_message_and_forget.post.error", nil, err.Error()) + } + } + + return nil +} + +func PostUpdateChannelPurposeMessage(userId string, channelId string, teamId string, oldChannelPurpose string, newChannelPurpose string) *model.AppError { + uc := Srv.Store.User().Get(userId) + + if uresult := <-uc; uresult.Err != nil { + return model.NewLocAppError("PostUpdateChannelPurposeMessage", "app.channel.post_update_channel_purpose_message.retrieve_user.error", nil, uresult.Err.Error()) + } else { + user := uresult.Data.(*model.User) + + var message string + if oldChannelPurpose == "" { + message = fmt.Sprintf(utils.T("app.channel.post_update_channel_purpose_message.updated_to"), user.Username, newChannelPurpose) + } else if newChannelPurpose == "" { + message = fmt.Sprintf(utils.T("app.channel.post_update_channel_purpose_message.removed"), user.Username, oldChannelPurpose) + } else { + message = fmt.Sprintf(utils.T("app.channel.post_update_channel_purpose_message.updated_from"), user.Username, oldChannelPurpose, newChannelPurpose) + } + + post := &model.Post{ + ChannelId: channelId, + Message: message, + Type: model.POST_PURPOSE_CHANGE, + UserId: userId, + Props: model.StringInterface{ + "old_purpose": oldChannelPurpose, + "new_purpose": newChannelPurpose, + }, + } + if _, err := CreatePost(post, teamId, false); err != nil { + return model.NewLocAppError("", "app.channel.post_update_channel_purpose_message.post.error", nil, err.Error()) + } + } + + return nil +} + +func PostUpdateChannelDisplayNameMessage(userId string, channelId string, teamId string, oldChannelDisplayName, newChannelDisplayName string) *model.AppError { + uc := Srv.Store.User().Get(userId) + + if uresult := <-uc; uresult.Err != nil { + return model.NewLocAppError("PostUpdateChannelDisplayNameMessage", "api.channel.post_update_channel_displayname_message_and_forget.retrieve_user.error", nil, uresult.Err.Error()) + } else { + user := uresult.Data.(*model.User) + + message := fmt.Sprintf(utils.T("api.channel.post_update_channel_displayname_message_and_forget.updated_from"), user.Username, oldChannelDisplayName, newChannelDisplayName) + + post := &model.Post{ + ChannelId: channelId, + Message: message, + Type: model.POST_DISPLAYNAME_CHANGE, + UserId: userId, + Props: model.StringInterface{ + "old_displayname": oldChannelDisplayName, + "new_displayname": newChannelDisplayName, + }, + } + + if _, err := CreatePost(post, teamId, false); err != nil { + return model.NewLocAppError("PostUpdateChannelDisplayNameMessage", "api.channel.post_update_channel_displayname_message_and_forget.create_post.error", nil, err.Error()) + } + } + + return nil +} + +func GetChannel(channelId string) (*model.Channel, *model.AppError) { + if result := <-Srv.Store.Channel().Get(channelId, true); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Channel), nil + } +} + +func GetChannelByName(channelName, teamId string) (*model.Channel, *model.AppError) { + if result := <-Srv.Store.Channel().GetByName(teamId, channelName); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Channel), nil + } +} + +func GetChannelsForUser(teamId string, userId string) (*model.ChannelList, *model.AppError) { + if result := <-Srv.Store.Channel().GetChannels(teamId, userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelList), nil + } +} + +func GetChannelsUserNotIn(teamId string, userId string, offset int, limit int) (*model.ChannelList, *model.AppError) { + if result := <-Srv.Store.Channel().GetMoreChannels(teamId, userId, offset, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelList), nil + } +} + +func GetChannelMember(channelId string, userId string) (*model.ChannelMember, *model.AppError) { + if result := <-Srv.Store.Channel().GetMember(channelId, userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelMember), nil + } +} + +func GetChannelMembersByIds(channelId string, userIds []string) (*model.ChannelMembers, *model.AppError) { + if result := <-Srv.Store.Channel().GetMembersByIds(channelId, userIds); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelMembers), nil + } +} + +func GetChannelMembersForUser(teamId string, userId string) (*model.ChannelMembers, *model.AppError) { + if result := <-Srv.Store.Channel().GetMembersForUser(teamId, userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelMembers), nil + } +} + +func GetChannelMemberCount(channelId string) (int64, *model.AppError) { + if result := <-Srv.Store.Channel().GetMemberCount(channelId, true); result.Err != nil { + return 0, result.Err + } else { + return result.Data.(int64), nil + } +} + +func GetChannelCounts(teamId string, userId string) (*model.ChannelCounts, *model.AppError) { + if result := <-Srv.Store.Channel().GetChannelCounts(teamId, userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelCounts), nil + } +} + +func JoinChannel(channel *model.Channel, userId string) *model.AppError { + userChan := Srv.Store.User().Get(userId) + memberChan := Srv.Store.Channel().GetMember(channel.Id, userId) + + if uresult := <-userChan; uresult.Err != nil { + return uresult.Err + } else if mresult := <-memberChan; mresult.Err == nil && mresult.Data != nil { + // user is already in the channel + return nil + } else { + user := uresult.Data.(*model.User) + + if channel.Type == model.CHANNEL_OPEN { + if _, err := AddUserToChannel(user, channel); err != nil { + return err + } + PostUserAddRemoveMessage(userId, channel.Id, channel.TeamId, fmt.Sprintf(utils.T("api.channel.join_channel.post_and_forget"), user.Username), model.POST_JOIN_LEAVE) + } else { + return model.NewLocAppError("JoinChannel", "api.channel.join_channel.permissions.app_error", nil, "") + } + } + + return nil +} + +func LeaveChannel(channelId string, userId string) *model.AppError { + sc := Srv.Store.Channel().Get(channelId, true) + uc := Srv.Store.User().Get(userId) + ccm := Srv.Store.Channel().GetMemberCount(channelId, false) + + if cresult := <-sc; cresult.Err != nil { + return cresult.Err + } else if uresult := <-uc; uresult.Err != nil { + return cresult.Err + } else if ccmresult := <-ccm; ccmresult.Err != nil { + return ccmresult.Err + } else { + channel := cresult.Data.(*model.Channel) + user := uresult.Data.(*model.User) + membersCount := ccmresult.Data.(int64) + + if channel.Type == model.CHANNEL_DIRECT { + err := model.NewLocAppError("LeaveChannel", "api.channel.leave.direct.app_error", nil, "") + err.StatusCode = http.StatusBadRequest + return err + } + + if channel.Type == model.CHANNEL_PRIVATE && membersCount == 1 { + err := model.NewLocAppError("LeaveChannel", "api.channel.leave.last_member.app_error", nil, "userId="+user.Id) + err.StatusCode = http.StatusBadRequest + return err + } + + if err := RemoveUserFromChannel(userId, userId, channel); err != nil { + return err + } + + go PostUserAddRemoveMessage(userId, channel.Id, channel.TeamId, fmt.Sprintf(utils.T("api.channel.leave.left"), user.Username), model.POST_JOIN_LEAVE) + } + + return nil +} + +func RemoveUserFromChannel(userIdToRemove string, removerUserId string, channel *model.Channel) *model.AppError { + if channel.DeleteAt > 0 { + err := model.NewLocAppError("RemoveUserFromChannel", "api.channel.remove_user_from_channel.deleted.app_error", nil, "") + err.StatusCode = http.StatusBadRequest + return err + } + + if channel.Name == model.DEFAULT_CHANNEL { + return model.NewLocAppError("RemoveUserFromChannel", "api.channel.remove.default.app_error", map[string]interface{}{"Channel": model.DEFAULT_CHANNEL}, "") + } + + if cmresult := <-Srv.Store.Channel().RemoveMember(channel.Id, userIdToRemove); cmresult.Err != nil { + return cmresult.Err + } + + InvalidateCacheForUser(userIdToRemove) + InvalidateCacheForChannel(channel.Id) + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_REMOVED, "", channel.Id, "", nil) + message.Add("user_id", userIdToRemove) + message.Add("remover_id", removerUserId) + go Publish(message) + + // because the removed user no longer belongs to the channel we need to send a separate websocket event + userMsg := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_REMOVED, "", "", userIdToRemove, nil) + userMsg.Add("channel_id", channel.Id) + userMsg.Add("remover_id", removerUserId) + go Publish(userMsg) + + return nil +} + +func PostUserAddRemoveMessage(userId, channelId, teamId, message, postType string) *model.AppError { + post := &model.Post{ + ChannelId: channelId, + Message: message, + Type: postType, + UserId: userId, + } + if _, err := CreatePost(post, teamId, false); err != nil { + return model.NewLocAppError("PostUserAddRemoveMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, err.Error()) + } + + return nil +} + +func GetNumberOfChannelsOnTeam(teamId string) (int, *model.AppError) { + // Get total number of channels on current team + if result := <-Srv.Store.Channel().GetTeamChannels(teamId); result.Err != nil { + return 0, result.Err + } else { + return len(*result.Data.(*model.ChannelList)), nil + } +} + +func SetActiveChannel(userId string, channelId string) *model.AppError { + status, err := GetStatus(userId) + if err != nil { + status = &model.Status{userId, model.STATUS_ONLINE, false, model.GetMillis(), channelId} + } else { + status.ActiveChannel = channelId + if !status.Manual { + status.Status = model.STATUS_ONLINE + } + status.LastActivityAt = model.GetMillis() + } + + AddStatusCache(status) + + return nil +} + +func UpdateChannelLastViewedAt(channelIds []string, userId string) *model.AppError { + if result := <-Srv.Store.Channel().UpdateLastViewedAt(channelIds, userId); result.Err != nil { + return result.Err + } + + return nil +} + +func SearchChannels(teamId string, term string) (*model.ChannelList, *model.AppError) { + if result := <-Srv.Store.Channel().SearchInTeam(teamId, term); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelList), nil + } +} + +func SearchChannelsUserNotIn(teamId string, userId string, term string) (*model.ChannelList, *model.AppError) { + if result := <-Srv.Store.Channel().SearchMore(userId, teamId, term); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelList), nil + } +} + +func ViewChannel(view *model.ChannelView, teamId string, userId string, clearPushNotifications bool) *model.AppError { + channelIds := []string{view.ChannelId} + + var pchan store.StoreChannel + if len(view.PrevChannelId) > 0 { + channelIds = append(channelIds, view.PrevChannelId) + + if *utils.Cfg.EmailSettings.SendPushNotifications && clearPushNotifications { + pchan = Srv.Store.User().GetUnreadCountForChannel(userId, view.ChannelId) + } + } + + uchan := Srv.Store.Channel().UpdateLastViewedAt(channelIds, userId) + + if pchan != nil { + if result := <-pchan; result.Err != nil { + return result.Err + } else { + if result.Data.(int64) > 0 { + ClearPushNotification(userId, view.ChannelId) + } + } + } + + if result := <-uchan; result.Err != nil { + return result.Err + } + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_VIEWED, teamId, "", userId, nil) + message.Add("channel_id", view.ChannelId) + go Publish(message) + + return nil +} diff --git a/app/command.go b/app/command.go new file mode 100644 index 000000000..2d5861206 --- /dev/null +++ b/app/command.go @@ -0,0 +1,31 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/platform/model" +) + +func CreateCommandPost(post *model.Post, teamId string, response *model.CommandResponse) (*model.Post, *model.AppError) { + post.Message = parseSlackLinksToMarkdown(response.Text) + post.CreateAt = model.GetMillis() + + if response.Attachments != nil { + parseSlackAttachment(post, response.Attachments) + } + + switch response.ResponseType { + case model.COMMAND_RESPONSE_TYPE_IN_CHANNEL: + return CreatePost(post, teamId, true) + case model.COMMAND_RESPONSE_TYPE_EPHEMERAL: + if response.Text == "" { + return post, nil + } + + post.ParentId = "" + SendEphemeralPost(teamId, post.UserId, post) + } + + return post, nil +} diff --git a/app/compliance.go b/app/compliance.go new file mode 100644 index 000000000..ffef69b44 --- /dev/null +++ b/app/compliance.go @@ -0,0 +1,62 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "io/ioutil" + + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func GetComplianceReports() (model.Compliances, *model.AppError) { + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance { + return nil, model.NewLocAppError("GetComplianceReports", "ent.compliance.licence_disable.app_error", nil, "") + } + + if result := <-Srv.Store.Compliance().GetAll(); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(model.Compliances), nil + } +} + +func SaveComplianceReport(job *model.Compliance) (*model.Compliance, *model.AppError) { + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance || einterfaces.GetComplianceInterface() == nil { + return nil, model.NewLocAppError("saveComplianceReport", "ent.compliance.licence_disable.app_error", nil, "") + } + + job.Type = model.COMPLIANCE_TYPE_ADHOC + + if result := <-Srv.Store.Compliance().Save(job); result.Err != nil { + return nil, result.Err + } else { + job = result.Data.(*model.Compliance) + go einterfaces.GetComplianceInterface().RunComplianceJob(job) + } + + return job, nil +} + +func GetComplianceReport(reportId string) (*model.Compliance, *model.AppError) { + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance || einterfaces.GetComplianceInterface() == nil { + return nil, model.NewLocAppError("downloadComplianceReport", "ent.compliance.licence_disable.app_error", nil, "") + } + + if result := <-Srv.Store.Compliance().Get(reportId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Compliance), nil + } +} + +func GetComplianceFile(job *model.Compliance) ([]byte, *model.AppError) { + if f, err := ioutil.ReadFile(*utils.Cfg.ComplianceSettings.Directory + "compliance/" + job.JobName() + ".zip"); err != nil { + return nil, model.NewLocAppError("readFile", "api.file.read_file.reading_local.app_error", nil, err.Error()) + + } else { + return f, nil + } +} diff --git a/app/email.go b/app/email.go new file mode 100644 index 000000000..007a24505 --- /dev/null +++ b/app/email.go @@ -0,0 +1,236 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "fmt" + "html/template" + "net/url" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func SendChangeUsernameEmail(oldUsername, newUsername, email, locale, siteURL string) *model.AppError { + T := utils.GetUserTranslations(locale) + + subject := fmt.Sprintf("[%v] %v", utils.Cfg.TeamSettings.SiteName, T("api.templates.username_change_subject", + map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName})) + + bodyPage := utils.NewHTMLTemplate("email_change_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.username_change_body.title") + bodyPage.Html["Info"] = template.HTML(T("api.templates.username_change_body.info", + map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName, "NewUsername": newUsername})) + + if err := utils.SendMail(email, subject, bodyPage.Render()); err != nil { + return model.NewLocAppError("SendChangeUsernameEmail", "api.user.send_email_change_username_and_forget.error", nil, err.Error()) + } + + return nil +} + +func SendEmailChangeVerifyEmail(userId, newUserEmail, locale, siteURL string) *model.AppError { + T := utils.GetUserTranslations(locale) + + link := fmt.Sprintf("%s/do_verify_email?uid=%s&hid=%s&email=%s", siteURL, userId, model.HashPassword(userId+utils.Cfg.EmailSettings.InviteSalt), url.QueryEscape(newUserEmail)) + + subject := fmt.Sprintf("[%v] %v", utils.Cfg.TeamSettings.SiteName, T("api.templates.email_change_verify_subject", + map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName})) + + bodyPage := utils.NewHTMLTemplate("email_change_verify_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.email_change_verify_body.title") + bodyPage.Props["Info"] = T("api.templates.email_change_verify_body.info", + map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName}) + bodyPage.Props["VerifyUrl"] = link + bodyPage.Props["VerifyButton"] = T("api.templates.email_change_verify_body.button") + + if err := utils.SendMail(newUserEmail, subject, bodyPage.Render()); err != nil { + return model.NewLocAppError("SendEmailChangeVerifyEmail", "api.user.send_email_change_verify_email_and_forget.error", nil, err.Error()) + } + + return nil +} + +func SendEmailChangeEmail(oldEmail, newEmail, locale, siteURL string) *model.AppError { + T := utils.GetUserTranslations(locale) + + subject := fmt.Sprintf("[%v] %v", utils.Cfg.TeamSettings.SiteName, T("api.templates.email_change_subject", + map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName})) + + bodyPage := utils.NewHTMLTemplate("email_change_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.email_change_body.title") + bodyPage.Html["Info"] = template.HTML(T("api.templates.email_change_body.info", + map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName, "NewEmail": newEmail})) + + if err := utils.SendMail(oldEmail, subject, bodyPage.Render()); err != nil { + return model.NewLocAppError("SendEmailChangeEmail", "api.user.send_email_change_email_and_forget.error", nil, err.Error()) + } + + return nil +} + +func SendVerifyEmail(userId, userEmail, locale, siteURL string) *model.AppError { + T := utils.GetUserTranslations(locale) + + link := fmt.Sprintf("%s/do_verify_email?uid=%s&hid=%s&email=%s", siteURL, userId, model.HashPassword(userId+utils.Cfg.EmailSettings.InviteSalt), url.QueryEscape(userEmail)) + + url, _ := url.Parse(siteURL) + + subject := T("api.templates.verify_subject", + map[string]interface{}{"SiteName": utils.ClientCfg["SiteName"]}) + + bodyPage := utils.NewHTMLTemplate("verify_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.verify_body.title", map[string]interface{}{"ServerURL": url.Host}) + bodyPage.Props["Info"] = T("api.templates.verify_body.info") + bodyPage.Props["VerifyUrl"] = link + bodyPage.Props["Button"] = T("api.templates.verify_body.button") + + if err := utils.SendMail(userEmail, subject, bodyPage.Render()); err != nil { + return model.NewLocAppError("SendVerifyEmail", "api.user.send_verify_email_and_forget.failed.error", nil, err.Error()) + } + + return nil +} + +func SendSignInChangeEmail(email, method, locale, siteURL string) *model.AppError { + T := utils.GetUserTranslations(locale) + + subject := T("api.templates.singin_change_email.subject", + map[string]interface{}{"SiteName": utils.ClientCfg["SiteName"]}) + + bodyPage := utils.NewHTMLTemplate("signin_change_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.signin_change_email.body.title") + bodyPage.Html["Info"] = template.HTML(T("api.templates.singin_change_email.body.info", + map[string]interface{}{"SiteName": utils.ClientCfg["SiteName"], "Method": method})) + + if err := utils.SendMail(email, subject, bodyPage.Render()); err != nil { + return model.NewLocAppError("SendSignInChangeEmail", "api.user.send_sign_in_change_email_and_forget.error", nil, err.Error()) + } + + return nil +} + +func SendWelcomeEmail(userId string, email string, verified bool, locale, siteURL string) *model.AppError { + T := utils.GetUserTranslations(locale) + + rawUrl, _ := url.Parse(siteURL) + + subject := T("api.templates.welcome_subject", map[string]interface{}{"ServerURL": rawUrl.Host}) + + bodyPage := utils.NewHTMLTemplate("welcome_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.welcome_body.title", map[string]interface{}{"ServerURL": rawUrl.Host}) + bodyPage.Props["Info"] = T("api.templates.welcome_body.info") + bodyPage.Props["Button"] = T("api.templates.welcome_body.button") + bodyPage.Props["Info2"] = T("api.templates.welcome_body.info2") + bodyPage.Props["Info3"] = T("api.templates.welcome_body.info3") + bodyPage.Props["SiteURL"] = siteURL + + if *utils.Cfg.NativeAppSettings.AppDownloadLink != "" { + bodyPage.Props["AppDownloadInfo"] = T("api.templates.welcome_body.app_download_info") + bodyPage.Props["AppDownloadLink"] = *utils.Cfg.NativeAppSettings.AppDownloadLink + } + + if !verified { + link := fmt.Sprintf("%s/do_verify_email?uid=%s&hid=%s&email=%s", siteURL, userId, model.HashPassword(userId+utils.Cfg.EmailSettings.InviteSalt), url.QueryEscape(email)) + bodyPage.Props["VerifyUrl"] = link + } + + if err := utils.SendMail(email, subject, bodyPage.Render()); err != nil { + return model.NewLocAppError("SendWelcomeEmail", "api.user.send_welcome_email_and_forget.failed.error", nil, err.Error()) + } + + return nil +} + +func SendPasswordChangeEmail(email, method, locale, siteURL string) *model.AppError { + T := utils.GetUserTranslations(locale) + + subject := T("api.templates.password_change_subject", + map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName, "SiteName": utils.Cfg.TeamSettings.SiteName}) + + bodyPage := utils.NewHTMLTemplate("password_change_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.password_change_body.title") + bodyPage.Html["Info"] = template.HTML(T("api.templates.password_change_body.info", + map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName, "TeamURL": siteURL, "Method": method})) + + if err := utils.SendMail(email, subject, bodyPage.Render()); err != nil { + return model.NewLocAppError("SendPasswordChangeEmail", "api.user.send_password_change_email_and_forget.error", nil, err.Error()) + } + + return nil +} + +func SendMfaChangeEmail(email string, activated bool, locale, siteURL string) *model.AppError { + T := utils.GetUserTranslations(locale) + + subject := T("api.templates.mfa_change_subject", + map[string]interface{}{"SiteName": utils.Cfg.TeamSettings.SiteName}) + + bodyPage := utils.NewHTMLTemplate("mfa_change_body", locale) + bodyPage.Props["SiteURL"] = siteURL + + bodyText := "" + if activated { + bodyText = "api.templates.mfa_activated_body.info" + bodyPage.Props["Title"] = T("api.templates.mfa_activated_body.title") + } else { + bodyText = "api.templates.mfa_deactivated_body.info" + bodyPage.Props["Title"] = T("api.templates.mfa_deactivated_body.title") + } + + bodyPage.Html["Info"] = template.HTML(T(bodyText, + map[string]interface{}{"SiteURL": siteURL})) + + if err := utils.SendMail(email, subject, bodyPage.Render()); err != nil { + return model.NewLocAppError("SendMfaChangeEmail", "api.user.send_mfa_change_email.error", nil, err.Error()) + } + + return nil +} + +func SendInviteEmails(team *model.Team, senderName string, invites []string, siteURL string) { + for _, invite := range invites { + if len(invite) > 0 { + senderRole := utils.T("api.team.invite_members.member") + + subject := utils.T("api.templates.invite_subject", + map[string]interface{}{"SenderName": senderName, "TeamDisplayName": team.DisplayName, "SiteName": utils.ClientCfg["SiteName"]}) + + bodyPage := utils.NewHTMLTemplate("invite_body", model.DEFAULT_LOCALE) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = utils.T("api.templates.invite_body.title") + bodyPage.Html["Info"] = template.HTML(utils.T("api.templates.invite_body.info", + map[string]interface{}{"SenderStatus": senderRole, "SenderName": senderName, "TeamDisplayName": team.DisplayName})) + bodyPage.Props["Button"] = utils.T("api.templates.invite_body.button") + bodyPage.Html["ExtraInfo"] = template.HTML(utils.T("api.templates.invite_body.extra_info", + map[string]interface{}{"TeamDisplayName": team.DisplayName, "TeamURL": siteURL + "/" + team.Name})) + + props := make(map[string]string) + props["email"] = invite + props["id"] = team.Id + props["display_name"] = team.DisplayName + props["name"] = team.Name + props["time"] = fmt.Sprintf("%v", model.GetMillis()) + data := model.MapToJson(props) + hash := model.HashPassword(fmt.Sprintf("%v:%v", data, utils.Cfg.EmailSettings.InviteSalt)) + bodyPage.Props["Link"] = fmt.Sprintf("%s/signup_user_complete/?d=%s&h=%s", siteURL, url.QueryEscape(data), url.QueryEscape(hash)) + + if !utils.Cfg.EmailSettings.SendEmailNotifications { + l4g.Info(utils.T("api.team.invite_members.sending.info"), invite, bodyPage.Props["Link"]) + } + + if err := utils.SendMail(invite, subject, bodyPage.Render()); err != nil { + l4g.Error(utils.T("api.team.invite_members.send.error"), err) + } + } + } +} diff --git a/app/email_batching.go b/app/email_batching.go new file mode 100644 index 000000000..fc2fb1cea --- /dev/null +++ b/app/email_batching.go @@ -0,0 +1,252 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "database/sql" + "fmt" + "html/template" + "strconv" + "time" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + + l4g "github.com/alecthomas/log4go" + "github.com/nicksnyder/go-i18n/i18n" +) + +const ( + EMAIL_BATCHING_TASK_NAME = "Email Batching" +) + +var emailBatchingJob *EmailBatchingJob + +func InitEmailBatching() { + if *utils.Cfg.EmailSettings.EnableEmailBatching { + if emailBatchingJob == nil { + emailBatchingJob = MakeEmailBatchingJob(*utils.Cfg.EmailSettings.EmailBatchingBufferSize) + } + + // note that we don't support changing EmailBatchingBufferSize without restarting the server + + emailBatchingJob.Start() + } +} + +func AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError { + if !*utils.Cfg.EmailSettings.EnableEmailBatching { + return model.NewLocAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.disabled.app_error", nil, "") + } + + if !emailBatchingJob.Add(user, post, team) { + l4g.Error(utils.T("api.email_batching.add_notification_email_to_batch.channel_full.app_error")) + return model.NewLocAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.channel_full.app_error", nil, "") + } + + return nil +} + +type batchedNotification struct { + userId string + post *model.Post + teamName string +} + +type EmailBatchingJob struct { + newNotifications chan *batchedNotification + pendingNotifications map[string][]*batchedNotification +} + +func MakeEmailBatchingJob(bufferSize int) *EmailBatchingJob { + return &EmailBatchingJob{ + newNotifications: make(chan *batchedNotification, bufferSize), + pendingNotifications: make(map[string][]*batchedNotification), + } +} + +func (job *EmailBatchingJob) Start() { + if task := model.GetTaskByName(EMAIL_BATCHING_TASK_NAME); task != nil { + task.Cancel() + } + + l4g.Debug(utils.T("api.email_batching.start.starting"), *utils.Cfg.EmailSettings.EmailBatchingInterval) + model.CreateRecurringTask(EMAIL_BATCHING_TASK_NAME, job.CheckPendingEmails, time.Duration(*utils.Cfg.EmailSettings.EmailBatchingInterval)*time.Second) +} + +func (job *EmailBatchingJob) Add(user *model.User, post *model.Post, team *model.Team) bool { + notification := &batchedNotification{ + userId: user.Id, + post: post, + teamName: team.Name, + } + + select { + case job.newNotifications <- notification: + return true + default: + // return false if we couldn't queue the email notification so that we can send an immediate email + return false + } +} + +func (job *EmailBatchingJob) CheckPendingEmails() { + job.handleNewNotifications() + + // it's a bit weird to pass the send email function through here, but it makes it so that we can test + // without actually sending emails + job.checkPendingNotifications(time.Now(), sendBatchedEmailNotification) + + l4g.Debug(utils.T("api.email_batching.check_pending_emails.finished_running"), len(job.pendingNotifications)) +} + +func (job *EmailBatchingJob) handleNewNotifications() { + receiving := true + + // read in new notifications to send + for receiving { + select { + case notification := <-job.newNotifications: + userId := notification.userId + + if _, ok := job.pendingNotifications[userId]; !ok { + job.pendingNotifications[userId] = []*batchedNotification{notification} + } else { + job.pendingNotifications[userId] = append(job.pendingNotifications[userId], notification) + } + default: + receiving = false + } + } +} + +func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler func(string, []*batchedNotification)) { + // look for users who've acted since pending posts were received + for userId, notifications := range job.pendingNotifications { + schan := Srv.Store.Status().Get(userId) + pchan := Srv.Store.Preference().Get(userId, model.PREFERENCE_CATEGORY_NOTIFICATIONS, model.PREFERENCE_NAME_EMAIL_INTERVAL) + batchStartTime := notifications[0].post.CreateAt + + // check if the user has been active and would've seen any new posts + if result := <-schan; result.Err != nil { + l4g.Error(utils.T("api.email_batching.check_pending_emails.status.app_error"), result.Err) + delete(job.pendingNotifications, userId) + continue + } else if status := result.Data.(*model.Status); status.LastActivityAt >= batchStartTime { + delete(job.pendingNotifications, userId) + continue + } + + // get how long we need to wait to send notifications to the user + var interval int64 + if result := <-pchan; result.Err != nil { + // default to 30 seconds to match the send "immediate" setting + interval, _ = strconv.ParseInt(model.PREFERENCE_DEFAULT_EMAIL_INTERVAL, 10, 64) + } else { + preference := result.Data.(model.Preference) + + if value, err := strconv.ParseInt(preference.Value, 10, 64); err != nil { + interval, _ = strconv.ParseInt(model.PREFERENCE_DEFAULT_EMAIL_INTERVAL, 10, 64) + } else { + interval = value + } + } + + // send the email notification if it's been long enough + if now.Sub(time.Unix(batchStartTime/1000, 0)) > time.Duration(interval)*time.Second { + go handler(userId, notifications) + delete(job.pendingNotifications, userId) + } + } +} + +func sendBatchedEmailNotification(userId string, notifications []*batchedNotification) { + uchan := Srv.Store.User().Get(userId) + pchan := Srv.Store.Preference().Get(userId, model.PREFERENCE_CATEGORY_DISPLAY_SETTINGS, model.PREFERENCE_NAME_DISPLAY_NAME_FORMAT) + + var user *model.User + if result := <-uchan; result.Err != nil { + l4g.Warn("api.email_batching.send_batched_email_notification.user.app_error") + return + } else { + user = result.Data.(*model.User) + } + + translateFunc := utils.GetUserTranslations(user.Locale) + + var displayNameFormat string + if result := <-pchan; result.Err != nil && result.Err.DetailedError != sql.ErrNoRows.Error() { + l4g.Warn("api.email_batching.send_batched_email_notification.preferences.app_error") + return + } else if result.Err != nil { + // no display name format saved, so fall back to default + displayNameFormat = model.PREFERENCE_DEFAULT_DISPLAY_NAME_FORMAT + } else { + displayNameFormat = result.Data.(model.Preference).Value + } + + var contents string + for _, notification := range notifications { + template := utils.NewHTMLTemplate("post_batched_post", user.Locale) + + contents += renderBatchedPost(template, notification.post, notification.teamName, displayNameFormat, translateFunc) + } + + tm := time.Unix(notifications[0].post.CreateAt/1000, 0) + + subject := translateFunc("api.email_batching.send_batched_email_notification.subject", len(notifications), map[string]interface{}{ + "SiteName": utils.Cfg.TeamSettings.SiteName, + "Year": tm.Year(), + "Month": translateFunc(tm.Month().String()), + "Day": tm.Day(), + }) + + body := utils.NewHTMLTemplate("post_batched_body", user.Locale) + body.Props["SiteURL"] = *utils.Cfg.ServiceSettings.SiteURL + body.Props["Posts"] = template.HTML(contents) + body.Props["BodyText"] = translateFunc("api.email_batching.send_batched_email_notification.body_text", len(notifications)) + + if err := utils.SendMail(user.Email, subject, body.Render()); err != nil { + l4g.Warn(utils.T("api.email_batchings.send_batched_email_notification.send.app_error"), user.Email, err) + } +} + +func renderBatchedPost(template *utils.HTMLTemplate, post *model.Post, teamName string, displayNameFormat string, translateFunc i18n.TranslateFunc) string { + schan := Srv.Store.User().Get(post.UserId) + cchan := Srv.Store.Channel().Get(post.ChannelId, true) + + template.Props["Button"] = translateFunc("api.email_batching.render_batched_post.go_to_post") + template.Props["PostMessage"] = GetMessageForNotification(post, translateFunc) + template.Props["PostLink"] = *utils.Cfg.ServiceSettings.SiteURL + "/" + teamName + "/pl/" + post.Id + + tm := time.Unix(post.CreateAt/1000, 0) + timezone, _ := tm.Zone() + + template.Props["Date"] = translateFunc("api.email_batching.render_batched_post.date", map[string]interface{}{ + "Year": tm.Year(), + "Month": translateFunc(tm.Month().String()), + "Day": tm.Day(), + "Hour": tm.Hour(), + "Minute": fmt.Sprintf("%02d", tm.Minute()), + "Timezone": timezone, + }) + + if result := <-schan; result.Err != nil { + l4g.Warn(utils.T("api.email_batching.render_batched_post.sender.app_error")) + return "" + } else { + template.Props["SenderName"] = result.Data.(*model.User).GetDisplayNameForPreference(displayNameFormat) + } + + if result := <-cchan; result.Err != nil { + l4g.Warn(utils.T("api.email_batching.render_batched_post.channel.app_error")) + return "" + } else if channel := result.Data.(*model.Channel); channel.Type == model.CHANNEL_DIRECT { + template.Props["ChannelName"] = translateFunc("api.email_batching.render_batched_post.direct_message") + } else { + template.Props["ChannelName"] = channel.DisplayName + } + + return template.Render() +} diff --git a/app/email_batching_test.go b/app/email_batching_test.go new file mode 100644 index 000000000..23722facd --- /dev/null +++ b/app/email_batching_test.go @@ -0,0 +1,193 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "testing" + "time" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" +) + +func TestHandleNewNotifications(t *testing.T) { + Setup() + + id1 := model.NewId() + id2 := model.NewId() + id3 := model.NewId() + + // test queueing of received posts by user + job := MakeEmailBatchingJob(128) + + job.handleNewNotifications() + + if len(job.pendingNotifications) != 0 { + t.Fatal("shouldn't have added any pending notifications") + } + + job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"}) + if len(job.pendingNotifications) != 0 { + t.Fatal("shouldn't have added any pending notifications") + } + + job.handleNewNotifications() + if len(job.pendingNotifications) != 1 { + t.Fatal("should have received posts for 1 user") + } else if len(job.pendingNotifications[id1]) != 1 { + t.Fatal("should have received 1 post for user") + } + + job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"}) + job.handleNewNotifications() + if len(job.pendingNotifications) != 1 { + t.Fatal("should have received posts for 1 user") + } else if len(job.pendingNotifications[id1]) != 2 { + t.Fatal("should have received 2 posts for user1", job.pendingNotifications[id1]) + } + + job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"}) + job.handleNewNotifications() + if len(job.pendingNotifications) != 2 { + t.Fatal("should have received posts for 2 users") + } else if len(job.pendingNotifications[id1]) != 2 { + t.Fatal("should have received 2 posts for user1") + } else if len(job.pendingNotifications[id2]) != 1 { + t.Fatal("should have received 1 post for user2") + } + + job.Add(&model.User{Id: id2}, &model.Post{UserId: id2, Message: "test"}, &model.Team{Name: "team"}) + job.Add(&model.User{Id: id1}, &model.Post{UserId: id3, Message: "test"}, &model.Team{Name: "team"}) + job.Add(&model.User{Id: id3}, &model.Post{UserId: id3, Message: "test"}, &model.Team{Name: "team"}) + job.Add(&model.User{Id: id2}, &model.Post{UserId: id2, Message: "test"}, &model.Team{Name: "team"}) + job.handleNewNotifications() + if len(job.pendingNotifications) != 3 { + t.Fatal("should have received posts for 3 users") + } else if len(job.pendingNotifications[id1]) != 3 { + t.Fatal("should have received 3 posts for user1") + } else if len(job.pendingNotifications[id2]) != 3 { + t.Fatal("should have received 3 posts for user2") + } else if len(job.pendingNotifications[id3]) != 1 { + t.Fatal("should have received 1 post for user3") + } + + // test ordering of received posts + job = MakeEmailBatchingJob(128) + + job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test1"}, &model.Team{Name: "team"}) + job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test2"}, &model.Team{Name: "team"}) + job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test3"}, &model.Team{Name: "team"}) + job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test4"}, &model.Team{Name: "team"}) + job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test5"}, &model.Team{Name: "team"}) + job.handleNewNotifications() + if job.pendingNotifications[id1][0].post.Message != "test1" || + job.pendingNotifications[id1][1].post.Message != "test2" || + job.pendingNotifications[id1][2].post.Message != "test4" { + t.Fatal("incorrect order of received posts for user1") + } else if job.pendingNotifications[id2][0].post.Message != "test3" || + job.pendingNotifications[id2][1].post.Message != "test5" { + t.Fatal("incorrect order of received posts for user2") + } +} + +func TestCheckPendingNotifications(t *testing.T) { + Setup() + + id1 := model.NewId() + + job := MakeEmailBatchingJob(128) + job.pendingNotifications[id1] = []*batchedNotification{ + { + post: &model.Post{ + UserId: id1, + CreateAt: 10000000, + }, + }, + } + + store.Must(Srv.Store.Status().SaveOrUpdate(&model.Status{ + UserId: id1, + LastActivityAt: 9999000, + })) + store.Must(Srv.Store.Preference().Save(&model.Preferences{{ + UserId: id1, + Category: model.PREFERENCE_CATEGORY_NOTIFICATIONS, + Name: model.PREFERENCE_NAME_EMAIL_INTERVAL, + Value: "60", + }})) + + // test that notifications aren't sent before interval + job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {}) + + if job.pendingNotifications[id1] == nil || len(job.pendingNotifications[id1]) != 1 { + t.Fatal("should'nt have sent queued post") + } + + // test that notifications are cleared if the user has acted + store.Must(Srv.Store.Status().SaveOrUpdate(&model.Status{ + UserId: id1, + LastActivityAt: 10001000, + })) + + job.checkPendingNotifications(time.Unix(10002, 0), func(string, []*batchedNotification) {}) + + if job.pendingNotifications[id1] != nil && len(job.pendingNotifications[id1]) != 0 { + t.Fatal("should've remove queued post since user acted") + } + + // test that notifications are sent if enough time passes since the first message + job.pendingNotifications[id1] = []*batchedNotification{ + { + post: &model.Post{ + UserId: id1, + CreateAt: 10060000, + Message: "post1", + }, + }, + { + post: &model.Post{ + UserId: id1, + CreateAt: 10090000, + Message: "post2", + }, + }, + } + + received := make(chan *model.Post, 2) + timeout := make(chan bool) + + job.checkPendingNotifications(time.Unix(10130, 0), func(s string, notifications []*batchedNotification) { + for _, notification := range notifications { + received <- notification.post + } + }) + + go func() { + // start a timeout to make sure that we don't get stuck here on a failed test + time.Sleep(5 * time.Second) + timeout <- true + }() + + if job.pendingNotifications[id1] != nil && len(job.pendingNotifications[id1]) != 0 { + t.Fatal("should've remove queued posts when sending messages") + } + + select { + case post := <-received: + if post.Message != "post1" { + t.Fatal("should've received post1 first") + } + case _ = <-timeout: + t.Fatal("timed out waiting for first post notification") + } + + select { + case post := <-received: + if post.Message != "post2" { + t.Fatal("should've received post2 second") + } + case _ = <-timeout: + t.Fatal("timed out waiting for second post notification") + } +} diff --git a/app/email_test.go b/app/email_test.go new file mode 100644 index 000000000..ecaa389bf --- /dev/null +++ b/app/email_test.go @@ -0,0 +1,419 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "strings" + "testing" + + "github.com/mattermost/platform/utils" +) + +func TestSendChangeUsernameEmail(t *testing.T) { + Setup() + + var emailTo string = "test@example.com" + var oldUsername string = "myoldusername" + var newUsername string = "fancyusername" + var locale string = "en" + var siteURL string = "" + var expectedPartialMessage string = "Your username for Mattermost has been changed to " + newUsername + "." + var expectedSubject string = "[" + utils.Cfg.TeamSettings.SiteName + "] Your username has changed for Mattermost" + + //Delete all the messages before check the sample email + utils.DeleteMailBox(emailTo) + + if err := SendChangeUsernameEmail(oldUsername, newUsername, emailTo, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(emailTo); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], emailTo) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } + } +} + +func TestSendEmailChangeVerifyEmail(t *testing.T) { + Setup() + utils.LoadConfig("config.json") + + var userId string = "5349853498543jdfvndf9834" + var newUserEmail string = "newtest@example.com" + var locale string = "en" + var siteURL string = "" + var expectedPartialMessage string = "You updated your email" + var expectedSubject string = "[" + utils.Cfg.TeamSettings.SiteName + "] Verify new email address for Mattermost" + + //Delete all the messages before check the sample email + utils.DeleteMailBox(newUserEmail) + + if err := SendEmailChangeVerifyEmail(userId, newUserEmail, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(newUserEmail); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], newUserEmail) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(newUserEmail, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + if !strings.Contains(resultsEmail.Body.Text, utils.UrlEncode(newUserEmail)) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong new email in the message") + } + } + } + } +} + +func TestSendEmailChangeEmail(t *testing.T) { + Setup() + utils.LoadConfig("config.json") + + var oldEmail string = "test@example.com" + var newUserEmail string = "newtest@example.com" + var locale string = "en" + var siteURL string = "" + var expectedPartialMessage string = "Your email address for Mattermost has been changed to " + newUserEmail + var expectedSubject string = "[" + utils.Cfg.TeamSettings.SiteName + "] Your email address has changed for Mattermost" + + //Delete all the messages before check the sample email + utils.DeleteMailBox(oldEmail) + + if err := SendEmailChangeEmail(oldEmail, newUserEmail, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(oldEmail); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], oldEmail) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(oldEmail, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } + } +} + +func TestSendVerifyEmail(t *testing.T) { + Setup() + utils.LoadConfig("config.json") + + var userId string = "5349853498543jdfvndf9834" + var userEmail string = "test@example.com" + var locale string = "en" + var siteURL string = "" + var expectedPartialMessage string = "Please verify your email address by clicking below" + var expectedSubject string = "[" + utils.Cfg.TeamSettings.SiteName + "] Email Verification" + + //Delete all the messages before check the sample email + utils.DeleteMailBox(userEmail) + + if err := SendVerifyEmail(userId, userEmail, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(userEmail); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], userEmail) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(userEmail, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + if !strings.Contains(resultsEmail.Body.Text, utils.UrlEncode(userEmail)) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong new email in the message") + } + } + } + } +} + +func TestSendSignInChangeEmail(t *testing.T) { + Setup() + utils.LoadConfig("config.json") + + var email string = "test@example.com" + var locale string = "en" + var siteURL string = "" + var method string = "AD/LDAP" + var expectedPartialMessage string = "You updated your sign-in method on Mattermost to " + method + "." + var expectedSubject string = "You updated your sign-in method on Mattermost" + + //Delete all the messages before check the sample email + utils.DeleteMailBox(email) + + if err := SendSignInChangeEmail(email, method, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(email); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], email) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(email, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } + } +} + +func TestSendWelcomeEmail(t *testing.T) { + Setup() + utils.LoadConfig("config.json") + + var userId string = "32432nkjnijn432uj32" + var email string = "test@example.com" + var locale string = "en" + var siteURL string = "http://test.mattermost.io" + var verified bool = true + var expectedPartialMessage string = "Mattermost lets you share messages and files from your PC or phone, with instant search and archiving" + var expectedSubject string = "You joined test.mattermost.io" + + //Delete all the messages before check the sample email + utils.DeleteMailBox(email) + + if err := SendWelcomeEmail(userId, email, verified, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(email); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], email) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(email, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } + } + + utils.DeleteMailBox(email) + verified = false + var expectedVerifyEmail string = "Please verify your email address by clicking below." + + if err := SendWelcomeEmail(userId, email, verified, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(email); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], email) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(email, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + if !strings.Contains(resultsEmail.Body.Text, expectedVerifyEmail) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + if !strings.Contains(resultsEmail.Body.Text, utils.UrlEncode(email)) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong email in the message") + } + } + } + } +} + +func TestSendPasswordChangeEmail(t *testing.T) { + Setup() + utils.LoadConfig("config.json") + + var email string = "test@example.com" + var locale string = "en" + var siteURL string = "http://test.mattermost.io" + var method string = "using a reset password link" + var expectedPartialMessage string = "Your password has been updated for " + utils.Cfg.TeamSettings.SiteName + " on " + siteURL + " by " + method + var expectedSubject string = "Your password has been updated for " + utils.Cfg.TeamSettings.SiteName + " on " + utils.Cfg.TeamSettings.SiteName + + //Delete all the messages before check the sample email + utils.DeleteMailBox(email) + + if err := SendPasswordChangeEmail(email, method, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(email); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], email) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(email, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } + } +} + +func TestSendMfaChangeEmail(t *testing.T) { + Setup() + utils.LoadConfig("config.json") + + var email string = "test@example.com" + var locale string = "en" + var siteURL string = "http://test.mattermost.io" + var activated bool = true + var expectedPartialMessage string = "Multi-factor authentication has been added to your account on " + siteURL + "." + var expectedSubject string = "Your MFA has been updated on " + utils.Cfg.TeamSettings.SiteName + + //Delete all the messages before check the sample email + utils.DeleteMailBox(email) + + if err := SendMfaChangeEmail(email, activated, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(email); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], email) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(email, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } + } + + activated = false + expectedPartialMessage = "Multi-factor authentication has been removed from your account on " + siteURL + "." + utils.DeleteMailBox(email) + + if err := SendMfaChangeEmail(email, activated, locale, siteURL); err != nil { + t.Log(err) + t.Fatal("Should send change username email") + } else { + //Check if the email was send to the rigth email address + if resultsMailbox, err := utils.GetMailBox(email); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], email) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(email, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } + } +} + +func TestSendInviteEmails(t *testing.T) { + th := Setup().InitBasic() + utils.LoadConfig("config.json") + + var email1 string = "test1@example.com" + var email2 string = "test2@example.com" + var senderName string = "TheBoss" + var siteURL string = "http://test.mattermost.io" + invites := []string{email1, email2} + var expectedPartialMessage string = "The team member *" + senderName + "* , has invited you to join *" + th.BasicTeam.DisplayName + "*" + var expectedSubject string = senderName + " invited you to join " + th.BasicTeam.DisplayName + " Team on " + utils.Cfg.TeamSettings.SiteName + + //Delete all the messages before check the sample email + utils.DeleteMailBox(email1) + utils.DeleteMailBox(email2) + + SendInviteEmails(th.BasicTeam, senderName, invites, siteURL) + + //Check if the email was send to the rigth email address to email1 + if resultsMailbox, err := utils.GetMailBox(email1); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], email1) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(email1, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Log(expectedSubject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } + + //Check if the email was send to the rigth email address to email2 + if resultsMailbox, err := utils.GetMailBox(email2); err != nil && !strings.ContainsAny(resultsMailbox[0].To[0], email2) { + t.Fatal("Wrong To recipient") + } else { + if resultsEmail, err := utils.GetMessageFromMailbox(email2, resultsMailbox[0].ID); err == nil { + if !strings.Contains(resultsEmail.Subject, expectedSubject) { + t.Log(resultsEmail.Subject) + t.Log(expectedSubject) + t.Fatal("Wrong Subject") + } + if !strings.Contains(resultsEmail.Body.Text, expectedPartialMessage) { + t.Log(resultsEmail.Body.Text) + t.Fatal("Wrong Body message") + } + } + } +} diff --git a/app/file.go b/app/file.go new file mode 100644 index 000000000..a4419bde8 --- /dev/null +++ b/app/file.go @@ -0,0 +1,529 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "bytes" + _ "image/gif" + "crypto/sha256" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "sync" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + "github.com/disintegration/imaging" + s3 "github.com/minio/minio-go" + "github.com/rwcarlsen/goexif/exif" + _ "golang.org/x/image/bmp" +) + +const ( + /* + EXIF Image Orientations + 1 2 3 4 5 6 7 8 + + 888888 888888 88 88 8888888888 88 88 8888888888 + 88 88 88 88 88 88 88 88 88 88 88 88 + 8888 8888 8888 8888 88 8888888888 8888888888 88 + 88 88 88 88 + 88 88 888888 888888 + */ + Upright = 1 + UprightMirrored = 2 + UpsideDown = 3 + UpsideDownMirrored = 4 + RotatedCWMirrored = 5 + RotatedCCW = 6 + RotatedCCWMirrored = 7 + RotatedCW = 8 + + MaxImageSize = 6048 * 4032 // 24 megapixels, roughly 36MB as a raw image +) + +func ReadFile(path string) ([]byte, *model.AppError) { + if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 { + endpoint := utils.Cfg.FileSettings.AmazonS3Endpoint + accessKey := utils.Cfg.FileSettings.AmazonS3AccessKeyId + secretKey := utils.Cfg.FileSettings.AmazonS3SecretAccessKey + secure := *utils.Cfg.FileSettings.AmazonS3SSL + s3Clnt, err := s3.New(endpoint, accessKey, secretKey, secure) + if err != nil { + return nil, model.NewLocAppError("ReadFile", "api.file.read_file.s3.app_error", nil, err.Error()) + } + bucket := utils.Cfg.FileSettings.AmazonS3Bucket + minioObject, err := s3Clnt.GetObject(bucket, path) + defer minioObject.Close() + if err != nil { + return nil, model.NewLocAppError("ReadFile", "api.file.read_file.s3.app_error", nil, err.Error()) + } + if f, err := ioutil.ReadAll(minioObject); err != nil { + return nil, model.NewLocAppError("ReadFile", "api.file.read_file.s3.app_error", nil, err.Error()) + } else { + return f, nil + } + } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL { + if f, err := ioutil.ReadFile(utils.Cfg.FileSettings.Directory + path); err != nil { + return nil, model.NewLocAppError("ReadFile", "api.file.read_file.reading_local.app_error", nil, err.Error()) + } else { + return f, nil + } + } else { + return nil, model.NewLocAppError("ReadFile", "api.file.read_file.configured.app_error", nil, "") + } +} + +func MoveFile(oldPath, newPath string) *model.AppError { + if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 { + endpoint := utils.Cfg.FileSettings.AmazonS3Endpoint + accessKey := utils.Cfg.FileSettings.AmazonS3AccessKeyId + secretKey := utils.Cfg.FileSettings.AmazonS3SecretAccessKey + secure := *utils.Cfg.FileSettings.AmazonS3SSL + s3Clnt, err := s3.New(endpoint, accessKey, secretKey, secure) + if err != nil { + return model.NewLocAppError("moveFile", "api.file.write_file.s3.app_error", nil, err.Error()) + } + bucket := utils.Cfg.FileSettings.AmazonS3Bucket + + var copyConds = s3.NewCopyConditions() + if err = s3Clnt.CopyObject(bucket, newPath, "/"+path.Join(bucket, oldPath), copyConds); err != nil { + return model.NewLocAppError("moveFile", "api.file.move_file.delete_from_s3.app_error", nil, err.Error()) + } + if err = s3Clnt.RemoveObject(bucket, oldPath); err != nil { + return model.NewLocAppError("moveFile", "api.file.move_file.delete_from_s3.app_error", nil, err.Error()) + } + } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL { + if err := os.MkdirAll(filepath.Dir(utils.Cfg.FileSettings.Directory+newPath), 0774); err != nil { + return model.NewLocAppError("moveFile", "api.file.move_file.rename.app_error", nil, err.Error()) + } + + if err := os.Rename(utils.Cfg.FileSettings.Directory+oldPath, utils.Cfg.FileSettings.Directory+newPath); err != nil { + return model.NewLocAppError("moveFile", "api.file.move_file.rename.app_error", nil, err.Error()) + } + } else { + return model.NewLocAppError("moveFile", "api.file.move_file.configured.app_error", nil, "") + } + + return nil +} + +func WriteFile(f []byte, path string) *model.AppError { + if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 { + endpoint := utils.Cfg.FileSettings.AmazonS3Endpoint + accessKey := utils.Cfg.FileSettings.AmazonS3AccessKeyId + secretKey := utils.Cfg.FileSettings.AmazonS3SecretAccessKey + secure := *utils.Cfg.FileSettings.AmazonS3SSL + s3Clnt, err := s3.New(endpoint, accessKey, secretKey, secure) + if err != nil { + return model.NewLocAppError("WriteFile", "api.file.write_file.s3.app_error", nil, err.Error()) + } + bucket := utils.Cfg.FileSettings.AmazonS3Bucket + ext := filepath.Ext(path) + + if model.IsFileExtImage(ext) { + _, err = s3Clnt.PutObject(bucket, path, bytes.NewReader(f), model.GetImageMimeType(ext)) + } else { + _, err = s3Clnt.PutObject(bucket, path, bytes.NewReader(f), "binary/octet-stream") + } + if err != nil { + return model.NewLocAppError("WriteFile", "api.file.write_file.s3.app_error", nil, err.Error()) + } + } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL { + if err := writeFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil { + return err + } + } else { + return model.NewLocAppError("WriteFile", "api.file.write_file.configured.app_error", nil, "") + } + + return nil +} + +func writeFileLocally(f []byte, path string) *model.AppError { + if err := os.MkdirAll(filepath.Dir(path), 0774); err != nil { + directory, _ := filepath.Abs(filepath.Dir(path)) + return model.NewLocAppError("WriteFile", "api.file.write_file_locally.create_dir.app_error", nil, "directory="+directory+", err="+err.Error()) + } + + if err := ioutil.WriteFile(path, f, 0644); err != nil { + return model.NewLocAppError("WriteFile", "api.file.write_file_locally.writing.app_error", nil, err.Error()) + } + + return nil +} + +func openFileWriteStream(path string) (io.Writer, *model.AppError) { + if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 { + return nil, model.NewLocAppError("openFileWriteStream", "api.file.open_file_write_stream.s3.app_error", nil, "") + } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL { + if err := os.MkdirAll(filepath.Dir(utils.Cfg.FileSettings.Directory+path), 0774); err != nil { + return nil, model.NewLocAppError("openFileWriteStream", "api.file.open_file_write_stream.creating_dir.app_error", nil, err.Error()) + } + + if fileHandle, err := os.Create(utils.Cfg.FileSettings.Directory + path); err != nil { + return nil, model.NewLocAppError("openFileWriteStream", "api.file.open_file_write_stream.local_server.app_error", nil, err.Error()) + } else { + fileHandle.Chmod(0644) + return fileHandle, nil + } + } + + return nil, model.NewLocAppError("openFileWriteStream", "api.file.open_file_write_stream.configured.app_error", nil, "") +} + +func closeFileWriteStream(file io.Writer) { + file.(*os.File).Close() +} + +func GetInfoForFilename(post *model.Post, teamId string, filename string) *model.FileInfo { + // Find the path from the Filename of the form /{channelId}/{userId}/{uid}/{nameWithExtension} + split := strings.SplitN(filename, "/", 5) + if len(split) < 5 { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.unexpected_filename.error"), post.Id, filename) + return nil + } + + channelId := split[1] + userId := split[2] + oldId := split[3] + name, _ := url.QueryUnescape(split[4]) + + if split[0] != "" || split[1] != post.ChannelId || split[2] != post.UserId || strings.Contains(split[4], "/") { + l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.mismatched_filename.warn"), post.Id, post.ChannelId, post.UserId, filename) + } + + pathPrefix := fmt.Sprintf("teams/%s/channels/%s/users/%s/%s/", teamId, channelId, userId, oldId) + path := pathPrefix + name + + // Open the file and populate the fields of the FileInfo + var info *model.FileInfo + if data, err := ReadFile(path); err != nil { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.file_not_found.error"), post.Id, filename, path, err) + return nil + } else { + var err *model.AppError + info, err = model.GetInfoForBytes(name, data) + if err != nil { + l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.info.app_error"), post.Id, filename, err) + } + } + + // Generate a new ID because with the old system, you could very rarely get multiple posts referencing the same file + info.Id = model.NewId() + info.CreatorId = post.UserId + info.PostId = post.Id + info.CreateAt = post.CreateAt + info.UpdateAt = post.UpdateAt + info.Path = path + + if info.IsImage() { + nameWithoutExtension := name[:strings.LastIndex(name, ".")] + info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg" + info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg" + } + + return info +} + +func FindTeamIdForFilename(post *model.Post, filename string) string { + split := strings.SplitN(filename, "/", 5) + id := split[3] + name, _ := url.QueryUnescape(split[4]) + + // This post is in a direct channel so we need to figure out what team the files are stored under. + if result := <-Srv.Store.Team().GetTeamsByUserId(post.UserId); result.Err != nil { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.teams.app_error"), post.Id, result.Err) + } else if teams := result.Data.([]*model.Team); len(teams) == 1 { + // The user has only one team so the post must've been sent from it + return teams[0].Id + } else { + for _, team := range teams { + path := fmt.Sprintf("teams/%s/channels/%s/users/%s/%s/%s", team.Id, post.ChannelId, post.UserId, id, name) + if _, err := ReadFile(path); err == nil { + // Found the team that this file was posted from + return team.Id + } + } + } + + return "" +} + +var fileMigrationLock sync.Mutex + +// Creates and stores FileInfos for a post created before the FileInfos table existed. +func MigrateFilenamesToFileInfos(post *model.Post) []*model.FileInfo { + if len(post.Filenames) == 0 { + l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.no_filenames.warn"), post.Id) + return []*model.FileInfo{} + } + + cchan := Srv.Store.Channel().Get(post.ChannelId, true) + + // There's a weird bug that rarely happens where a post ends up with duplicate Filenames so remove those + filenames := utils.RemoveDuplicatesFromStringArray(post.Filenames) + + var channel *model.Channel + if result := <-cchan; result.Err != nil { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.channel.app_error"), post.Id, post.ChannelId, result.Err) + return []*model.FileInfo{} + } else { + channel = result.Data.(*model.Channel) + } + + // Find the team that was used to make this post since its part of the file path that isn't saved in the Filename + var teamId string + if channel.TeamId == "" { + // This post was made in a cross-team DM channel so we need to find where its files were saved + teamId = FindTeamIdForFilename(post, filenames[0]) + } else { + teamId = channel.TeamId + } + + // Create FileInfo objects for this post + infos := make([]*model.FileInfo, 0, len(filenames)) + if teamId == "" { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.team_id.error"), post.Id, filenames) + } else { + for _, filename := range filenames { + info := GetInfoForFilename(post, teamId, filename) + if info == nil { + continue + } + + infos = append(infos, info) + } + } + + // Lock to prevent only one migration thread from trying to update the post at once, preventing duplicate FileInfos from being created + fileMigrationLock.Lock() + defer fileMigrationLock.Unlock() + + if result := <-Srv.Store.Post().Get(post.Id); result.Err != nil { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.get_post_again.app_error"), post.Id, result.Err) + return []*model.FileInfo{} + } else if newPost := result.Data.(*model.PostList).Posts[post.Id]; len(newPost.Filenames) != len(post.Filenames) { + // Another thread has already created FileInfos for this post, so just return those + if result := <-Srv.Store.FileInfo().GetForPost(post.Id); result.Err != nil { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.get_post_file_infos_again.app_error"), post.Id, result.Err) + return []*model.FileInfo{} + } else { + l4g.Debug(utils.T("api.file.migrate_filenames_to_file_infos.not_migrating_post.debug"), post.Id) + return result.Data.([]*model.FileInfo) + } + } + + l4g.Debug(utils.T("api.file.migrate_filenames_to_file_infos.migrating_post.debug"), post.Id) + + savedInfos := make([]*model.FileInfo, 0, len(infos)) + fileIds := make([]string, 0, len(filenames)) + for _, info := range infos { + if result := <-Srv.Store.FileInfo().Save(info); result.Err != nil { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.save_file_info.app_error"), post.Id, info.Id, info.Path, result.Err) + continue + } + + savedInfos = append(savedInfos, info) + fileIds = append(fileIds, info.Id) + } + + // Copy and save the updated post + newPost := &model.Post{} + *newPost = *post + + newPost.Filenames = []string{} + newPost.FileIds = fileIds + + // Update Posts to clear Filenames and set FileIds + if result := <-Srv.Store.Post().Update(newPost, post); result.Err != nil { + l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.save_post.app_error"), post.Id, newPost.FileIds, post.Filenames, result.Err) + return []*model.FileInfo{} + } else { + return savedInfos + } +} + +func GeneratePublicLink(siteURL string, info *model.FileInfo) string { + hash := GeneratePublicLinkHash(info.Id, *utils.Cfg.FileSettings.PublicLinkSalt) + return fmt.Sprintf("%s%s/public/files/%v/get?h=%s", siteURL, model.API_URL_SUFFIX, info.Id, hash) +} + +func GeneratePublicLinkHash(fileId, salt string) string { + hash := sha256.New() + hash.Write([]byte(salt)) + hash.Write([]byte(fileId)) + + return base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) +} + +func DoUploadFile(teamId string, channelId string, userId string, rawFilename string, data []byte) (*model.FileInfo, *model.AppError) { + filename := filepath.Base(rawFilename) + + info, err := model.GetInfoForBytes(filename, data) + if err != nil { + err.StatusCode = http.StatusBadRequest + return nil, err + } + + info.Id = model.NewId() + info.CreatorId = userId + + pathPrefix := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + info.Id + "/" + info.Path = pathPrefix + filename + + if info.IsImage() { + // Check dimensions before loading the whole thing into memory later on + if info.Width*info.Height > MaxImageSize { + err := model.NewLocAppError("uploadFile", "api.file.upload_file.large_image.app_error", map[string]interface{}{"Filename": filename}, "") + err.StatusCode = http.StatusBadRequest + return nil, err + } + + nameWithoutExtension := filename[:strings.LastIndex(filename, ".")] + info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg" + info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg" + } + + if err := WriteFile(data, info.Path); err != nil { + return nil, err + } + + if result := <-Srv.Store.FileInfo().Save(info); result.Err != nil { + return nil, result.Err + } + + return info, nil +} + +func HandleImages(previewPathList []string, thumbnailPathList []string, fileData [][]byte) { + for i, data := range fileData { + go func(i int, data []byte) { + img, width, height := prepareImage(fileData[i]) + if img != nil { + go generateThumbnailImage(*img, thumbnailPathList[i], width, height) + go generatePreviewImage(*img, previewPathList[i], width) + } + }(i, data) + } +} + +func prepareImage(fileData []byte) (*image.Image, int, int) { + // Decode image bytes into Image object + img, imgType, err := image.Decode(bytes.NewReader(fileData)) + if err != nil { + l4g.Error(utils.T("api.file.handle_images_forget.decode.error"), err) + return nil, 0, 0 + } + + width := img.Bounds().Dx() + height := img.Bounds().Dy() + + // Fill in the background of a potentially-transparent png file as white + if imgType == "png" { + dst := image.NewRGBA(img.Bounds()) + draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) + draw.Draw(dst, dst.Bounds(), img, img.Bounds().Min, draw.Over) + img = dst + } + + // Flip the image to be upright + orientation, _ := getImageOrientation(fileData) + + switch orientation { + case UprightMirrored: + img = imaging.FlipH(img) + case UpsideDown: + img = imaging.Rotate180(img) + case UpsideDownMirrored: + img = imaging.FlipV(img) + case RotatedCWMirrored: + img = imaging.Transpose(img) + case RotatedCCW: + img = imaging.Rotate270(img) + case RotatedCCWMirrored: + img = imaging.Transverse(img) + case RotatedCW: + img = imaging.Rotate90(img) + } + + return &img, width, height +} + +func getImageOrientation(imageData []byte) (int, error) { + if exifData, err := exif.Decode(bytes.NewReader(imageData)); err != nil { + return Upright, err + } else { + if tag, err := exifData.Get("Orientation"); err != nil { + return Upright, err + } else { + orientation, err := tag.Int(0) + if err != nil { + return Upright, err + } else { + return orientation, nil + } + } + } +} + +func generateThumbnailImage(img image.Image, thumbnailPath string, width int, height int) { + thumbWidth := float64(utils.Cfg.FileSettings.ThumbnailWidth) + thumbHeight := float64(utils.Cfg.FileSettings.ThumbnailHeight) + imgWidth := float64(width) + imgHeight := float64(height) + + var thumbnail image.Image + if imgHeight < thumbHeight && imgWidth < thumbWidth { + thumbnail = img + } else if imgHeight/imgWidth < thumbHeight/thumbWidth { + thumbnail = imaging.Resize(img, 0, utils.Cfg.FileSettings.ThumbnailHeight, imaging.Lanczos) + } else { + thumbnail = imaging.Resize(img, utils.Cfg.FileSettings.ThumbnailWidth, 0, imaging.Lanczos) + } + + buf := new(bytes.Buffer) + if err := jpeg.Encode(buf, thumbnail, &jpeg.Options{Quality: 90}); err != nil { + l4g.Error(utils.T("api.file.handle_images_forget.encode_jpeg.error"), thumbnailPath, err) + return + } + + if err := WriteFile(buf.Bytes(), thumbnailPath); err != nil { + l4g.Error(utils.T("api.file.handle_images_forget.upload_thumb.error"), thumbnailPath, err) + return + } +} + +func generatePreviewImage(img image.Image, previewPath string, width int) { + var preview image.Image + if width > int(utils.Cfg.FileSettings.PreviewWidth) { + preview = imaging.Resize(img, utils.Cfg.FileSettings.PreviewWidth, utils.Cfg.FileSettings.PreviewHeight, imaging.Lanczos) + } else { + preview = img + } + + buf := new(bytes.Buffer) + + if err := jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}); err != nil { + l4g.Error(utils.T("api.file.handle_images_forget.encode_preview.error"), previewPath, err) + return + } + + if err := WriteFile(buf.Bytes(), previewPath); err != nil { + l4g.Error(utils.T("api.file.handle_images_forget.upload_preview.error"), previewPath, err) + return + } +} diff --git a/app/file_test.go b/app/file_test.go new file mode 100644 index 000000000..9df03315e --- /dev/null +++ b/app/file_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/platform/model" +) + +func TestGeneratePublicLinkHash(t *testing.T) { + filename1 := model.NewId() + "/" + model.NewRandomString(16) + ".txt" + filename2 := model.NewId() + "/" + model.NewRandomString(16) + ".txt" + salt1 := model.NewRandomString(32) + salt2 := model.NewRandomString(32) + + hash1 := GeneratePublicLinkHash(filename1, salt1) + hash2 := GeneratePublicLinkHash(filename2, salt1) + hash3 := GeneratePublicLinkHash(filename1, salt2) + + if hash1 != GeneratePublicLinkHash(filename1, salt1) { + t.Fatal("hash should be equal for the same file name and salt") + } + + if hash1 == hash2 { + t.Fatal("hashes for different files should not be equal") + } + + if hash1 == hash3 { + t.Fatal("hashes for the same file with different salts should not be equal") + } +} diff --git a/app/import.go b/app/import.go new file mode 100644 index 000000000..8f2cf552e --- /dev/null +++ b/app/import.go @@ -0,0 +1,157 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "bytes" + "io" + "regexp" + "unicode/utf8" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +// +// Import functions are sutible for entering posts and users into the database without +// some of the usual checks. (IsValid is still run) +// + +func ImportPost(post *model.Post) { + // Workaround for empty messages, which may be the case if they are webhook posts. + firstIteration := true + for messageRuneCount := utf8.RuneCountInString(post.Message); messageRuneCount > 0 || firstIteration; messageRuneCount = utf8.RuneCountInString(post.Message) { + firstIteration = false + var remainder string + if messageRuneCount > model.POST_MESSAGE_MAX_RUNES { + remainder = string(([]rune(post.Message))[model.POST_MESSAGE_MAX_RUNES:]) + post.Message = truncateRunes(post.Message, model.POST_MESSAGE_MAX_RUNES) + } else { + remainder = "" + } + + post.Hashtags, _ = model.ParseHashtags(post.Message) + + if result := <-Srv.Store.Post().Save(post); result.Err != nil { + l4g.Debug(utils.T("api.import.import_post.saving.debug"), post.UserId, post.Message) + } + + for _, fileId := range post.FileIds { + if result := <-Srv.Store.FileInfo().AttachToPost(fileId, post.Id); result.Err != nil { + l4g.Error(utils.T("api.import.import_post.attach_files.error"), post.Id, post.FileIds, result.Err) + } + } + + post.Id = "" + post.CreateAt++ + post.Message = remainder + } +} + +func ImportUser(team *model.Team, user *model.User) *model.User { + user.MakeNonNil() + + user.Roles = model.ROLE_SYSTEM_USER.Id + + if result := <-Srv.Store.User().Save(user); result.Err != nil { + l4g.Error(utils.T("api.import.import_user.saving.error"), result.Err) + return nil + } else { + ruser := result.Data.(*model.User) + + if cresult := <-Srv.Store.User().VerifyEmail(ruser.Id); cresult.Err != nil { + l4g.Error(utils.T("api.import.import_user.set_email.error"), cresult.Err) + } + + if err := JoinUserToTeam(team, user); err != nil { + l4g.Error(utils.T("api.import.import_user.join_team.error"), err) + } + + return ruser + } +} + +func ImportChannel(channel *model.Channel) *model.Channel { + if result := <-Srv.Store.Channel().Save(channel); result.Err != nil { + return nil + } else { + sc := result.Data.(*model.Channel) + + return sc + } +} + +func ImportFile(file io.Reader, teamId string, channelId string, userId string, fileName string) (*model.FileInfo, error) { + buf := bytes.NewBuffer(nil) + io.Copy(buf, file) + data := buf.Bytes() + + fileInfo, err := DoUploadFile(teamId, channelId, userId, fileName, data) + if err != nil { + return nil, err + } + + img, width, height := prepareImage(data) + if img != nil { + generateThumbnailImage(*img, fileInfo.ThumbnailPath, width, height) + generatePreviewImage(*img, fileInfo.PreviewPath, width) + } + + return fileInfo, nil +} + +func ImportIncomingWebhookPost(post *model.Post, props model.StringInterface) { + linkWithTextRegex := regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`) + post.Message = linkWithTextRegex.ReplaceAllString(post.Message, "[${2}](${1})") + + post.AddProp("from_webhook", "true") + + if _, ok := props["override_username"]; !ok { + post.AddProp("override_username", model.DEFAULT_WEBHOOK_USERNAME) + } + + if len(props) > 0 { + for key, val := range props { + if key == "attachments" { + if list, success := val.([]interface{}); success { + // parse attachment links into Markdown format + for i, aInt := range list { + attachment := aInt.(map[string]interface{}) + if aText, ok := attachment["text"].(string); ok { + aText = linkWithTextRegex.ReplaceAllString(aText, "[${2}](${1})") + attachment["text"] = aText + list[i] = attachment + } + if aText, ok := attachment["pretext"].(string); ok { + aText = linkWithTextRegex.ReplaceAllString(aText, "[${2}](${1})") + attachment["pretext"] = aText + list[i] = attachment + } + if fVal, ok := attachment["fields"]; ok { + if fields, ok := fVal.([]interface{}); ok { + // parse attachment field links into Markdown format + for j, fInt := range fields { + field := fInt.(map[string]interface{}) + if fValue, ok := field["value"].(string); ok { + fValue = linkWithTextRegex.ReplaceAllString(fValue, "[${2}](${1})") + field["value"] = fValue + fields[j] = field + } + } + attachment["fields"] = fields + list[i] = attachment + } + } + } + post.AddProp(key, list) + } + } else if key != "from_webhook" { + post.AddProp(key, val) + } + } + } + + ImportPost(post) +} diff --git a/app/ldap.go b/app/ldap.go new file mode 100644 index 000000000..fe68dfa81 --- /dev/null +++ b/app/ldap.go @@ -0,0 +1,40 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "net/http" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func SyncLdap() { + go func() { + if utils.IsLicensed && *utils.License.Features.LDAP && *utils.Cfg.LdapSettings.Enable { + if ldapI := einterfaces.GetLdapInterface(); ldapI != nil { + ldapI.SyncNow() + } else { + l4g.Error("%v", model.NewLocAppError("ldapSyncNow", "ent.ldap.disabled.app_error", nil, "").Error()) + } + } + }() +} + +func TestLdap() *model.AppError { + if ldapI := einterfaces.GetLdapInterface(); ldapI != nil && utils.IsLicensed && *utils.License.Features.LDAP && *utils.Cfg.LdapSettings.Enable { + if err := ldapI.RunTest(); err != nil { + err.StatusCode = 500 + return err + } + } else { + err := model.NewLocAppError("ldapTest", "ent.ldap.disabled.app_error", nil, "") + err.StatusCode = http.StatusNotImplemented + return err + } + + return nil +} diff --git a/app/notification.go b/app/notification.go new file mode 100644 index 000000000..ec78c416b --- /dev/null +++ b/app/notification.go @@ -0,0 +1,721 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "crypto/tls" + "fmt" + "html" + "html/template" + "io/ioutil" + "net/http" + "net/url" + "path/filepath" + "sort" + "strings" + "time" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" + "github.com/nicksnyder/go-i18n/i18n" +) + +func SendNotifications(post *model.Post, team *model.Team, channel *model.Channel, sender *model.User) ([]string, *model.AppError) { + pchan := Srv.Store.User().GetProfilesInChannel(channel.Id, -1, -1, true) + fchan := Srv.Store.FileInfo().GetForPost(post.Id) + + var profileMap map[string]*model.User + if result := <-pchan; result.Err != nil { + return nil, result.Err + } else { + profileMap = result.Data.(map[string]*model.User) + } + + // If the user who made the post isn't in the channel, don't send a notification + if _, ok := profileMap[post.UserId]; !ok && post.Props["from_webhook"] != "true" { + l4g.Debug(utils.T("api.post.send_notifications.user_id.debug"), post.Id, channel.Id, post.UserId) + return []string{}, nil + } + + mentionedUserIds := make(map[string]bool) + allActivityPushUserIds := []string{} + hereNotification := false + channelNotification := false + allNotification := false + updateMentionChans := []store.StoreChannel{} + + if channel.Type == model.CHANNEL_DIRECT { + var otherUserId string + if userIds := strings.Split(channel.Name, "__"); userIds[0] == post.UserId { + otherUserId = userIds[1] + } else { + otherUserId = userIds[0] + } + + mentionedUserIds[otherUserId] = true + if post.Props["from_webhook"] == "true" { + mentionedUserIds[post.UserId] = true + } + } else { + keywords := GetMentionKeywordsInChannel(profileMap) + + var potentialOtherMentions []string + mentionedUserIds, potentialOtherMentions, hereNotification, channelNotification, allNotification = GetExplicitMentions(post.Message, keywords) + + // get users that have comment thread mentions enabled + if len(post.RootId) > 0 { + if result := <-Srv.Store.Post().Get(post.RootId); result.Err != nil { + return nil, result.Err + } else { + list := result.Data.(*model.PostList) + + for _, threadPost := range list.Posts { + profile := profileMap[threadPost.UserId] + if profile.NotifyProps["comments"] == "any" || (profile.NotifyProps["comments"] == "root" && threadPost.Id == list.Order[0]) { + mentionedUserIds[threadPost.UserId] = true + } + } + } + } + + // prevent the user from mentioning themselves + if post.Props["from_webhook"] != "true" { + delete(mentionedUserIds, post.UserId) + } + + if len(potentialOtherMentions) > 0 { + if result := <-Srv.Store.User().GetProfilesByUsernames(potentialOtherMentions, team.Id); result.Err == nil { + outOfChannelMentions := result.Data.(map[string]*model.User) + go sendOutOfChannelMentions(sender, post, team.Id, outOfChannelMentions) + } + } + + // find which users in the channel are set up to always receive mobile notifications + for _, profile := range profileMap { + if profile.NotifyProps["push"] == model.USER_NOTIFY_ALL && + (post.UserId != profile.Id || post.Props["from_webhook"] == "true") && + !post.IsSystemMessage() { + allActivityPushUserIds = append(allActivityPushUserIds, profile.Id) + } + } + } + + mentionedUsersList := make([]string, 0, len(mentionedUserIds)) + for id := range mentionedUserIds { + mentionedUsersList = append(mentionedUsersList, id) + updateMentionChans = append(updateMentionChans, Srv.Store.Channel().IncrementMentionCount(post.ChannelId, id)) + } + + senderName := make(map[string]string) + for _, id := range mentionedUsersList { + senderName[id] = "" + if post.IsSystemMessage() { + senderName[id] = utils.T("system.message.name") + } else { + if value, ok := post.Props["override_username"]; ok && post.Props["from_webhook"] == "true" { + senderName[id] = value.(string) + } else { + // Get the Display name preference from the receiver + if result := <-Srv.Store.Preference().Get(id, model.PREFERENCE_CATEGORY_DISPLAY_SETTINGS, "name_format"); result.Err != nil { + // Show default sender's name if user doesn't set display settings. + senderName[id] = sender.Username + } else { + senderName[id] = sender.GetDisplayNameForPreference(result.Data.(model.Preference).Value) + } + } + } + } + + var senderUsername string + if value, ok := post.Props["override_username"]; ok && post.Props["from_webhook"] == "true" { + senderUsername = value.(string) + } else { + senderUsername = sender.Username + } + + if utils.Cfg.EmailSettings.SendEmailNotifications { + for _, id := range mentionedUsersList { + userAllowsEmails := profileMap[id].NotifyProps["email"] != "false" + + var status *model.Status + var err *model.AppError + if status, err = GetStatus(id); err != nil { + status = &model.Status{ + UserId: id, + Status: model.STATUS_OFFLINE, + Manual: false, + LastActivityAt: 0, + ActiveChannel: "", + } + } + + if userAllowsEmails && status.Status != model.STATUS_ONLINE && profileMap[id].DeleteAt == 0 { + sendNotificationEmail(post, profileMap[id], channel, team, senderName[id], sender) + } + } + } + + T := utils.GetUserTranslations(sender.Locale) + + // If the channel has more than 1K users then @here is disabled + if hereNotification && int64(len(profileMap)) > *utils.Cfg.TeamSettings.MaxNotificationsPerChannel { + hereNotification = false + SendEphemeralPost( + team.Id, + post.UserId, + &model.Post{ + ChannelId: post.ChannelId, + Message: T("api.post.disabled_here", map[string]interface{}{"Users": *utils.Cfg.TeamSettings.MaxNotificationsPerChannel}), + CreateAt: post.CreateAt + 1, + }, + ) + } + + // If the channel has more than 1K users then @channel is disabled + if channelNotification && int64(len(profileMap)) > *utils.Cfg.TeamSettings.MaxNotificationsPerChannel { + SendEphemeralPost( + team.Id, + post.UserId, + &model.Post{ + ChannelId: post.ChannelId, + Message: T("api.post.disabled_channel", map[string]interface{}{"Users": *utils.Cfg.TeamSettings.MaxNotificationsPerChannel}), + CreateAt: post.CreateAt + 1, + }, + ) + } + + // If the channel has more than 1K users then @all is disabled + if allNotification && int64(len(profileMap)) > *utils.Cfg.TeamSettings.MaxNotificationsPerChannel { + SendEphemeralPost( + team.Id, + post.UserId, + &model.Post{ + ChannelId: post.ChannelId, + Message: T("api.post.disabled_all", map[string]interface{}{"Users": *utils.Cfg.TeamSettings.MaxNotificationsPerChannel}), + CreateAt: post.CreateAt + 1, + }, + ) + } + + if hereNotification { + if result := <-Srv.Store.Status().GetOnline(); result.Err != nil { + return nil, result.Err + } else { + statuses := result.Data.([]*model.Status) + for _, status := range statuses { + if status.UserId == post.UserId { + continue + } + + _, profileFound := profileMap[status.UserId] + _, alreadyMentioned := mentionedUserIds[status.UserId] + + if status.Status == model.STATUS_ONLINE && profileFound && !alreadyMentioned { + mentionedUsersList = append(mentionedUsersList, status.UserId) + updateMentionChans = append(updateMentionChans, Srv.Store.Channel().IncrementMentionCount(post.ChannelId, status.UserId)) + } + } + } + } + + // Make sure all mention updates are complete to prevent race + // Probably better to batch these DB updates in the future + // MUST be completed before push notifications send + for _, uchan := range updateMentionChans { + if result := <-uchan; result.Err != nil { + l4g.Warn(utils.T("api.post.update_mention_count_and_forget.update_error"), post.Id, post.ChannelId, result.Err) + } + } + + sendPushNotifications := false + if *utils.Cfg.EmailSettings.SendPushNotifications { + pushServer := *utils.Cfg.EmailSettings.PushNotificationServer + if pushServer == model.MHPNS && (!utils.IsLicensed || !*utils.License.Features.MHPNS) { + l4g.Warn(utils.T("api.post.send_notifications_and_forget.push_notification.mhpnsWarn")) + sendPushNotifications = false + } else { + sendPushNotifications = true + } + } + + if sendPushNotifications { + for _, id := range mentionedUsersList { + var status *model.Status + var err *model.AppError + if status, err = GetStatus(id); err != nil { + status = &model.Status{id, model.STATUS_OFFLINE, false, 0, ""} + } + + if DoesStatusAllowPushNotification(profileMap[id], status, post.ChannelId) { + sendPushNotification(post, profileMap[id], channel, senderName[id], true) + } + } + + for _, id := range allActivityPushUserIds { + if _, ok := mentionedUserIds[id]; !ok { + var status *model.Status + var err *model.AppError + if status, err = GetStatus(id); err != nil { + status = &model.Status{id, model.STATUS_OFFLINE, false, 0, ""} + } + + if DoesStatusAllowPushNotification(profileMap[id], status, post.ChannelId) { + sendPushNotification(post, profileMap[id], channel, senderName[id], false) + } + } + } + } + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POSTED, "", post.ChannelId, "", nil) + message.Add("post", post.ToJson()) + message.Add("channel_type", channel.Type) + message.Add("channel_display_name", channel.DisplayName) + message.Add("channel_name", channel.Name) + message.Add("sender_name", senderUsername) + message.Add("team_id", team.Id) + + if len(post.FileIds) != 0 { + message.Add("otherFile", "true") + + var infos []*model.FileInfo + if result := <-fchan; result.Err != nil { + l4g.Warn(utils.T("api.post.send_notifications.files.error"), post.Id, result.Err) + } else { + infos = result.Data.([]*model.FileInfo) + } + + for _, info := range infos { + if info.IsImage() { + message.Add("image", "true") + break + } + } + } + + if len(mentionedUsersList) != 0 { + message.Add("mentions", model.ArrayToJson(mentionedUsersList)) + } + + Publish(message) + return mentionedUsersList, nil +} + +func sendNotificationEmail(post *model.Post, user *model.User, channel *model.Channel, team *model.Team, senderName string, sender *model.User) *model.AppError { + if channel.Type == model.CHANNEL_DIRECT && channel.TeamId != team.Id { + // this message is a cross-team DM so it we need to find a team that the recipient is on to use in the link + if result := <-Srv.Store.Team().GetTeamsByUserId(user.Id); result.Err != nil { + return result.Err + } else { + // if the recipient isn't in the current user's team, just pick one + teams := result.Data.([]*model.Team) + found := false + + for i := range teams { + if teams[i].Id == team.Id { + found = true + break + } + } + + if !found && len(teams) > 0 { + team = teams[0] + } else { + // in case the user hasn't joined any teams we send them to the select_team page + team = &model.Team{Name: "select_team", DisplayName: utils.Cfg.TeamSettings.SiteName} + } + } + } + if *utils.Cfg.EmailSettings.EnableEmailBatching { + var sendBatched bool + + if result := <-Srv.Store.Preference().Get(user.Id, model.PREFERENCE_CATEGORY_NOTIFICATIONS, model.PREFERENCE_NAME_EMAIL_INTERVAL); result.Err != nil { + // if the call fails, assume it hasn't been set and use the default + sendBatched = false + } else { + // default to not using batching if the setting is set to immediate + sendBatched = result.Data.(model.Preference).Value != model.PREFERENCE_DEFAULT_EMAIL_INTERVAL + } + + if sendBatched { + if err := AddNotificationEmailToBatch(user, post, team); err == nil { + return nil + } + } + + // fall back to sending a single email if we can't batch it for some reason + } + + var channelName string + var bodyText string + var subjectText string + var mailTemplate string + var mailParameters map[string]interface{} + + teamURL := utils.GetSiteURL() + "/" + team.Name + tm := time.Unix(post.CreateAt/1000, 0) + + userLocale := utils.GetUserTranslations(user.Locale) + month := userLocale(tm.Month().String()) + day := fmt.Sprintf("%d", tm.Day()) + year := fmt.Sprintf("%d", tm.Year()) + zone, _ := tm.Zone() + + if channel.Type == model.CHANNEL_DIRECT { + bodyText = userLocale("api.post.send_notifications_and_forget.message_body") + subjectText = userLocale("api.post.send_notifications_and_forget.message_subject") + + senderDisplayName := senderName + + mailTemplate = "api.templates.post_subject_in_direct_message" + mailParameters = map[string]interface{}{"SubjectText": subjectText, "TeamDisplayName": team.DisplayName, + "SenderDisplayName": senderDisplayName, "Month": month, "Day": day, "Year": year} + } else { + bodyText = userLocale("api.post.send_notifications_and_forget.mention_body") + subjectText = userLocale("api.post.send_notifications_and_forget.mention_subject") + channelName = channel.DisplayName + mailTemplate = "api.templates.post_subject_in_channel" + mailParameters = map[string]interface{}{"SubjectText": subjectText, "TeamDisplayName": team.DisplayName, + "ChannelName": channelName, "Month": month, "Day": day, "Year": year} + } + + subject := fmt.Sprintf("[%v] %v", utils.Cfg.TeamSettings.SiteName, userLocale(mailTemplate, mailParameters)) + + bodyPage := utils.NewHTMLTemplate("post_body", user.Locale) + bodyPage.Props["SiteURL"] = utils.GetSiteURL() + bodyPage.Props["PostMessage"] = GetMessageForNotification(post, userLocale) + if team.Name != "select_team" { + bodyPage.Props["TeamLink"] = teamURL + "/pl/" + post.Id + } else { + bodyPage.Props["TeamLink"] = teamURL + } + + bodyPage.Props["BodyText"] = bodyText + bodyPage.Props["Button"] = userLocale("api.templates.post_body.button") + bodyPage.Html["Info"] = template.HTML(userLocale("api.templates.post_body.info", + map[string]interface{}{"ChannelName": channelName, "SenderName": senderName, + "Hour": fmt.Sprintf("%02d", tm.Hour()), "Minute": fmt.Sprintf("%02d", tm.Minute()), + "TimeZone": zone, "Month": month, "Day": day})) + + if err := utils.SendMail(user.Email, html.UnescapeString(subject), bodyPage.Render()); err != nil { + return err + } + + if einterfaces.GetMetricsInterface() != nil { + einterfaces.GetMetricsInterface().IncrementPostSentEmail() + } + + return nil +} + +func GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string { + if len(strings.TrimSpace(post.Message)) != 0 || len(post.FileIds) == 0 { + return post.Message + } + + // extract the filenames from their paths and determine what type of files are attached + var infos []*model.FileInfo + if result := <-Srv.Store.FileInfo().GetForPost(post.Id); result.Err != nil { + l4g.Warn(utils.T("api.post.get_message_for_notification.get_files.error"), post.Id, result.Err) + } else { + infos = result.Data.([]*model.FileInfo) + } + + filenames := make([]string, len(infos)) + onlyImages := true + for i, info := range infos { + if escaped, err := url.QueryUnescape(filepath.Base(info.Name)); err != nil { + // this should never error since filepath was escaped using url.QueryEscape + filenames[i] = escaped + } else { + filenames[i] = info.Name + } + + onlyImages = onlyImages && info.IsImage() + } + + props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")} + + if onlyImages { + return translateFunc("api.post.get_message_for_notification.images_sent", len(filenames), props) + } else { + return translateFunc("api.post.get_message_for_notification.files_sent", len(filenames), props) + } +} + +func sendPushNotification(post *model.Post, user *model.User, channel *model.Channel, senderName string, wasMentioned bool) *model.AppError { + sessions, err := getMobileAppSessions(user.Id) + if err != nil { + return err + } + + var channelName string + + if channel.Type == model.CHANNEL_DIRECT { + channelName = senderName + } else { + channelName = channel.DisplayName + } + + userLocale := utils.GetUserTranslations(user.Locale) + + msg := model.PushNotification{} + if badge := <-Srv.Store.User().GetUnreadCount(user.Id); badge.Err != nil { + msg.Badge = 1 + l4g.Error(utils.T("store.sql_user.get_unread_count.app_error"), user.Id, badge.Err) + } else { + msg.Badge = int(badge.Data.(int64)) + } + msg.Type = model.PUSH_TYPE_MESSAGE + msg.TeamId = channel.TeamId + msg.ChannelId = channel.Id + msg.ChannelName = channel.Name + + if *utils.Cfg.EmailSettings.PushNotificationContents == model.FULL_NOTIFICATION { + if channel.Type == model.CHANNEL_DIRECT { + msg.Category = model.CATEGORY_DM + msg.Message = "@" + senderName + ": " + model.ClearMentionTags(post.Message) + } else { + msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_in") + channelName + ": " + model.ClearMentionTags(post.Message) + } + } else { + if channel.Type == model.CHANNEL_DIRECT { + msg.Category = model.CATEGORY_DM + msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") + } else if wasMentioned { + msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName + } else { + msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention") + channelName + } + } + + l4g.Debug(utils.T("api.post.send_notifications_and_forget.push_notification.debug"), msg.DeviceId, msg.Message) + + for _, session := range sessions { + tmpMessage := *model.PushNotificationFromJson(strings.NewReader(msg.ToJson())) + tmpMessage.SetDeviceIdAndPlatform(session.DeviceId) + if err := sendToPushProxy(tmpMessage); err != nil { + return err + } + + if einterfaces.GetMetricsInterface() != nil { + einterfaces.GetMetricsInterface().IncrementPostSentPush() + } + } + + return nil +} + +func ClearPushNotification(userId string, channelId string) *model.AppError { + sessions, err := getMobileAppSessions(userId) + if err != nil { + return err + } + + msg := model.PushNotification{} + msg.Type = model.PUSH_TYPE_CLEAR + msg.ChannelId = channelId + msg.ContentAvailable = 0 + if badge := <-Srv.Store.User().GetUnreadCount(userId); badge.Err != nil { + msg.Badge = 0 + l4g.Error(utils.T("store.sql_user.get_unread_count.app_error"), userId, badge.Err) + } else { + msg.Badge = int(badge.Data.(int64)) + } + + l4g.Debug(utils.T("api.post.send_notifications_and_forget.clear_push_notification.debug"), msg.DeviceId, msg.ChannelId) + + for _, session := range sessions { + tmpMessage := *model.PushNotificationFromJson(strings.NewReader(msg.ToJson())) + tmpMessage.SetDeviceIdAndPlatform(session.DeviceId) + if err := sendToPushProxy(tmpMessage); err != nil { + return err + } + } + + return nil +} + +func sendToPushProxy(msg model.PushNotification) *model.AppError { + msg.ServerId = utils.CfgDiagnosticId + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: *utils.Cfg.ServiceSettings.EnableInsecureOutgoingConnections}, + } + httpClient := &http.Client{Transport: tr} + request, _ := http.NewRequest("POST", *utils.Cfg.EmailSettings.PushNotificationServer+model.API_URL_SUFFIX_V1+"/send_push", strings.NewReader(msg.ToJson())) + + if resp, err := httpClient.Do(request); err != nil { + return model.NewLocAppError("sendToPushProxy", "api.post.send_notifications_and_forget.push_notification.error", map[string]interface{}{"DeviceId": msg.DeviceId, "Error": err.Error()}, "") + } else { + ioutil.ReadAll(resp.Body) + resp.Body.Close() + } + + return nil +} + +func getMobileAppSessions(userId string) ([]*model.Session, *model.AppError) { + if result := <-Srv.Store.Session().GetSessionsWithActiveDeviceIds(userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.Session), nil + } +} + +func sendOutOfChannelMentions(sender *model.User, post *model.Post, teamId string, profiles map[string]*model.User) *model.AppError { + if len(profiles) == 0 { + return nil + } + + var usernames []string + for _, user := range profiles { + usernames = append(usernames, user.Username) + } + sort.Strings(usernames) + + T := utils.GetUserTranslations(sender.Locale) + + var message string + if len(usernames) == 1 { + message = T("api.post.check_for_out_of_channel_mentions.message.one", map[string]interface{}{ + "Username": usernames[0], + }) + } else { + message = T("api.post.check_for_out_of_channel_mentions.message.multiple", map[string]interface{}{ + "Usernames": strings.Join(usernames[:len(usernames)-1], ", "), + "LastUsername": usernames[len(usernames)-1], + }) + } + + SendEphemeralPost( + teamId, + post.UserId, + &model.Post{ + ChannelId: post.ChannelId, + Message: message, + CreateAt: post.CreateAt + 1, + }, + ) + + return nil +} + +// Given a message and a map mapping mention keywords to the users who use them, returns a map of mentioned +// users and a slice of potential mention users not in the channel and whether or not @here was mentioned. +func GetExplicitMentions(message string, keywords map[string][]string) (map[string]bool, []string, bool, bool, bool) { + mentioned := make(map[string]bool) + potentialOthersMentioned := make([]string, 0) + systemMentions := map[string]bool{"@here": true, "@channel": true, "@all": true} + hereMentioned := false + allMentioned := false + channelMentioned := false + + addMentionedUsers := func(ids []string) { + for _, id := range ids { + mentioned[id] = true + } + } + + for _, word := range strings.Fields(message) { + isMention := false + + if word == "@here" { + hereMentioned = true + } + + if word == "@channel" { + channelMentioned = true + } + + if word == "@all" { + allMentioned = true + } + + // Non-case-sensitive check for regular keys + if ids, match := keywords[strings.ToLower(word)]; match { + addMentionedUsers(ids) + isMention = true + } + + // Case-sensitive check for first name + if ids, match := keywords[word]; match { + addMentionedUsers(ids) + isMention = true + } + + if !isMention { + // No matches were found with the string split just on whitespace so try further splitting + // the message on punctuation + splitWords := strings.FieldsFunc(word, func(c rune) bool { + return model.SplitRunes[c] + }) + + for _, splitWord := range splitWords { + if splitWord == "@here" { + hereMentioned = true + } + + if splitWord == "@all" { + allMentioned = true + } + + if splitWord == "@channel" { + channelMentioned = true + } + + // Non-case-sensitive check for regular keys + if ids, match := keywords[strings.ToLower(splitWord)]; match { + addMentionedUsers(ids) + } + + // Case-sensitive check for first name + if ids, match := keywords[splitWord]; match { + addMentionedUsers(ids) + } else if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { + username := word[1:len(splitWord)] + potentialOthersMentioned = append(potentialOthersMentioned, username) + } + } + } + } + + return mentioned, potentialOthersMentioned, hereMentioned, channelMentioned, allMentioned +} + +// Given a map of user IDs to profiles, returns a list of mention +// keywords for all users in the channel. +func GetMentionKeywordsInChannel(profiles map[string]*model.User) map[string][]string { + keywords := make(map[string][]string) + + for id, profile := range profiles { + userMention := "@" + strings.ToLower(profile.Username) + keywords[userMention] = append(keywords[userMention], id) + + if len(profile.NotifyProps["mention_keys"]) > 0 { + // Add all the user's mention keys + splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",") + for _, k := range splitKeys { + // note that these are made lower case so that we can do a case insensitive check for them + key := strings.ToLower(k) + keywords[key] = append(keywords[key], id) + } + } + + // If turned on, add the user's case sensitive first name + if profile.NotifyProps["first_name"] == "true" { + keywords[profile.FirstName] = append(keywords[profile.FirstName], profile.Id) + } + + // Add @channel and @all to keywords if user has them turned on + if int64(len(profiles)) < *utils.Cfg.TeamSettings.MaxNotificationsPerChannel && profile.NotifyProps["channel"] == "true" { + keywords["@channel"] = append(keywords["@channel"], profile.Id) + keywords["@all"] = append(keywords["@all"], profile.Id) + } + } + + return keywords +} diff --git a/app/notification_test.go b/app/notification_test.go new file mode 100644 index 000000000..10eb09247 --- /dev/null +++ b/app/notification_test.go @@ -0,0 +1,313 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/platform/model" +) + +func TestSendNotifications(t *testing.T) { + th := Setup().InitBasic() + + AddUserToChannel(th.BasicUser2, th.BasicChannel) + + post1, postErr := CreatePost(&model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "@" + th.BasicUser2.Username, + }, th.BasicTeam.Id, true) + + if postErr != nil { + t.Fatal(postErr) + } + + mentions, err := SendNotifications(post1, th.BasicTeam, th.BasicChannel, th.BasicUser) + if err != nil { + t.Fatal(err) + } else if mentions == nil { + t.Log(mentions) + t.Fatal("user should have been mentioned") + } else if mentions[0] != th.BasicUser2.Id { + t.Log(mentions) + t.Fatal("user should have been mentioned") + } +} + +func TestGetExplicitMentions(t *testing.T) { + id1 := model.NewId() + id2 := model.NewId() + + // not mentioning anybody + message := "this is a message" + keywords := map[string][]string{} + if mentions, potential, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 0 || len(potential) != 0 { + t.Fatal("shouldn't have mentioned anybody or have any potencial mentions") + } + + // mentioning a user that doesn't exist + message = "this is a message for @user" + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 0 { + t.Fatal("shouldn't have mentioned user that doesn't exist") + } + + // mentioning one person + keywords = map[string][]string{"@user": {id1}} + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { + t.Fatal("should've mentioned @user") + } + + // mentioning one person without an @mention + message = "this is a message for @user" + keywords = map[string][]string{"this": {id1}} + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { + t.Fatal("should've mentioned this") + } + + // mentioning multiple people with one word + message = "this is a message for @user" + keywords = map[string][]string{"@user": {id1, id2}} + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + t.Fatal("should've mentioned two users with @user") + } + + // mentioning only one of multiple people + keywords = map[string][]string{"@user": {id1}, "@mention": {id2}} + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { + t.Fatal("should've mentioned @user and not @mention") + } + + // mentioning multiple people with multiple words + message = "this is an @mention for @user" + keywords = map[string][]string{"@user": {id1}, "@mention": {id2}} + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + t.Fatal("should've mentioned two users with @user and @mention") + } + + // mentioning @channel (not a special case, but it's good to double check) + message = "this is an message for @channel" + keywords = map[string][]string{"@channel": {id1, id2}} + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + t.Fatal("should've mentioned two users with @channel") + } + + // mentioning @all (not a special case, but it's good to double check) + message = "this is an message for @all" + keywords = map[string][]string{"@all": {id1, id2}} + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + t.Fatal("should've mentioned two users with @all") + } + + // mentioning user.period without mentioning user (PLT-3222) + message = "user.period doesn't complicate things at all by including periods in their username" + keywords = map[string][]string{"user.period": {id1}, "user": {id2}} + if mentions, _, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { + t.Fatal("should've mentioned user.period and not user") + } + + // mentioning a potential out of channel user + message = "this is an message for @potential and @user" + keywords = map[string][]string{"@user": {id1}} + if mentions, potential, _, _, _ := GetExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || len(potential) != 1 { + t.Fatal("should've mentioned user and have a potential not in channel") + } +} + +func TestGetExplicitMentionsAtHere(t *testing.T) { + // test all the boundary cases that we know can break up terms (and those that we know won't) + cases := map[string]bool{ + "": false, + "here": false, + "@here": true, + " @here ": true, + "\t@here\t": true, + "\n@here\n": true, + // "!@here!": true, + // "@@here@": true, + // "#@here#": true, + // "$@here$": true, + // "%@here%": true, + // "^@here^": true, + // "&@here&": true, + // "*@here*": true, + "(@here(": true, + ")@here)": true, + // "-@here-": true, + // "_@here_": true, + // "=@here=": true, + "+@here+": true, + "[@here[": true, + "{@here{": true, + "]@here]": true, + "}@here}": true, + "\\@here\\": true, + // "|@here|": true, + ";@here;": true, + ":@here:": true, + // "'@here'": true, + // "\"@here\"": true, + ",@here,": true, + "<@here<": true, + ".@here.": true, + ">@here>": true, + "/@here/": true, + "?@here?": true, + // "`@here`": true, + // "~@here~": true, + } + + for message, shouldMention := range cases { + if _, _, hereMentioned, _, _ := GetExplicitMentions(message, nil); hereMentioned && !shouldMention { + t.Fatalf("shouldn't have mentioned @here with \"%v\"", message) + } else if !hereMentioned && shouldMention { + t.Fatalf("should've have mentioned @here with \"%v\"", message) + } + } + + // mentioning @here and someone + id := model.NewId() + if mentions, potential, hereMentioned, _, _ := GetExplicitMentions("@here @user @potential", map[string][]string{"@user": {id}}); !hereMentioned { + t.Fatal("should've mentioned @here with \"@here @user\"") + } else if len(mentions) != 1 || !mentions[id] { + t.Fatal("should've mentioned @user with \"@here @user\"") + } else if len(potential) > 1 { + t.Fatal("should've potential mentions for @potential") + } +} + +func TestGetMentionKeywords(t *testing.T) { + Setup() + // user with username or custom mentions enabled + user1 := &model.User{ + Id: model.NewId(), + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "mention_keys": "User,@User,MENTION", + }, + } + + profiles := map[string]*model.User{user1.Id: user1} + mentions := GetMentionKeywordsInChannel(profiles) + if len(mentions) != 3 { + t.Fatal("should've returned three mention keywords") + } else if ids, ok := mentions["user"]; !ok || ids[0] != user1.Id { + t.Fatal("should've returned mention key of user") + } else if ids, ok := mentions["@user"]; !ok || ids[0] != user1.Id { + t.Fatal("should've returned mention key of @user") + } else if ids, ok := mentions["mention"]; !ok || ids[0] != user1.Id { + t.Fatal("should've returned mention key of mention") + } + + // user with first name mention enabled + user2 := &model.User{ + Id: model.NewId(), + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "first_name": "true", + }, + } + + profiles = map[string]*model.User{user2.Id: user2} + mentions = GetMentionKeywordsInChannel(profiles) + if len(mentions) != 2 { + t.Fatal("should've returned two mention keyword") + } else if ids, ok := mentions["First"]; !ok || ids[0] != user2.Id { + t.Fatal("should've returned mention key of First") + } + + // user with @channel/@all mentions enabled + user3 := &model.User{ + Id: model.NewId(), + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "channel": "true", + }, + } + + profiles = map[string]*model.User{user3.Id: user3} + mentions = GetMentionKeywordsInChannel(profiles) + if len(mentions) != 3 { + t.Fatal("should've returned three mention keywords") + } else if ids, ok := mentions["@channel"]; !ok || ids[0] != user3.Id { + t.Fatal("should've returned mention key of @channel") + } else if ids, ok := mentions["@all"]; !ok || ids[0] != user3.Id { + t.Fatal("should've returned mention key of @all") + } + + // user with all types of mentions enabled + user4 := &model.User{ + Id: model.NewId(), + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "mention_keys": "User,@User,MENTION", + "first_name": "true", + "channel": "true", + }, + } + + profiles = map[string]*model.User{user4.Id: user4} + mentions = GetMentionKeywordsInChannel(profiles) + if len(mentions) != 6 { + t.Fatal("should've returned six mention keywords") + } else if ids, ok := mentions["user"]; !ok || ids[0] != user4.Id { + t.Fatal("should've returned mention key of user") + } else if ids, ok := mentions["@user"]; !ok || ids[0] != user4.Id { + t.Fatal("should've returned mention key of @user") + } else if ids, ok := mentions["mention"]; !ok || ids[0] != user4.Id { + t.Fatal("should've returned mention key of mention") + } else if ids, ok := mentions["First"]; !ok || ids[0] != user4.Id { + t.Fatal("should've returned mention key of First") + } else if ids, ok := mentions["@channel"]; !ok || ids[0] != user4.Id { + t.Fatal("should've returned mention key of @channel") + } else if ids, ok := mentions["@all"]; !ok || ids[0] != user4.Id { + t.Fatal("should've returned mention key of @all") + } + + dup_count := func(list []string) map[string]int { + + duplicate_frequency := make(map[string]int) + + for _, item := range list { + // check if the item/element exist in the duplicate_frequency map + + _, exist := duplicate_frequency[item] + + if exist { + duplicate_frequency[item] += 1 // increase counter by 1 if already in the map + } else { + duplicate_frequency[item] = 1 // else start counting from 1 + } + } + return duplicate_frequency + } + + // multiple users + profiles = map[string]*model.User{ + user1.Id: user1, + user2.Id: user2, + user3.Id: user3, + user4.Id: user4, + } + mentions = GetMentionKeywordsInChannel(profiles) + if len(mentions) != 6 { + t.Fatal("should've returned six mention keywords") + } else if ids, ok := mentions["user"]; !ok || len(ids) != 2 || (ids[0] != user1.Id && ids[1] != user1.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { + t.Fatal("should've mentioned user1 and user4 with user") + } else if ids := dup_count(mentions["@user"]); len(ids) != 4 || (ids[user1.Id] != 2) || (ids[user4.Id] != 2) { + t.Fatal("should've mentioned user1 and user4 with @user") + } else if ids, ok := mentions["mention"]; !ok || len(ids) != 2 || (ids[0] != user1.Id && ids[1] != user1.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { + t.Fatal("should've mentioned user1 and user4 with mention") + } else if ids, ok := mentions["First"]; !ok || len(ids) != 2 || (ids[0] != user2.Id && ids[1] != user2.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { + t.Fatal("should've mentioned user2 and user4 with mention") + } else if ids, ok := mentions["@channel"]; !ok || len(ids) != 2 || (ids[0] != user3.Id && ids[1] != user3.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { + t.Fatal("should've mentioned user3 and user4 with @channel") + } else if ids, ok := mentions["@all"]; !ok || len(ids) != 2 || (ids[0] != user3.Id && ids[1] != user3.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { + t.Fatal("should've mentioned user3 and user4 with @all") + } +} diff --git a/app/oauth.go b/app/oauth.go new file mode 100644 index 000000000..3e8b0b8d2 --- /dev/null +++ b/app/oauth.go @@ -0,0 +1,34 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/platform/model" +) + +func RevokeAccessToken(token string) *model.AppError { + + session, _ := GetSession(token) + schan := Srv.Store.Session().Remove(token) + + if result := <-Srv.Store.OAuth().GetAccessData(token); result.Err != nil { + return model.NewLocAppError("RevokeAccessToken", "api.oauth.revoke_access_token.get.app_error", nil, "") + } + + tchan := Srv.Store.OAuth().RemoveAccessData(token) + + if result := <-tchan; result.Err != nil { + return model.NewLocAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_token.app_error", nil, "") + } + + if result := <-schan; result.Err != nil { + return model.NewLocAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_session.app_error", nil, "") + } + + if session != nil { + ClearSessionCacheForUser(session.UserId) + } + + return nil +} diff --git a/app/post.go b/app/post.go new file mode 100644 index 000000000..6d34cc035 --- /dev/null +++ b/app/post.go @@ -0,0 +1,501 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "net/http" + "regexp" + + l4g "github.com/alecthomas/log4go" + "github.com/dyatlov/go-opengraph/opengraph" + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +func CreatePostAsUser(post *model.Post, teamId string) (*model.Post, *model.AppError) { + // Check that channel has not been deleted + var channel *model.Channel + if result := <-Srv.Store.Channel().Get(post.ChannelId, true); result.Err != nil { + err := model.NewLocAppError("CreatePostAsUser", "api.context.invalid_param.app_error", map[string]interface{}{"Name": "post.channel_id"}, result.Err.Error()) + err.StatusCode = http.StatusBadRequest + return nil, err + } else { + channel = result.Data.(*model.Channel) + } + + if channel.DeleteAt != 0 { + err := model.NewLocAppError("createPost", "api.post.create_post.can_not_post_to_deleted.error", nil, "") + err.StatusCode = http.StatusBadRequest + return nil, err + } + + if rp, err := CreatePost(post, teamId, true); err != nil { + if err.Id == "api.post.create_post.root_id.app_error" || + err.Id == "api.post.create_post.channel_root_id.app_error" || + err.Id == "api.post.create_post.parent_id.app_error" { + err.StatusCode = http.StatusBadRequest + } + + return nil, err + } else { + // Update the LastViewAt only if the post does not have from_webhook prop set (eg. Zapier app) + if _, ok := post.Props["from_webhook"]; !ok { + if result := <-Srv.Store.Channel().UpdateLastViewedAt([]string{post.ChannelId}, post.UserId); result.Err != nil { + l4g.Error(utils.T("api.post.create_post.last_viewed.error"), post.ChannelId, post.UserId, result.Err) + } + } + + return rp, nil + } + +} + +func CreatePost(post *model.Post, teamId string, triggerWebhooks bool) (*model.Post, *model.AppError) { + var pchan store.StoreChannel + if len(post.RootId) > 0 { + pchan = Srv.Store.Post().Get(post.RootId) + } + + // Verify the parent/child relationships are correct + if pchan != nil { + if presult := <-pchan; presult.Err != nil { + return nil, model.NewLocAppError("createPost", "api.post.create_post.root_id.app_error", nil, "") + } else { + list := presult.Data.(*model.PostList) + if len(list.Posts) == 0 || !list.IsChannelId(post.ChannelId) { + return nil, model.NewLocAppError("createPost", "api.post.create_post.channel_root_id.app_error", nil, "") + } + + if post.ParentId == "" { + post.ParentId = post.RootId + } + + if post.RootId != post.ParentId { + parent := list.Posts[post.ParentId] + if parent == nil { + return nil, model.NewLocAppError("createPost", "api.post.create_post.parent_id.app_error", nil, "") + } + } + } + } + + post.Hashtags, _ = model.ParseHashtags(post.Message) + + var rpost *model.Post + if result := <-Srv.Store.Post().Save(post); result.Err != nil { + return nil, result.Err + } else { + rpost = result.Data.(*model.Post) + } + + if einterfaces.GetMetricsInterface() != nil { + einterfaces.GetMetricsInterface().IncrementPostCreate() + } + + if len(post.FileIds) > 0 { + // There's a rare bug where the client sends up duplicate FileIds so protect against that + post.FileIds = utils.RemoveDuplicatesFromStringArray(post.FileIds) + + for _, fileId := range post.FileIds { + if result := <-Srv.Store.FileInfo().AttachToPost(fileId, post.Id); result.Err != nil { + l4g.Error(utils.T("api.post.create_post.attach_files.error"), post.Id, post.FileIds, post.UserId, result.Err) + } + } + + if einterfaces.GetMetricsInterface() != nil { + einterfaces.GetMetricsInterface().IncrementPostFileAttachment(len(post.FileIds)) + } + } + + InvalidateCacheForChannel(rpost.ChannelId) + InvalidateCacheForChannelPosts(rpost.ChannelId) + + if err := handlePostEvents(rpost, teamId, triggerWebhooks); err != nil { + return nil, err + } + + return rpost, nil +} + +func handlePostEvents(post *model.Post, teamId string, triggerWebhooks bool) *model.AppError { + tchan := Srv.Store.Team().Get(teamId) + cchan := Srv.Store.Channel().Get(post.ChannelId, true) + uchan := Srv.Store.User().Get(post.UserId) + + var team *model.Team + if result := <-tchan; result.Err != nil { + return result.Err + } else { + team = result.Data.(*model.Team) + } + + var channel *model.Channel + if result := <-cchan; result.Err != nil { + return result.Err + } else { + channel = result.Data.(*model.Channel) + } + + var user *model.User + if result := <-uchan; result.Err != nil { + return result.Err + } else { + user = result.Data.(*model.User) + } + + if _, err := SendNotifications(post, team, channel, user); err != nil { + return err + } + + if triggerWebhooks { + go func() { + if err := handleWebhookEvents(post, team, channel, user); err != nil { + l4g.Error(err.Error()) + } + }() + } + + if channel.Type == model.CHANNEL_DIRECT { + go func() { + if err := MakeDirectChannelVisible(post.ChannelId); err != nil { + l4g.Error(err.Error()) + } + }() + } + + return nil +} + +var linkWithTextRegex *regexp.Regexp = regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`) + +// This method only parses and processes the attachments, +// all else should be set in the post which is passed +func parseSlackAttachment(post *model.Post, attachments interface{}) { + post.Type = model.POST_SLACK_ATTACHMENT + + if list, success := attachments.([]interface{}); success { + for i, aInt := range list { + attachment := aInt.(map[string]interface{}) + if aText, ok := attachment["text"].(string); ok { + aText = linkWithTextRegex.ReplaceAllString(aText, "[${2}](${1})") + attachment["text"] = aText + list[i] = attachment + } + if aText, ok := attachment["pretext"].(string); ok { + aText = linkWithTextRegex.ReplaceAllString(aText, "[${2}](${1})") + attachment["pretext"] = aText + list[i] = attachment + } + if fVal, ok := attachment["fields"]; ok { + if fields, ok := fVal.([]interface{}); ok { + // parse attachment field links into Markdown format + for j, fInt := range fields { + field := fInt.(map[string]interface{}) + if fValue, ok := field["value"].(string); ok { + fValue = linkWithTextRegex.ReplaceAllString(fValue, "[${2}](${1})") + field["value"] = fValue + fields[j] = field + } + } + attachment["fields"] = fields + list[i] = attachment + } + } + } + post.AddProp("attachments", list) + } +} + +func parseSlackLinksToMarkdown(text string) string { + return linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})") +} + +func SendEphemeralPost(teamId, userId string, post *model.Post) *model.Post { + post.Type = model.POST_EPHEMERAL + + // fill in fields which haven't been specified which have sensible defaults + if post.Id == "" { + post.Id = model.NewId() + } + if post.CreateAt == 0 { + post.CreateAt = model.GetMillis() + } + if post.Props == nil { + post.Props = model.StringInterface{} + } + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE, "", post.ChannelId, userId, nil) + message.Add("post", post.ToJson()) + + go Publish(message) + + return post +} + +func UpdatePost(post *model.Post) (*model.Post, *model.AppError) { + if utils.IsLicensed { + if *utils.Cfg.ServiceSettings.AllowEditPost == model.ALLOW_EDIT_POST_NEVER { + err := model.NewLocAppError("updatePost", "api.post.update_post.permissions_denied.app_error", nil, "") + err.StatusCode = http.StatusForbidden + return nil, err + } + } + + var oldPost *model.Post + if result := <-Srv.Store.Post().Get(post.Id); result.Err != nil { + return nil, result.Err + } else { + oldPost = result.Data.(*model.PostList).Posts[post.Id] + + if oldPost == nil { + err := model.NewLocAppError("updatePost", "api.post.update_post.find.app_error", nil, "id="+post.Id) + err.StatusCode = http.StatusBadRequest + return nil, err + } + + if oldPost.UserId != post.UserId { + err := model.NewLocAppError("updatePost", "api.post.update_post.permissions.app_error", nil, "oldUserId="+oldPost.UserId) + err.StatusCode = http.StatusBadRequest + return nil, err + } + + if oldPost.DeleteAt != 0 { + err := model.NewLocAppError("updatePost", "api.post.update_post.permissions_details.app_error", map[string]interface{}{"PostId": post.Id}, "") + err.StatusCode = http.StatusBadRequest + return nil, err + } + + if oldPost.IsSystemMessage() { + err := model.NewLocAppError("updatePost", "api.post.update_post.system_message.app_error", nil, "id="+post.Id) + err.StatusCode = http.StatusBadRequest + return nil, err + } + + if utils.IsLicensed { + if *utils.Cfg.ServiceSettings.AllowEditPost == model.ALLOW_EDIT_POST_TIME_LIMIT && model.GetMillis() > oldPost.CreateAt+int64(*utils.Cfg.ServiceSettings.PostEditTimeLimit*1000) { + err := model.NewLocAppError("updatePost", "api.post.update_post.permissions_time_limit.app_error", map[string]interface{}{"timeLimit": *utils.Cfg.ServiceSettings.PostEditTimeLimit}, "") + err.StatusCode = http.StatusBadRequest + return nil, err + } + } + } + + newPost := &model.Post{} + *newPost = *oldPost + + newPost.Message = post.Message + newPost.EditAt = model.GetMillis() + newPost.Hashtags, _ = model.ParseHashtags(post.Message) + + if result := <-Srv.Store.Post().Update(newPost, oldPost); result.Err != nil { + return nil, result.Err + } else { + rpost := result.Data.(*model.Post) + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POST_EDITED, "", rpost.ChannelId, "", nil) + message.Add("post", rpost.ToJson()) + + go Publish(message) + + InvalidateCacheForChannelPosts(rpost.ChannelId) + + return rpost, nil + } +} + +func GetPosts(channelId string, offset int, limit int) (*model.PostList, *model.AppError) { + if result := <-Srv.Store.Post().GetPosts(channelId, offset, limit, true); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.PostList), nil + } +} + +func GetPostsEtag(channelId string) string { + return (<-Srv.Store.Post().GetEtag(channelId, true)).Data.(string) +} + +func GetPostsSince(channelId string, time int64) (*model.PostList, *model.AppError) { + if result := <-Srv.Store.Post().GetPostsSince(channelId, time, true); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.PostList), nil + } +} + +func GetSinglePost(postId string) (*model.Post, *model.AppError) { + if result := <-Srv.Store.Post().GetSingle(postId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Post), nil + } +} + +func GetPostThread(postId string) (*model.PostList, *model.AppError) { + if result := <-Srv.Store.Post().Get(postId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.PostList), nil + } +} + +func GetFlaggedPosts(userId string, offset int, limit int) (*model.PostList, *model.AppError) { + if result := <-Srv.Store.Post().GetFlaggedPosts(userId, offset, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.PostList), nil + } +} + +func GetPermalinkPost(postId string, userId string) (*model.PostList, *model.AppError) { + if result := <-Srv.Store.Post().Get(postId); result.Err != nil { + return nil, result.Err + } else { + list := result.Data.(*model.PostList) + + if len(list.Order) != 1 { + return nil, model.NewLocAppError("getPermalinkTmp", "api.post_get_post_by_id.get.app_error", nil, "") + } + post := list.Posts[list.Order[0]] + + var channel *model.Channel + var err *model.AppError + if channel, err = GetChannel(post.ChannelId); err != nil { + return nil, err + } + + if err = JoinChannel(channel, userId); err != nil { + return nil, err + } + + return list, nil + } +} + +func GetPostsAroundPost(postId, channelId string, offset, limit int, before bool) (*model.PostList, *model.AppError) { + var pchan store.StoreChannel + if before { + pchan = Srv.Store.Post().GetPostsBefore(channelId, postId, limit, offset) + } else { + pchan = Srv.Store.Post().GetPostsAfter(channelId, postId, limit, offset) + } + + if result := <-pchan; result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.PostList), nil + } +} + +func DeletePost(postId string) (*model.Post, *model.AppError) { + if result := <-Srv.Store.Post().GetSingle(postId); result.Err != nil { + return nil, result.Err + } else { + post := result.Data.(*model.Post) + + if result := <-Srv.Store.Post().Delete(postId, model.GetMillis()); result.Err != nil { + return nil, result.Err + } + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POST_DELETED, "", post.ChannelId, "", nil) + message.Add("post", post.ToJson()) + + go Publish(message) + go DeletePostFiles(post) + go DeleteFlaggedPosts(post.Id) + + InvalidateCacheForChannelPosts(post.ChannelId) + + return post, nil + } +} + +func DeleteFlaggedPosts(postId string) { + if result := <-Srv.Store.Preference().DeleteCategoryAndName(model.PREFERENCE_CATEGORY_FLAGGED_POST, postId); result.Err != nil { + l4g.Warn(utils.T("api.post.delete_flagged_post.app_error.warn"), result.Err) + return + } +} + +func DeletePostFiles(post *model.Post) { + if len(post.FileIds) != 0 { + return + } + + if result := <-Srv.Store.FileInfo().DeleteForPost(post.Id); result.Err != nil { + l4g.Warn(utils.T("api.post.delete_post_files.app_error.warn"), post.Id, result.Err) + } +} + +func SearchPostsInTeam(terms string, userId string, teamId string, isOrSearch bool) (*model.PostList, *model.AppError) { + paramsList := model.ParseSearchParams(terms) + channels := []store.StoreChannel{} + + for _, params := range paramsList { + params.OrTerms = isOrSearch + // don't allow users to search for everything + if params.Terms != "*" { + channels = append(channels, Srv.Store.Post().Search(teamId, userId, params)) + } + } + + posts := &model.PostList{} + for _, channel := range channels { + if result := <-channel; result.Err != nil { + return nil, result.Err + } else { + data := result.Data.(*model.PostList) + posts.Extend(data) + } + } + + return posts, nil +} + +func GetFileInfosForPost(postId string) ([]*model.FileInfo, *model.AppError) { + pchan := Srv.Store.Post().Get(postId) + fchan := Srv.Store.FileInfo().GetForPost(postId) + + var infos []*model.FileInfo + if result := <-fchan; result.Err != nil { + return nil, result.Err + } else { + infos = result.Data.([]*model.FileInfo) + } + + if len(infos) == 0 { + // No FileInfos were returned so check if they need to be created for this post + var post *model.Post + if result := <-pchan; result.Err != nil { + return nil, result.Err + } else { + post = result.Data.(*model.PostList).Posts[postId] + } + + if len(post.Filenames) > 0 { + // The post has Filenames that need to be replaced with FileInfos + infos = MigrateFilenamesToFileInfos(post) + } + } + + return infos, nil +} + +func GetOpenGraphMetadata(url string) *opengraph.OpenGraph { + og := opengraph.NewOpenGraph() + + res, err := http.Get(url) + defer CloseBody(res) + if err != nil { + return og + } + + if err := og.ProcessHTML(res.Body); err != nil { + return og + } + + return og +} diff --git a/app/preference.go b/app/preference.go new file mode 100644 index 000000000..4e492c4a8 --- /dev/null +++ b/app/preference.go @@ -0,0 +1,16 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/platform/model" +) + +func GetPreferencesForUser(userId string) (model.Preferences, *model.AppError) { + if result := <-Srv.Store.Preference().GetAll(userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(model.Preferences), nil + } +} diff --git a/app/saml.go b/app/saml.go new file mode 100644 index 000000000..cc39d4540 --- /dev/null +++ b/app/saml.go @@ -0,0 +1,67 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "io" + "mime/multipart" + "net/http" + "os" + + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func GetSamlMetadata() (string, *model.AppError) { + samlInterface := einterfaces.GetSamlInterface() + + if samlInterface == nil { + err := model.NewLocAppError("GetSamlMetadata", "api.admin.saml.not_available.app_error", nil, "") + err.StatusCode = http.StatusNotImplemented + return "", err + } + + if result, err := samlInterface.GetMetadata(); err != nil { + return "", model.NewLocAppError("GetSamlMetadata", "api.admin.saml.metadata.app_error", nil, "err="+err.Message) + } else { + return result, nil + } +} + +func AddSamlCertificate(fileData *multipart.FileHeader) *model.AppError { + file, err := fileData.Open() + defer file.Close() + if err != nil { + return model.NewLocAppError("AddSamlCertificate", "api.admin.add_certificate.open.app_error", nil, err.Error()) + } + + out, err := os.Create(utils.FindDir("config") + fileData.Filename) + if err != nil { + return model.NewLocAppError("AddSamlCertificate", "api.admin.add_certificate.saving.app_error", nil, err.Error()) + } + defer out.Close() + + io.Copy(out, file) + return nil +} + +func RemoveSamlCertificate(filename string) *model.AppError { + if err := os.Remove(utils.FindConfigFile(filename)); err != nil { + return model.NewLocAppError("removeCertificate", "api.admin.remove_certificate.delete.app_error", + map[string]interface{}{"Filename": filename}, err.Error()) + } + + return nil +} + +func GetSamlCertificateStatus() map[string]interface{} { + status := make(map[string]interface{}) + + status["IdpCertificateFile"] = utils.FileExistsInConfigFolder(*utils.Cfg.SamlSettings.IdpCertificateFile) + status["PrivateKeyFile"] = utils.FileExistsInConfigFolder(*utils.Cfg.SamlSettings.PrivateKeyFile) + status["PublicCertificateFile"] = utils.FileExistsInConfigFolder(*utils.Cfg.SamlSettings.PublicCertificateFile) + + return status +} diff --git a/app/server.go b/app/server.go new file mode 100644 index 000000000..972c91ea3 --- /dev/null +++ b/app/server.go @@ -0,0 +1,217 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "crypto/tls" + "net" + "net/http" + "strings" + "time" + + l4g "github.com/alecthomas/log4go" + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" + "github.com/rsc/letsencrypt" + "github.com/tylerb/graceful" + "gopkg.in/throttled/throttled.v2" + "gopkg.in/throttled/throttled.v2/store/memstore" +) + +type Server struct { + Store store.Store + WebSocketRouter *WebSocketRouter + Router *mux.Router + GracefulServer *graceful.Server +} + +var allowedMethods []string = []string{ + "POST", + "GET", + "OPTIONS", + "PUT", + "PATCH", + "DELETE", +} + +type CorsWrapper struct { + router *mux.Router +} + +func (cw *CorsWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if len(*utils.Cfg.ServiceSettings.AllowCorsFrom) > 0 { + origin := r.Header.Get("Origin") + if *utils.Cfg.ServiceSettings.AllowCorsFrom == "*" || strings.Contains(*utils.Cfg.ServiceSettings.AllowCorsFrom, origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + + if r.Method == "OPTIONS" { + w.Header().Set( + "Access-Control-Allow-Methods", + strings.Join(allowedMethods, ", ")) + + w.Header().Set( + "Access-Control-Allow-Headers", + r.Header.Get("Access-Control-Request-Headers")) + } + } + } + + if r.Method == "OPTIONS" { + return + } + + cw.router.ServeHTTP(w, r) +} + +const TIME_TO_WAIT_FOR_CONNECTIONS_TO_CLOSE_ON_SERVER_SHUTDOWN = time.Second + +var Srv *Server + +func NewServer() { + l4g.Info(utils.T("api.server.new_server.init.info")) + + Srv = &Server{} +} + +func InitStores() { + Srv.Store = store.NewSqlStore() +} + +type VaryBy struct{} + +func (m *VaryBy) Key(r *http.Request) string { + return utils.GetIpAddress(r) +} + +func initalizeThrottledVaryBy() *throttled.VaryBy { + vary := throttled.VaryBy{} + + if utils.Cfg.RateLimitSettings.VaryByRemoteAddr { + vary.RemoteAddr = true + } + + if len(utils.Cfg.RateLimitSettings.VaryByHeader) > 0 { + vary.Headers = strings.Fields(utils.Cfg.RateLimitSettings.VaryByHeader) + + if utils.Cfg.RateLimitSettings.VaryByRemoteAddr { + l4g.Warn(utils.T("api.server.start_server.rate.warn")) + vary.RemoteAddr = false + } + } + + return &vary +} + +func redirectHTTPToHTTPS(w http.ResponseWriter, r *http.Request) { + if r.Host == "" { + http.Error(w, "Not Found", http.StatusNotFound) + } + + url := r.URL + url.Host = r.Host + url.Scheme = "https" + http.Redirect(w, r, url.String(), http.StatusFound) +} + +func StartServer() { + l4g.Info(utils.T("api.server.start_server.starting.info")) + + var handler http.Handler = &CorsWrapper{Srv.Router} + + if *utils.Cfg.RateLimitSettings.Enable { + l4g.Info(utils.T("api.server.start_server.rate.info")) + + store, err := memstore.New(utils.Cfg.RateLimitSettings.MemoryStoreSize) + if err != nil { + l4g.Critical(utils.T("api.server.start_server.rate_limiting_memory_store")) + return + } + + quota := throttled.RateQuota{ + MaxRate: throttled.PerSec(utils.Cfg.RateLimitSettings.PerSec), + MaxBurst: *utils.Cfg.RateLimitSettings.MaxBurst, + } + + rateLimiter, err := throttled.NewGCRARateLimiter(store, quota) + if err != nil { + l4g.Critical(utils.T("api.server.start_server.rate_limiting_rate_limiter")) + return + } + + httpRateLimiter := throttled.HTTPRateLimiter{ + RateLimiter: rateLimiter, + VaryBy: &VaryBy{}, + DeniedHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l4g.Error("%v: Denied due to throttling settings code=429 ip=%v", r.URL.Path, utils.GetIpAddress(r)) + throttled.DefaultDeniedHandler.ServeHTTP(w, r) + }), + } + + handler = httpRateLimiter.RateLimit(handler) + } + + Srv.GracefulServer = &graceful.Server{ + Timeout: TIME_TO_WAIT_FOR_CONNECTIONS_TO_CLOSE_ON_SERVER_SHUTDOWN, + Server: &http.Server{ + Addr: utils.Cfg.ServiceSettings.ListenAddress, + Handler: handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))(handler), + ReadTimeout: time.Duration(*utils.Cfg.ServiceSettings.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(*utils.Cfg.ServiceSettings.WriteTimeout) * time.Second, + }, + } + l4g.Info(utils.T("api.server.start_server.listening.info"), utils.Cfg.ServiceSettings.ListenAddress) + + if *utils.Cfg.ServiceSettings.Forward80To443 { + go func() { + listener, err := net.Listen("tcp", ":80") + if err != nil { + l4g.Error("Unable to setup forwarding") + return + } + defer listener.Close() + + http.Serve(listener, http.HandlerFunc(redirectHTTPToHTTPS)) + }() + } + + go func() { + var err error + if *utils.Cfg.ServiceSettings.ConnectionSecurity == model.CONN_SECURITY_TLS { + if *utils.Cfg.ServiceSettings.UseLetsEncrypt { + var m letsencrypt.Manager + m.CacheFile(*utils.Cfg.ServiceSettings.LetsEncryptCertificateCacheFile) + + tlsConfig := &tls.Config{ + GetCertificate: m.GetCertificate, + } + + tlsConfig.NextProtos = append(tlsConfig.NextProtos, "h2") + + err = Srv.GracefulServer.ListenAndServeTLSConfig(tlsConfig) + } else { + err = Srv.GracefulServer.ListenAndServeTLS(*utils.Cfg.ServiceSettings.TLSCertFile, *utils.Cfg.ServiceSettings.TLSKeyFile) + } + } else { + err = Srv.GracefulServer.ListenAndServe() + } + if err != nil { + l4g.Critical(utils.T("api.server.start_server.starting.critical"), err) + time.Sleep(time.Second) + } + }() +} + +func StopServer() { + + l4g.Info(utils.T("api.server.stop_server.stopping.info")) + + Srv.GracefulServer.Stop(TIME_TO_WAIT_FOR_CONNECTIONS_TO_CLOSE_ON_SERVER_SHUTDOWN) + Srv.Store.Close() + HubStop() + + l4g.Info(utils.T("api.server.stop_server.stopped.info")) +} diff --git a/app/session.go b/app/session.go new file mode 100644 index 000000000..83e5f343a --- /dev/null +++ b/app/session.go @@ -0,0 +1,180 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + + l4g "github.com/alecthomas/log4go" +) + +var sessionCache *utils.Cache = utils.NewLru(model.SESSION_CACHE_SIZE) + +func CreateSession(session *model.Session) (*model.Session, *model.AppError) { + if result := <-Srv.Store.Session().Save(session); result.Err != nil { + return nil, result.Err + } else { + session := result.Data.(*model.Session) + + AddSessionToCache(session) + + return session, nil + } +} + +func GetSession(token string) (*model.Session, *model.AppError) { + metrics := einterfaces.GetMetricsInterface() + + var session *model.Session + if ts, ok := sessionCache.Get(token); ok { + session = ts.(*model.Session) + if metrics != nil { + metrics.IncrementMemCacheHitCounter("Session") + } + } else { + if metrics != nil { + metrics.IncrementMemCacheMissCounter("Session") + } + } + + if session == nil { + if sessionResult := <-Srv.Store.Session().Get(token); sessionResult.Err != nil { + return nil, model.NewLocAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": sessionResult.Err.DetailedError}, "") + } else { + session = sessionResult.Data.(*model.Session) + + if session.IsExpired() || session.Token != token { + return nil, model.NewLocAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": sessionResult.Err.DetailedError}, "") + } else { + AddSessionToCache(session) + return session, nil + } + } + } + + if session == nil || session.IsExpired() { + return nil, model.NewLocAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token}, "") + } + + return session, nil +} + +func GetSessions(userId string) ([]*model.Session, *model.AppError) { + if result := <-Srv.Store.Session().GetSessions(userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.Session), nil + } +} + +func RevokeAllSessions(userId string) *model.AppError { + if result := <-Srv.Store.Session().GetSessions(userId); result.Err != nil { + return result.Err + } else { + sessions := result.Data.([]*model.Session) + + for _, session := range sessions { + if session.IsOAuth { + RevokeAccessToken(session.Token) + } else { + if result := <-Srv.Store.Session().Remove(session.Id); result.Err != nil { + return result.Err + } + } + + RevokeWebrtcToken(session.Id) + } + } + + ClearSessionCacheForUser(userId) + + return nil +} + +func ClearSessionCacheForUser(userId string) { + + ClearSessionCacheForUserSkipClusterSend(userId) + + if einterfaces.GetClusterInterface() != nil { + einterfaces.GetClusterInterface().ClearSessionCacheForUser(userId) + } +} + +func ClearSessionCacheForUserSkipClusterSend(userId string) { + keys := sessionCache.Keys() + + for _, key := range keys { + if ts, ok := sessionCache.Get(key); ok { + session := ts.(*model.Session) + if session.UserId == userId { + sessionCache.Remove(key) + } + } + } + + InvalidateWebConnSessionCacheForUser(userId) + +} + +func AddSessionToCache(session *model.Session) { + sessionCache.AddWithExpiresInSecs(session.Token, session, int64(*utils.Cfg.ServiceSettings.SessionCacheInMinutes*60)) +} + +func SessionCacheLength() int { + return sessionCache.Len() +} + +func RevokeSessionsForDeviceId(userId string, deviceId string, currentSessionId string) *model.AppError { + if result := <-Srv.Store.Session().GetSessions(userId); result.Err != nil { + return result.Err + } else { + sessions := result.Data.([]*model.Session) + for _, session := range sessions { + if session.DeviceId == deviceId && session.Id != currentSessionId { + l4g.Debug(utils.T("api.user.login.revoking.app_error"), session.Id, userId) + if err := RevokeSession(session); err != nil { + // Soft error so we still remove the other sessions + l4g.Error(err.Error()) + } + } + } + } + + return nil +} + +func RevokeSessionById(sessionId string) *model.AppError { + if result := <-Srv.Store.Session().Get(sessionId); result.Err != nil { + return result.Err + } else { + return RevokeSession(result.Data.(*model.Session)) + } +} + +func RevokeSession(session *model.Session) *model.AppError { + if session.IsOAuth { + if err := RevokeAccessToken(session.Token); err != nil { + return err + } + } else { + if result := <-Srv.Store.Session().Remove(session.Id); result.Err != nil { + return result.Err + } + } + + RevokeWebrtcToken(session.Id) + ClearSessionCacheForUser(session.UserId) + + return nil +} + +func AttachDeviceId(sessionId string, deviceId string, expiresAt int64) *model.AppError { + if result := <-Srv.Store.Session().UpdateDeviceId(sessionId, deviceId, expiresAt); result.Err != nil { + return result.Err + } + + return nil +} diff --git a/app/session_test.go b/app/session_test.go new file mode 100644 index 000000000..aea31cf86 --- /dev/null +++ b/app/session_test.go @@ -0,0 +1,31 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/platform/model" + "testing" +) + +func TestCache(t *testing.T) { + session := &model.Session{ + Id: model.NewId(), + Token: model.NewId(), + UserId: model.NewId(), + } + + sessionCache.AddWithExpiresInSecs(session.Token, session, 5*60) + + keys := sessionCache.Keys() + if len(keys) <= 0 { + t.Fatal("should have items") + } + + ClearSessionCacheForUser(session.UserId) + + rkeys := sessionCache.Keys() + if len(rkeys) != len(keys)-1 { + t.Fatal("should have one less") + } +} diff --git a/app/slackimport.go b/app/slackimport.go new file mode 100644 index 000000000..508803126 --- /dev/null +++ b/app/slackimport.go @@ -0,0 +1,631 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "mime/multipart" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +type SlackChannel struct { + Id string `json:"id"` + Name string `json:"name"` + Members []string `json:"members"` + Topic map[string]string `json:"topic"` + Purpose map[string]string `json:"purpose"` +} + +type SlackUser struct { + Id string `json:"id"` + Username string `json:"name"` + Profile map[string]string `json:"profile"` +} + +type SlackFile struct { + Id string `json:"id"` + Title string `json:"title"` +} + +type SlackPost struct { + User string `json:"user"` + BotId string `json:"bot_id"` + BotUsername string `json:"username"` + Text string `json:"text"` + TimeStamp string `json:"ts"` + Type string `json:"type"` + SubType string `json:"subtype"` + Comment *SlackComment `json:"comment"` + Upload bool `json:"upload"` + File *SlackFile `json:"file"` + Attachments []SlackAttachment `json:"attachments"` +} + +type SlackComment struct { + User string `json:"user"` + Comment string `json:"comment"` +} + +type SlackAttachment struct { + Id int `json:"id"` + Text string `json:"text"` + Pretext string `json:"pretext"` + Fields []map[string]interface{} `json:"fields"` +} + +func truncateRunes(s string, i int) string { + runes := []rune(s) + if len(runes) > i { + return string(runes[:i]) + } + return s +} + +func SlackConvertTimeStamp(ts string) int64 { + timeString := strings.SplitN(ts, ".", 2)[0] + + timeStamp, err := strconv.ParseInt(timeString, 10, 64) + if err != nil { + l4g.Warn(utils.T("api.slackimport.slack_convert_timestamp.bad.warn")) + return 1 + } + return timeStamp * 1000 // Convert to milliseconds +} + +func SlackConvertChannelName(channelName string) string { + newName := strings.Trim(channelName, "_-") + if len(newName) == 1 { + return "slack-channel-" + newName + } + + return newName +} + +func SlackParseChannels(data io.Reader) ([]SlackChannel, error) { + decoder := json.NewDecoder(data) + + var channels []SlackChannel + if err := decoder.Decode(&channels); err != nil { + l4g.Warn(utils.T("api.slackimport.slack_parse_channels.error")) + return channels, err + } + return channels, nil +} + +func SlackParseUsers(data io.Reader) ([]SlackUser, error) { + decoder := json.NewDecoder(data) + + var users []SlackUser + if err := decoder.Decode(&users); err != nil { + // This actually returns errors that are ignored. + // In this case it is erroring because of a null that Slack + // introduced. So we just return the users here. + return users, err + } + return users, nil +} + +func SlackParsePosts(data io.Reader) ([]SlackPost, error) { + decoder := json.NewDecoder(data) + + var posts []SlackPost + if err := decoder.Decode(&posts); err != nil { + l4g.Warn(utils.T("api.slackimport.slack_parse_posts.error")) + return posts, err + } + return posts, nil +} + +func SlackAddUsers(teamId string, slackusers []SlackUser, log *bytes.Buffer) map[string]*model.User { + // Log header + log.WriteString(utils.T("api.slackimport.slack_add_users.created")) + log.WriteString("===============\r\n\r\n") + + addedUsers := make(map[string]*model.User) + + // Need the team + var team *model.Team + if result := <-Srv.Store.Team().Get(teamId); result.Err != nil { + log.WriteString(utils.T("api.slackimport.slack_import.team_fail")) + return addedUsers + } else { + team = result.Data.(*model.Team) + } + + for _, sUser := range slackusers { + firstName := "" + lastName := "" + if name, ok := sUser.Profile["first_name"]; ok { + firstName = name + } + if name, ok := sUser.Profile["last_name"]; ok { + lastName = name + } + + email := sUser.Profile["email"] + + password := model.NewId() + + // Check for email conflict and use existing user if found + if result := <-Srv.Store.User().GetByEmail(email); result.Err == nil { + existingUser := result.Data.(*model.User) + addedUsers[sUser.Id] = existingUser + if err := JoinUserToTeam(team, addedUsers[sUser.Id]); err != nil { + log.WriteString(utils.T("api.slackimport.slack_add_users.merge_existing_failed", map[string]interface{}{"Email": existingUser.Email, "Username": existingUser.Username})) + } else { + log.WriteString(utils.T("api.slackimport.slack_add_users.merge_existing", map[string]interface{}{"Email": existingUser.Email, "Username": existingUser.Username})) + } + continue + } + + newUser := model.User{ + Username: sUser.Username, + FirstName: firstName, + LastName: lastName, + Email: email, + Password: password, + } + + if mUser := ImportUser(team, &newUser); mUser != nil { + addedUsers[sUser.Id] = mUser + log.WriteString(utils.T("api.slackimport.slack_add_users.email_pwd", map[string]interface{}{"Email": newUser.Email, "Password": password})) + } else { + log.WriteString(utils.T("api.slackimport.slack_add_users.unable_import", map[string]interface{}{"Username": sUser.Username})) + } + } + + return addedUsers +} + +func SlackAddBotUser(teamId string, log *bytes.Buffer) *model.User { + var team *model.Team + if result := <-Srv.Store.Team().Get(teamId); result.Err != nil { + log.WriteString(utils.T("api.slackimport.slack_import.team_fail")) + return nil + } else { + team = result.Data.(*model.Team) + } + + password := model.NewId() + username := "slackimportuser_" + model.NewId() + email := username + "@localhost" + + botUser := model.User{ + Username: username, + FirstName: "", + LastName: "", + Email: email, + Password: password, + } + + if mUser := ImportUser(team, &botUser); mUser != nil { + log.WriteString(utils.T("api.slackimport.slack_add_bot_user.email_pwd", map[string]interface{}{"Email": botUser.Email, "Password": password})) + return mUser + } else { + log.WriteString(utils.T("api.slackimport.slack_add_bot_user.unable_import", map[string]interface{}{"Username": username})) + return nil + } +} + +func SlackAddPosts(teamId string, channel *model.Channel, posts []SlackPost, users map[string]*model.User, uploads map[string]*zip.File, botUser *model.User) { + for _, sPost := range posts { + switch { + case sPost.Type == "message" && (sPost.SubType == "" || sPost.SubType == "file_share"): + if sPost.User == "" { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.without_user.debug")) + continue + } else if users[sPost.User] == nil { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.user_no_exists.debug"), sPost.User) + continue + } + newPost := model.Post{ + UserId: users[sPost.User].Id, + ChannelId: channel.Id, + Message: sPost.Text, + CreateAt: SlackConvertTimeStamp(sPost.TimeStamp), + } + if sPost.Upload { + if fileInfo, ok := SlackUploadFile(sPost, uploads, teamId, newPost.ChannelId, newPost.UserId); ok == true { + newPost.FileIds = append(newPost.FileIds, fileInfo.Id) + newPost.Message = sPost.File.Title + } + } + ImportPost(&newPost) + for _, fileId := range newPost.FileIds { + if result := <-Srv.Store.FileInfo().AttachToPost(fileId, newPost.Id); result.Err != nil { + l4g.Error(utils.T("api.slackimport.slack_add_posts.attach_files.error"), newPost.Id, newPost.FileIds, result.Err) + } + } + + case sPost.Type == "message" && sPost.SubType == "file_comment": + if sPost.Comment == nil { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.msg_no_comment.debug")) + continue + } else if sPost.Comment.User == "" { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.msg_no_usr.debug")) + continue + } else if users[sPost.Comment.User] == nil { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.user_no_exists.debug"), sPost.User) + continue + } + newPost := model.Post{ + UserId: users[sPost.Comment.User].Id, + ChannelId: channel.Id, + Message: sPost.Comment.Comment, + CreateAt: SlackConvertTimeStamp(sPost.TimeStamp), + } + ImportPost(&newPost) + case sPost.Type == "message" && sPost.SubType == "bot_message": + if botUser == nil { + l4g.Warn(utils.T("api.slackimport.slack_add_posts.bot_user_no_exists.warn")) + continue + } else if sPost.BotId == "" { + l4g.Warn(utils.T("api.slackimport.slack_add_posts.no_bot_id.warn")) + continue + } + + props := make(model.StringInterface) + props["override_username"] = sPost.BotUsername + if len(sPost.Attachments) > 0 { + var mAttachments []interface{} + for _, attachment := range sPost.Attachments { + mAttachments = append(mAttachments, map[string]interface{}{ + "text": attachment.Text, + "pretext": attachment.Pretext, + "fields": attachment.Fields, + }) + } + props["attachments"] = mAttachments + } + + post := &model.Post{ + UserId: botUser.Id, + ChannelId: channel.Id, + CreateAt: SlackConvertTimeStamp(sPost.TimeStamp), + Message: sPost.Text, + Type: model.POST_SLACK_ATTACHMENT, + } + + ImportIncomingWebhookPost(post, props) + case sPost.Type == "message" && (sPost.SubType == "channel_join" || sPost.SubType == "channel_leave"): + if sPost.User == "" { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.msg_no_usr.debug")) + continue + } else if users[sPost.User] == nil { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.user_no_exists.debug"), sPost.User) + continue + } + newPost := model.Post{ + UserId: users[sPost.User].Id, + ChannelId: channel.Id, + Message: sPost.Text, + CreateAt: SlackConvertTimeStamp(sPost.TimeStamp), + Type: model.POST_JOIN_LEAVE, + } + ImportPost(&newPost) + case sPost.Type == "message" && sPost.SubType == "me_message": + if sPost.User == "" { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.without_user.debug")) + continue + } else if users[sPost.User] == nil { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.user_no_exists.debug"), sPost.User) + continue + } + newPost := model.Post{ + UserId: users[sPost.User].Id, + ChannelId: channel.Id, + Message: "*" + sPost.Text + "*", + CreateAt: SlackConvertTimeStamp(sPost.TimeStamp), + } + ImportPost(&newPost) + case sPost.Type == "message" && sPost.SubType == "channel_topic": + if sPost.User == "" { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.msg_no_usr.debug")) + continue + } else if users[sPost.User] == nil { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.user_no_exists.debug"), sPost.User) + continue + } + newPost := model.Post{ + UserId: users[sPost.User].Id, + ChannelId: channel.Id, + Message: sPost.Text, + CreateAt: SlackConvertTimeStamp(sPost.TimeStamp), + Type: model.POST_HEADER_CHANGE, + } + ImportPost(&newPost) + case sPost.Type == "message" && sPost.SubType == "channel_purpose": + if sPost.User == "" { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.msg_no_usr.debug")) + continue + } else if users[sPost.User] == nil { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.user_no_exists.debug"), sPost.User) + continue + } + newPost := model.Post{ + UserId: users[sPost.User].Id, + ChannelId: channel.Id, + Message: sPost.Text, + CreateAt: SlackConvertTimeStamp(sPost.TimeStamp), + Type: model.POST_PURPOSE_CHANGE, + } + ImportPost(&newPost) + case sPost.Type == "message" && sPost.SubType == "channel_name": + if sPost.User == "" { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.msg_no_usr.debug")) + continue + } else if users[sPost.User] == nil { + l4g.Debug(utils.T("api.slackimport.slack_add_posts.user_no_exists.debug"), sPost.User) + continue + } + newPost := model.Post{ + UserId: users[sPost.User].Id, + ChannelId: channel.Id, + Message: sPost.Text, + CreateAt: SlackConvertTimeStamp(sPost.TimeStamp), + Type: model.POST_DISPLAYNAME_CHANGE, + } + ImportPost(&newPost) + default: + l4g.Warn(utils.T("api.slackimport.slack_add_posts.unsupported.warn"), sPost.Type, sPost.SubType) + } + } +} + +func SlackUploadFile(sPost SlackPost, uploads map[string]*zip.File, teamId string, channelId string, userId string) (*model.FileInfo, bool) { + if sPost.File != nil { + if file, ok := uploads[sPost.File.Id]; ok == true { + openFile, err := file.Open() + if err != nil { + l4g.Warn(utils.T("api.slackimport.slack_add_posts.upload_file_open_failed.warn", map[string]interface{}{"FileId": sPost.File.Id, "Error": err.Error()})) + return nil, false + } + defer openFile.Close() + + uploadedFile, err := ImportFile(openFile, teamId, channelId, userId, filepath.Base(file.Name)) + if err != nil { + l4g.Warn(utils.T("api.slackimport.slack_add_posts.upload_file_upload_failed.warn", map[string]interface{}{"FileId": sPost.File.Id, "Error": err.Error()})) + return nil, false + } + + return uploadedFile, true + } else { + l4g.Warn(utils.T("api.slackimport.slack_add_posts.upload_file_not_found.warn", map[string]interface{}{"FileId": sPost.File.Id})) + return nil, false + } + } else { + l4g.Warn(utils.T("api.slackimport.slack_add_posts.upload_file_not_in_json.warn")) + return nil, false + } +} + +func deactivateSlackBotUser(user *model.User) { + _, err := UpdateActive(user, false) + if err != nil { + l4g.Warn(utils.T("api.slackimport.slack_deactivate_bot_user.failed_to_deactivate", err)) + } +} + +func addSlackUsersToChannel(members []string, users map[string]*model.User, channel *model.Channel, log *bytes.Buffer) { + for _, member := range members { + if user, ok := users[member]; !ok { + log.WriteString(utils.T("api.slackimport.slack_add_channels.failed_to_add_user", map[string]interface{}{"Username": "?"})) + } else { + if _, err := AddUserToChannel(user, channel); err != nil { + log.WriteString(utils.T("api.slackimport.slack_add_channels.failed_to_add_user", map[string]interface{}{"Username": user.Username})) + } + } + } +} + +func SlackSanitiseChannelProperties(channel model.Channel) model.Channel { + if utf8.RuneCountInString(channel.DisplayName) > model.CHANNEL_DISPLAY_NAME_MAX_RUNES { + l4g.Warn("api.slackimport.slack_sanitise_channel_properties.display_name_too_long.warn", map[string]interface{}{"ChannelName": channel.DisplayName}) + channel.DisplayName = truncateRunes(channel.DisplayName, model.CHANNEL_DISPLAY_NAME_MAX_RUNES) + } + + if len(channel.Name) > model.CHANNEL_NAME_MAX_LENGTH { + l4g.Warn("api.slackimport.slack_sanitise_channel_properties.name_too_long.warn", map[string]interface{}{"ChannelName": channel.DisplayName}) + channel.Name = channel.Name[0:model.CHANNEL_NAME_MAX_LENGTH] + } + + if utf8.RuneCountInString(channel.Purpose) > model.CHANNEL_PURPOSE_MAX_RUNES { + l4g.Warn("api.slackimport.slack_sanitise_channel_properties.purpose_too_long.warn", map[string]interface{}{"ChannelName": channel.DisplayName}) + channel.Purpose = truncateRunes(channel.Purpose, model.CHANNEL_PURPOSE_MAX_RUNES) + } + + if utf8.RuneCountInString(channel.Header) > model.CHANNEL_HEADER_MAX_RUNES { + l4g.Warn("api.slackimport.slack_sanitise_channel_properties.header_too_long.warn", map[string]interface{}{"ChannelName": channel.DisplayName}) + channel.Header = truncateRunes(channel.Header, model.CHANNEL_HEADER_MAX_RUNES) + } + + return channel +} + +func SlackAddChannels(teamId string, slackchannels []SlackChannel, posts map[string][]SlackPost, users map[string]*model.User, uploads map[string]*zip.File, botUser *model.User, log *bytes.Buffer) map[string]*model.Channel { + // Write Header + log.WriteString(utils.T("api.slackimport.slack_add_channels.added")) + log.WriteString("=================\r\n\r\n") + + addedChannels := make(map[string]*model.Channel) + for _, sChannel := range slackchannels { + newChannel := model.Channel{ + TeamId: teamId, + Type: model.CHANNEL_OPEN, + DisplayName: sChannel.Name, + Name: SlackConvertChannelName(sChannel.Name), + Purpose: sChannel.Purpose["value"], + Header: sChannel.Topic["value"], + } + newChannel = SlackSanitiseChannelProperties(newChannel) + + var mChannel *model.Channel + if result := <-Srv.Store.Channel().GetByName(teamId, sChannel.Name); result.Err == nil { + // The channel already exists as an active channel. Merge with the existing one. + mChannel = result.Data.(*model.Channel) + log.WriteString(utils.T("api.slackimport.slack_add_channels.merge", map[string]interface{}{"DisplayName": newChannel.DisplayName})) + } else if result := <-Srv.Store.Channel().GetDeletedByName(teamId, sChannel.Name); result.Err == nil { + // The channel already exists but has been deleted. Generate a random string for the handle instead. + newChannel.Name = model.NewId() + newChannel = SlackSanitiseChannelProperties(newChannel) + } + + if mChannel == nil { + // Haven't found an existing channel to merge with. Try importing it as a new one. + mChannel = ImportChannel(&newChannel) + if mChannel == nil { + l4g.Warn(utils.T("api.slackimport.slack_add_channels.import_failed.warn"), newChannel.DisplayName) + log.WriteString(utils.T("api.slackimport.slack_add_channels.import_failed", map[string]interface{}{"DisplayName": newChannel.DisplayName})) + continue + } + } + + addSlackUsersToChannel(sChannel.Members, users, mChannel, log) + log.WriteString(newChannel.DisplayName + "\r\n") + addedChannels[sChannel.Id] = mChannel + SlackAddPosts(teamId, mChannel, posts[sChannel.Name], users, uploads, botUser) + } + + return addedChannels +} + +func SlackConvertUserMentions(users []SlackUser, posts map[string][]SlackPost) map[string][]SlackPost { + var regexes = make(map[string]*regexp.Regexp, len(users)) + for _, user := range users { + r, err := regexp.Compile("<@" + user.Id + `(\|` + user.Username + ")?>") + if err != nil { + l4g.Warn(utils.T("api.slackimport.slack_convert_user_mentions.compile_regexp_failed.warn"), user.Id, user.Username) + continue + } + regexes["@"+user.Username] = r + } + + // Special cases. + regexes["@here"], _ = regexp.Compile(`<!here\|@here>`) + regexes["@channel"], _ = regexp.Compile("<!channel>") + regexes["@all"], _ = regexp.Compile("<!everyone>") + + for channelName, channelPosts := range posts { + for postIdx, post := range channelPosts { + for mention, r := range regexes { + post.Text = r.ReplaceAllString(post.Text, mention) + posts[channelName][postIdx] = post + } + } + } + + return posts +} + +func SlackConvertChannelMentions(channels []SlackChannel, posts map[string][]SlackPost) map[string][]SlackPost { + var regexes = make(map[string]*regexp.Regexp, len(channels)) + for _, channel := range channels { + r, err := regexp.Compile("<#" + channel.Id + `(\|` + channel.Name + ")?>") + if err != nil { + l4g.Warn(utils.T("api.slackimport.slack_convert_channel_mentions.compile_regexp_failed.warn"), channel.Id, channel.Name) + continue + } + regexes["~"+channel.Name] = r + } + + for channelName, channelPosts := range posts { + for postIdx, post := range channelPosts { + for channelReplace, r := range regexes { + post.Text = r.ReplaceAllString(post.Text, channelReplace) + posts[channelName][postIdx] = post + } + } + } + + return posts +} + +func SlackConvertPostsMarkup(posts map[string][]SlackPost) map[string][]SlackPost { + // Convert URLs in Slack's format to Markdown format. + regex := regexp.MustCompile(`<([^|<>]+)\|([^|<>]+)>`) + + for channelName, channelPosts := range posts { + for postIdx, post := range channelPosts { + posts[channelName][postIdx].Text = regex.ReplaceAllString(post.Text, "[$2]($1)") + } + } + + return posts +} + +func SlackImport(fileData multipart.File, fileSize int64, teamID string) (*model.AppError, *bytes.Buffer) { + // Create log file + log := bytes.NewBufferString(utils.T("api.slackimport.slack_import.log")) + + zipreader, err := zip.NewReader(fileData, fileSize) + if err != nil || zipreader.File == nil { + log.WriteString(utils.T("api.slackimport.slack_import.zip.app_error")) + return model.NewLocAppError("SlackImport", "api.slackimport.slack_import.zip.app_error", nil, err.Error()), log + } + + var channels []SlackChannel + var users []SlackUser + posts := make(map[string][]SlackPost) + uploads := make(map[string]*zip.File) + for _, file := range zipreader.File { + reader, err := file.Open() + if err != nil { + log.WriteString(utils.T("api.slackimport.slack_import.open.app_error", map[string]interface{}{"Filename": file.Name})) + return model.NewLocAppError("SlackImport", "api.slackimport.slack_import.open.app_error", map[string]interface{}{"Filename": file.Name}, err.Error()), log + } + if file.Name == "channels.json" { + channels, _ = SlackParseChannels(reader) + } else if file.Name == "users.json" { + users, _ = SlackParseUsers(reader) + } else { + spl := strings.Split(file.Name, "/") + if len(spl) == 2 && strings.HasSuffix(spl[1], ".json") { + newposts, _ := SlackParsePosts(reader) + channel := spl[0] + if _, ok := posts[channel]; ok == false { + posts[channel] = newposts + } else { + posts[channel] = append(posts[channel], newposts...) + } + } else if len(spl) == 3 && spl[0] == "__uploads" { + uploads[spl[1]] = file + } + } + } + + posts = SlackConvertUserMentions(users, posts) + posts = SlackConvertChannelMentions(channels, posts) + posts = SlackConvertPostsMarkup(posts) + + addedUsers := SlackAddUsers(teamID, users, log) + botUser := SlackAddBotUser(teamID, log) + + SlackAddChannels(teamID, channels, posts, addedUsers, uploads, botUser, log) + + if botUser != nil { + deactivateSlackBotUser(botUser) + } + + InvalidateAllCaches() + + log.WriteString(utils.T("api.slackimport.slack_import.notes")) + log.WriteString("=======\r\n\r\n") + + log.WriteString(utils.T("api.slackimport.slack_import.note1")) + log.WriteString(utils.T("api.slackimport.slack_import.note2")) + log.WriteString(utils.T("api.slackimport.slack_import.note3")) + + return nil, log +} diff --git a/app/slackimport_test.go b/app/slackimport_test.go new file mode 100644 index 000000000..3389c5217 --- /dev/null +++ b/app/slackimport_test.go @@ -0,0 +1,240 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/platform/model" + "os" + "strings" + "testing" +) + +func TestSlackConvertTimeStamp(t *testing.T) { + + testTimeStamp := "1469785419.000033" + + result := SlackConvertTimeStamp(testTimeStamp) + + if result != 1469785419000 { + t.Fatalf("Unexpected timestamp value %v returned.", result) + } +} + +func TestSlackConvertChannelName(t *testing.T) { + var testData = []struct { + input string + output string + }{ + {"test-channel", "test-channel"}, + {"_test_channel_", "test_channel"}, + {"__test", "test"}, + {"-t", "slack-channel-t"}, + {"a", "slack-channel-a"}, + } + + for _, td := range testData { + if td.output != SlackConvertChannelName(td.input) { + t.Fatalf("Did not convert channel name correctly: %v", td.input) + } + } +} + +func TestSlackConvertUserMentions(t *testing.T) { + users := []SlackUser{ + {Id: "U00000A0A", Username: "firstuser"}, + {Id: "U00000B1B", Username: "seconduser"}, + } + + posts := map[string][]SlackPost{ + "test-channel": { + { + Text: "<!channel>: Hi guys.", + }, + { + Text: "Calling <!here|@here>.", + }, + { + Text: "Yo <!everyone>.", + }, + { + Text: "Regular user test <@U00000B1B|seconduser> and <@U00000A0A>.", + }, + }, + } + + expectedPosts := map[string][]SlackPost{ + "test-channel": { + { + Text: "@channel: Hi guys.", + }, + { + Text: "Calling @here.", + }, + { + Text: "Yo @all.", + }, + { + Text: "Regular user test @seconduser and @firstuser.", + }, + }, + } + + convertedPosts := SlackConvertUserMentions(users, posts) + + for channelName, channelPosts := range convertedPosts { + for postIdx, post := range channelPosts { + if post.Text != expectedPosts[channelName][postIdx].Text { + t.Fatalf("Converted post text not as expected: %v", post.Text) + } + } + } +} + +func TestSlackConvertChannelMentions(t *testing.T) { + channels := []SlackChannel{ + {Id: "C000AA00A", Name: "one"}, + {Id: "C000BB11B", Name: "two"}, + } + + posts := map[string][]SlackPost{ + "test-channel": { + { + Text: "Go to <#C000AA00A>.", + }, + { + User: "U00000A0A", + Text: "Try <#C000BB11B|two> for this.", + }, + }, + } + + expectedPosts := map[string][]SlackPost{ + "test-channel": { + { + Text: "Go to ~one.", + }, + { + Text: "Try ~two for this.", + }, + }, + } + + convertedPosts := SlackConvertChannelMentions(channels, posts) + + for channelName, channelPosts := range convertedPosts { + for postIdx, post := range channelPosts { + if post.Text != expectedPosts[channelName][postIdx].Text { + t.Fatalf("Converted post text not as expected: %v", post.Text) + } + } + } + +} + +func TestSlackParseChannels(t *testing.T) { + file, err := os.Open("../tests/slack-import-test-channels.json") + if err != nil { + t.Fatalf("Failed to open data file: %v", err) + } + + channels, err := SlackParseChannels(file) + if err != nil { + t.Fatalf("Error occurred parsing channels: %v", err) + } + + if len(channels) != 6 { + t.Fatalf("Unexpected number of channels: %v", len(channels)) + } +} + +func TestSlackParseUsers(t *testing.T) { + file, err := os.Open("../tests/slack-import-test-users.json") + if err != nil { + t.Fatalf("Failed to open data file: %v", err) + } + + users, err := SlackParseUsers(file) + if err != nil { + t.Fatalf("Error occurred parsing users: %v", err) + } + + if len(users) != 11 { + t.Fatalf("Unexpected number of users: %v", len(users)) + } +} + +func TestSlackParsePosts(t *testing.T) { + file, err := os.Open("../tests/slack-import-test-posts.json") + if err != nil { + t.Fatalf("Failed to open data file: %v", err) + } + + posts, err := SlackParsePosts(file) + if err != nil { + t.Fatalf("Error occurred parsing posts: %v", err) + } + + if len(posts) != 8 { + t.Fatalf("Unexpected number of posts: %v", len(posts)) + } +} + +func TestSlackSanitiseChannelProperties(t *testing.T) { + c1 := model.Channel{ + DisplayName: "display-name", + Name: "name", + Purpose: "The channel purpose", + Header: "The channel header", + } + + c1s := SlackSanitiseChannelProperties(c1) + if c1.DisplayName != c1s.DisplayName || c1.Name != c1s.Name || c1.Purpose != c1s.Purpose || c1.Header != c1s.Header { + t.Fatalf("Unexpected alterations to the channel properties.") + } + + c2 := model.Channel{ + DisplayName: strings.Repeat("abcdefghij", 7), + Name: strings.Repeat("abcdefghij", 7), + Purpose: strings.Repeat("0123456789", 30), + Header: strings.Repeat("0123456789", 120), + } + + c2s := SlackSanitiseChannelProperties(c2) + if c2s.DisplayName != strings.Repeat("abcdefghij", 6)+"abcd" { + t.Fatalf("Unexpected alterations to the channel properties: %v", c2s.DisplayName) + } + + if c2s.Name != strings.Repeat("abcdefghij", 6)+"abcd" { + t.Fatalf("Unexpected alterations to the channel properties: %v", c2s.Name) + } + + if c2s.Purpose != strings.Repeat("0123456789", 25) { + t.Fatalf("Unexpected alterations to the channel properties: %v", c2s.Purpose) + } + + if c2s.Header != strings.Repeat("0123456789", 102)+"0123" { + t.Fatalf("Unexpected alterations to the channel properties: %v", c2s.Header) + } +} + +func TestSlackConvertPostsMarkup(t *testing.T) { + input := make(map[string][]SlackPost) + input["test"] = []SlackPost{ + { + Text: "This message contains a link to <https://google.com|Google>.", + }, + { + Text: "This message contains a mailto link to <mailto:me@example.com|me@example.com> in it.", + }, + } + + output := SlackConvertPostsMarkup(input) + + if output["test"][0].Text != "This message contains a link to [Google](https://google.com)." { + t.Fatalf("Unexpected message after markup translation: %v", output["test"][0].Text) + } + if output["test"][1].Text != "This message contains a mailto link to [me@example.com](mailto:me@example.com) in it." { + t.Fatalf("Unexpected message after markup translation: %v", output["test"][0].Text) + } +} diff --git a/app/status.go b/app/status.go new file mode 100644 index 000000000..98cdb0dc0 --- /dev/null +++ b/app/status.go @@ -0,0 +1,255 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + l4g "github.com/alecthomas/log4go" + + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +var statusCache *utils.Cache = utils.NewLru(model.STATUS_CACHE_SIZE) + +func ClearStatusCache() { + statusCache.Purge() +} + +func AddStatusCacheSkipClusterSend(status *model.Status) { + statusCache.Add(status.UserId, status) +} + +func AddStatusCache(status *model.Status) { + AddStatusCacheSkipClusterSend(status) + + if einterfaces.GetClusterInterface() != nil { + einterfaces.GetClusterInterface().UpdateStatus(status) + } +} + +func GetAllStatuses() map[string]*model.Status { + userIds := statusCache.Keys() + statusMap := map[string]*model.Status{} + + for _, userId := range userIds { + if id, ok := userId.(string); !ok { + continue + } else { + status := GetStatusFromCache(id) + if status != nil { + statusMap[id] = status + } + } + } + + return statusMap +} + +func GetStatusesByIds(userIds []string) (map[string]interface{}, *model.AppError) { + statusMap := map[string]interface{}{} + metrics := einterfaces.GetMetricsInterface() + + missingUserIds := []string{} + for _, userId := range userIds { + if result, ok := statusCache.Get(userId); ok { + statusMap[userId] = result.(*model.Status).Status + if metrics != nil { + metrics.IncrementMemCacheHitCounter("Status") + } + } else { + missingUserIds = append(missingUserIds, userId) + if metrics != nil { + metrics.IncrementMemCacheMissCounter("Status") + } + } + } + + if len(missingUserIds) > 0 { + if result := <-Srv.Store.Status().GetByIds(missingUserIds); result.Err != nil { + return nil, result.Err + } else { + statuses := result.Data.([]*model.Status) + + for _, s := range statuses { + AddStatusCache(s) + statusMap[s.UserId] = s.Status + } + } + } + + // For the case where the user does not have a row in the Status table and cache + for _, userId := range missingUserIds { + if _, ok := statusMap[userId]; !ok { + statusMap[userId] = model.STATUS_OFFLINE + } + } + + return statusMap, nil +} + +func SetStatusOnline(userId string, sessionId string, manual bool) { + broadcast := false + + var oldStatus string = model.STATUS_OFFLINE + var oldTime int64 = 0 + var oldManual bool = false + var status *model.Status + var err *model.AppError + + if status, err = GetStatus(userId); err != nil { + status = &model.Status{userId, model.STATUS_ONLINE, false, model.GetMillis(), ""} + broadcast = true + } else { + if status.Manual && !manual { + return // manually set status always overrides non-manual one + } + + if status.Status != model.STATUS_ONLINE { + broadcast = true + } + + oldStatus = status.Status + oldTime = status.LastActivityAt + oldManual = status.Manual + + status.Status = model.STATUS_ONLINE + status.Manual = false // for "online" there's no manual setting + status.LastActivityAt = model.GetMillis() + } + + 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.STATUS_MIN_UPDATE_TIME { + achan := Srv.Store.Session().UpdateLastActivityAt(sessionId, status.LastActivityAt) + + var schan store.StoreChannel + if broadcast { + schan = Srv.Store.Status().SaveOrUpdate(status) + } else { + schan = Srv.Store.Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt) + } + + if result := <-achan; result.Err != nil { + l4g.Error(utils.T("api.status.last_activity.error"), userId, sessionId, result.Err) + } + + if result := <-schan; result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } + } + + if broadcast { + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) + event.Add("status", model.STATUS_ONLINE) + event.Add("user_id", status.UserId) + go Publish(event) + } +} + +func SetStatusOffline(userId string, manual bool) { + status, err := GetStatus(userId) + if err == nil && status.Manual && !manual { + return // manually set status always overrides non-manual one + } + + status = &model.Status{userId, model.STATUS_OFFLINE, manual, model.GetMillis(), ""} + + AddStatusCache(status) + + if result := <-Srv.Store.Status().SaveOrUpdate(status); result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } + + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) + event.Add("status", model.STATUS_OFFLINE) + event.Add("user_id", status.UserId) + go Publish(event) +} + +func SetStatusAwayIfNeeded(userId string, manual bool) { + status, err := GetStatus(userId) + + if err != nil { + status = &model.Status{userId, model.STATUS_OFFLINE, manual, 0, ""} + } + + if !manual && status.Manual { + return // manually set status always overrides non-manual one + } + + if !manual { + if status.Status == model.STATUS_AWAY { + return + } + + if !IsUserAway(status.LastActivityAt) { + return + } + } + + status.Status = model.STATUS_AWAY + status.Manual = manual + status.ActiveChannel = "" + + AddStatusCache(status) + + if result := <-Srv.Store.Status().SaveOrUpdate(status); result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } + + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) + event.Add("status", model.STATUS_AWAY) + event.Add("user_id", status.UserId) + go Publish(event) +} + +func GetStatusFromCache(userId string) *model.Status { + if result, ok := statusCache.Get(userId); ok { + status := result.(*model.Status) + statusCopy := &model.Status{} + *statusCopy = *status + return statusCopy + } + + return nil +} + +func GetStatus(userId string) (*model.Status, *model.AppError) { + status := GetStatusFromCache(userId) + if status != nil { + return status, nil + } + + if result := <-Srv.Store.Status().Get(userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Status), nil + } +} + +func IsUserAway(lastActivityAt int64) bool { + return model.GetMillis()-lastActivityAt >= *utils.Cfg.TeamSettings.UserStatusAwayTimeout*1000 +} + +func DoesStatusAllowPushNotification(user *model.User, status *model.Status, channelId string) bool { + props := user.NotifyProps + + if props["push"] == "none" { + return false + } + + if pushStatus, ok := props["push_status"]; (pushStatus == model.STATUS_ONLINE || !ok) && (status.ActiveChannel != channelId || model.GetMillis()-status.LastActivityAt > model.STATUS_CHANNEL_TIMEOUT) { + return true + } else if pushStatus == model.STATUS_AWAY && (status.Status == model.STATUS_AWAY || status.Status == model.STATUS_OFFLINE) { + return true + } else if pushStatus == model.STATUS_OFFLINE && status.Status == model.STATUS_OFFLINE { + return true + } + + return false +} diff --git a/app/team.go b/app/team.go new file mode 100644 index 000000000..aabdc0bfd --- /dev/null +++ b/app/team.go @@ -0,0 +1,563 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + l4g "github.com/alecthomas/log4go" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func CreateTeam(team *model.Team) (*model.Team, *model.AppError) { + if result := <-Srv.Store.Team().Save(team); result.Err != nil { + return nil, result.Err + } else { + rteam := result.Data.(*model.Team) + + if _, err := CreateDefaultChannels(rteam.Id); err != nil { + return nil, err + } + + return rteam, nil + } +} + +func CreateTeamWithUser(team *model.Team, userId string) (*model.Team, *model.AppError) { + var user *model.User + var err *model.AppError + if user, err = GetUser(userId); err != nil { + return nil, err + } else { + team.Email = user.Email + } + + if !isTeamEmailAllowed(user) { + return nil, model.NewLocAppError("isTeamEmailAllowed", "api.team.is_team_creation_allowed.domain.app_error", nil, "") + } + + var rteam *model.Team + if rteam, err = CreateTeam(team); err != nil { + return nil, err + } + + if err = JoinUserToTeam(rteam, user); err != nil { + return nil, err + } + + return rteam, nil +} + +func isTeamEmailAllowed(user *model.User) bool { + email := strings.ToLower(user.Email) + + if len(user.AuthService) > 0 && len(*user.AuthData) > 0 { + return true + } + + // 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 + domains := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(strings.Replace(utils.Cfg.TeamSettings.RestrictCreationToDomains, "@", " ", -1), ",", " ", -1)))) + + matched := false + for _, d := range domains { + if strings.HasSuffix(email, "@"+d) { + matched = true + break + } + } + + if len(utils.Cfg.TeamSettings.RestrictCreationToDomains) > 0 && !matched { + return false + } + + return true +} + +func UpdateTeam(team *model.Team) (*model.Team, *model.AppError) { + var oldTeam *model.Team + var err *model.AppError + if oldTeam, err = GetTeam(team.Id); err != nil { + return nil, err + } + + oldTeam.DisplayName = team.DisplayName + oldTeam.Description = team.Description + oldTeam.InviteId = team.InviteId + oldTeam.AllowOpenInvite = team.AllowOpenInvite + oldTeam.CompanyName = team.CompanyName + oldTeam.AllowedDomains = team.AllowedDomains + + if result := <-Srv.Store.Team().Update(oldTeam); result.Err != nil { + return nil, result.Err + } + + oldTeam.Sanitize() + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_UPDATE_TEAM, "", "", "", nil) + message.Add("team", oldTeam.ToJson()) + go Publish(message) + + return oldTeam, nil +} + +func UpdateTeamMemberRoles(teamId string, userId string, newRoles string) (*model.TeamMember, *model.AppError) { + var member *model.TeamMember + if result := <-Srv.Store.Team().GetTeamsForUser(userId); result.Err != nil { + return nil, result.Err + } else { + members := result.Data.([]*model.TeamMember) + for _, m := range members { + if m.TeamId == teamId { + member = m + } + } + } + + if member == nil { + err := model.NewLocAppError("UpdateTeamMemberRoles", "api.team.update_member_roles.not_a_member", nil, "userId="+userId+" teamId="+teamId) + err.StatusCode = http.StatusBadRequest + return nil, err + } + + member.Roles = newRoles + + if result := <-Srv.Store.Team().UpdateMember(member); result.Err != nil { + return nil, result.Err + } + + ClearSessionCacheForUser(userId) + + return member, nil +} + +func AddUserToTeam(teamId string, userId string) (*model.Team, *model.AppError) { + tchan := Srv.Store.Team().Get(teamId) + uchan := Srv.Store.User().Get(userId) + + var team *model.Team + if result := <-tchan; result.Err != nil { + return nil, result.Err + } else { + team = result.Data.(*model.Team) + } + + var user *model.User + if result := <-uchan; result.Err != nil { + return nil, result.Err + } else { + user = result.Data.(*model.User) + } + + if err := JoinUserToTeam(team, user); err != nil { + return nil, err + } + + return team, nil +} + +func AddUserToTeamByTeamId(teamId string, user *model.User) *model.AppError { + if result := <-Srv.Store.Team().Get(teamId); result.Err != nil { + return result.Err + } else { + return JoinUserToTeam(result.Data.(*model.Team), user) + } +} + +func AddUserToTeamByHash(userId string, hash string, data string) (*model.Team, *model.AppError) { + props := model.MapFromJson(strings.NewReader(data)) + + if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.EmailSettings.InviteSalt)) { + return nil, model.NewLocAppError("JoinUserToTeamByHash", "api.user.create_user.signup_link_invalid.app_error", nil, "") + } + + t, timeErr := strconv.ParseInt(props["time"], 10, 64) + if timeErr != nil || model.GetMillis()-t > 1000*60*60*48 { // 48 hours + return nil, model.NewLocAppError("JoinUserToTeamByHash", "api.user.create_user.signup_link_expired.app_error", nil, "") + } + + tchan := Srv.Store.Team().Get(props["id"]) + uchan := Srv.Store.User().Get(userId) + + var team *model.Team + if result := <-tchan; result.Err != nil { + return nil, result.Err + } else { + team = result.Data.(*model.Team) + } + + var user *model.User + if result := <-uchan; result.Err != nil { + return nil, result.Err + } else { + user = result.Data.(*model.User) + } + + if err := JoinUserToTeam(team, user); err != nil { + return nil, err + } + + return team, nil +} + +func AddUserToTeamByInviteId(inviteId string, userId string) (*model.Team, *model.AppError) { + tchan := Srv.Store.Team().GetByInviteId(inviteId) + uchan := Srv.Store.User().Get(userId) + + var team *model.Team + if result := <-tchan; result.Err != nil { + return nil, result.Err + } else { + team = result.Data.(*model.Team) + } + + var user *model.User + if result := <-uchan; result.Err != nil { + return nil, result.Err + } else { + user = result.Data.(*model.User) + } + + if err := JoinUserToTeam(team, user); err != nil { + return nil, err + } + + return team, nil +} + +func JoinUserToTeam(team *model.Team, user *model.User) *model.AppError { + + tm := &model.TeamMember{ + TeamId: team.Id, + UserId: user.Id, + Roles: model.ROLE_TEAM_USER.Id, + } + + channelRole := model.ROLE_CHANNEL_USER.Id + + if team.Email == user.Email { + tm.Roles = model.ROLE_TEAM_USER.Id + " " + model.ROLE_TEAM_ADMIN.Id + channelRole = model.ROLE_CHANNEL_USER.Id + " " + model.ROLE_CHANNEL_ADMIN.Id + } + + if etmr := <-Srv.Store.Team().GetMember(team.Id, user.Id); etmr.Err == nil { + // Membership alredy exists. Check if deleted and and update, otherwise do nothing + rtm := etmr.Data.(*model.TeamMember) + + // Do nothing if already added + if rtm.DeleteAt == 0 { + return nil + } + + if tmr := <-Srv.Store.Team().UpdateMember(tm); tmr.Err != nil { + return tmr.Err + } + } else { + // Membership appears to be missing. Lets try to add. + if tmr := <-Srv.Store.Team().SaveMember(tm); tmr.Err != nil { + return tmr.Err + } + } + + if uua := <-Srv.Store.User().UpdateUpdateAt(user.Id); uua.Err != nil { + return uua.Err + } + + // Soft error if there is an issue joining the default channels + if err := JoinDefaultChannels(team.Id, user, channelRole); err != nil { + l4g.Error(utils.T("api.user.create_user.joining.error"), user.Id, team.Id, err) + } + + ClearSessionCacheForUser(user.Id) + InvalidateCacheForUser(user.Id) + + return nil +} + +func GetTeam(teamId string) (*model.Team, *model.AppError) { + if result := <-Srv.Store.Team().Get(teamId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Team), nil + } +} + +func GetTeamByName(name string) (*model.Team, *model.AppError) { + if result := <-Srv.Store.Team().GetByName(name); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Team), nil + } +} + +func GetTeamByInviteId(inviteId string) (*model.Team, *model.AppError) { + if result := <-Srv.Store.Team().GetByInviteId(inviteId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.Team), nil + } +} + +func GetAllTeams() ([]*model.Team, *model.AppError) { + if result := <-Srv.Store.Team().GetAll(); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.Team), nil + } +} + +func GetAllOpenTeams() ([]*model.Team, *model.AppError) { + if result := <-Srv.Store.Team().GetAllTeamListing(); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.Team), nil + } +} + +func GetTeamsForUser(userId string) ([]*model.Team, *model.AppError) { + if result := <-Srv.Store.Team().GetTeamsByUserId(userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.Team), nil + } +} + +func GetTeamMember(teamId, userId string) (*model.TeamMember, *model.AppError) { + if result := <-Srv.Store.Team().GetMember(teamId, userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.TeamMember), nil + } +} + +func GetTeamMembersForUser(userId string) ([]*model.TeamMember, *model.AppError) { + if result := <-Srv.Store.Team().GetTeamsForUser(userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.TeamMember), nil + } +} + +func GetTeamMembers(teamId string, offset int, limit int) ([]*model.TeamMember, *model.AppError) { + if result := <-Srv.Store.Team().GetMembers(teamId, offset, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.TeamMember), nil + } +} + +func GetTeamMembersByIds(teamId string, userIds []string) ([]*model.TeamMember, *model.AppError) { + if result := <-Srv.Store.Team().GetMembersByIds(teamId, userIds); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.TeamMember), nil + } +} + +func RemoveUserFromTeam(teamId string, userId string) *model.AppError { + tchan := Srv.Store.Team().Get(teamId) + uchan := Srv.Store.User().Get(userId) + + var team *model.Team + if result := <-tchan; result.Err != nil { + return result.Err + } else { + team = result.Data.(*model.Team) + } + + var user *model.User + if result := <-uchan; result.Err != nil { + return result.Err + } else { + user = result.Data.(*model.User) + } + + if err := LeaveTeam(team, user); err != nil { + return err + } + + return nil +} + +func LeaveTeam(team *model.Team, user *model.User) *model.AppError { + var teamMember *model.TeamMember + var err *model.AppError + + if teamMember, err = GetTeamMember(team.Id, user.Id); err != nil { + return model.NewLocAppError("LeaveTeam", "api.team.remove_user_from_team.missing.app_error", nil, err.Error()) + } + + var channelList *model.ChannelList + + if result := <-Srv.Store.Channel().GetChannels(team.Id, user.Id); result.Err != nil { + if result.Err.Id == "store.sql_channel.get_channels.not_found.app_error" { + channelList = &model.ChannelList{} + } else { + return result.Err + } + + } else { + channelList = result.Data.(*model.ChannelList) + } + + for _, channel := range *channelList { + if channel.Type != model.CHANNEL_DIRECT { + InvalidateCacheForChannel(channel.Id) + if result := <-Srv.Store.Channel().RemoveMember(channel.Id, user.Id); result.Err != nil { + return result.Err + } + } + } + + // Send the websocket message before we actually do the remove so the user being removed gets it. + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_LEAVE_TEAM, team.Id, "", "", nil) + message.Add("user_id", user.Id) + message.Add("team_id", team.Id) + Publish(message) + + teamMember.Roles = "" + teamMember.DeleteAt = model.GetMillis() + + if result := <-Srv.Store.Team().UpdateMember(teamMember); result.Err != nil { + return result.Err + } + + if uua := <-Srv.Store.User().UpdateUpdateAt(user.Id); uua.Err != nil { + return uua.Err + } + + // delete the preferences that set the last channel used in the team and other team specific preferences + if result := <-Srv.Store.Preference().DeleteCategory(user.Id, team.Id); result.Err != nil { + return result.Err + } + + ClearSessionCacheForUser(user.Id) + InvalidateCacheForUser(user.Id) + + return nil +} + +func InviteNewUsersToTeam(emailList []string, teamId, senderId, siteURL string) *model.AppError { + if len(emailList) == 0 { + err := model.NewLocAppError("InviteNewUsersToTeam", "api.team.invite_members.no_one.app_error", nil, "") + err.StatusCode = http.StatusBadRequest + return err + } + + tchan := Srv.Store.Team().Get(teamId) + uchan := Srv.Store.User().Get(senderId) + + var team *model.Team + if result := <-tchan; result.Err != nil { + return result.Err + } else { + team = result.Data.(*model.Team) + } + + var user *model.User + if result := <-uchan; result.Err != nil { + return result.Err + } else { + user = result.Data.(*model.User) + } + + SendInviteEmails(team, user.GetDisplayName(), emailList, siteURL) + + return nil +} + +func FindTeamByName(name string) bool { + if result := <-Srv.Store.Team().GetByName(name); result.Err != nil { + return false + } else { + return true + } +} + +func GetTeamsUnreadForUser(teamId string, userId string) ([]*model.TeamUnread, *model.AppError) { + if result := <-Srv.Store.Team().GetTeamsUnreadForUser(teamId, userId); result.Err != nil { + return nil, result.Err + } else { + data := result.Data.([]*model.ChannelUnread) + var members []*model.TeamUnread + membersMap := make(map[string]*model.TeamUnread) + + unreads := func(cu *model.ChannelUnread, tu *model.TeamUnread) *model.TeamUnread { + tu.MentionCount += cu.MentionCount + + if cu.NotifyProps["mark_unread"] != model.CHANNEL_MARK_UNREAD_MENTION { + tu.MsgCount += (cu.TotalMsgCount - cu.MsgCount) + } + + return tu + } + + for i := range data { + id := data[i].TeamId + if mu, ok := membersMap[id]; ok { + membersMap[id] = unreads(data[i], mu) + } else { + membersMap[id] = unreads(data[i], &model.TeamUnread{ + MsgCount: 0, + MentionCount: 0, + TeamId: id, + }) + } + } + + for _, val := range membersMap { + members = append(members, val) + } + + return members, nil + } +} + +func PermanentDeleteTeam(team *model.Team) *model.AppError { + team.DeleteAt = model.GetMillis() + if result := <-Srv.Store.Team().Update(team); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Channel().PermanentDeleteByTeam(team.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Team().RemoveAllMembersByTeam(team.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Team().PermanentDelete(team.Id); result.Err != nil { + return result.Err + } + + return nil +} + +func GetTeamStats(teamId string) (*model.TeamStats, *model.AppError) { + tchan := Srv.Store.Team().GetTotalMemberCount(teamId) + achan := Srv.Store.Team().GetActiveMemberCount(teamId) + + stats := &model.TeamStats{} + stats.TeamId = teamId + + if result := <-tchan; result.Err != nil { + return nil, result.Err + } else { + stats.TotalMemberCount = result.Data.(int64) + } + + if result := <-achan; result.Err != nil { + return nil, result.Err + } else { + stats.ActiveMemberCount = result.Data.(int64) + } + + return stats, nil +} diff --git a/app/user.go b/app/user.go new file mode 100644 index 000000000..8fbed301d --- /dev/null +++ b/app/user.go @@ -0,0 +1,982 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "bytes" + "fmt" + "hash/fnv" + "html/template" + "image" + "image/color" + "image/draw" + _ "image/gif" + _ "image/jpeg" + "image/png" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + + l4g "github.com/alecthomas/log4go" + "github.com/disintegration/imaging" + "github.com/golang/freetype" + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func CreateUserWithHash(user *model.User, hash string, data string) (*model.User, *model.AppError) { + props := model.MapFromJson(strings.NewReader(data)) + + if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.EmailSettings.InviteSalt)) { + return nil, model.NewLocAppError("CreateUserWithHash", "api.user.create_user.signup_link_invalid.app_error", nil, "") + } + + if t, err := strconv.ParseInt(props["time"], 10, 64); err != nil || model.GetMillis()-t > 1000*60*60*48 { // 48 hours + return nil, model.NewLocAppError("CreateUserWithHash", "api.user.create_user.signup_link_expired.app_error", nil, "") + } + + teamId := props["id"] + + var team *model.Team + if result := <-Srv.Store.Team().Get(teamId); result.Err != nil { + return nil, result.Err + } else { + team = result.Data.(*model.Team) + } + + user.Email = props["email"] + user.EmailVerified = true + + var ruser *model.User + var err *model.AppError + if ruser, err = CreateUser(user); err != nil { + return nil, err + } + + if err := JoinUserToTeam(team, ruser); err != nil { + return nil, err + } + + AddDirectChannels(team.Id, ruser) + + return ruser, nil +} + +func CreateUserWithInviteId(user *model.User, inviteId string, siteURL string) (*model.User, *model.AppError) { + var team *model.Team + if result := <-Srv.Store.Team().GetByInviteId(inviteId); result.Err != nil { + return nil, result.Err + } else { + team = result.Data.(*model.Team) + } + + var ruser *model.User + var err *model.AppError + if ruser, err = CreateUser(user); err != nil { + return nil, err + } + + if err := JoinUserToTeam(team, ruser); err != nil { + return nil, err + } + + AddDirectChannels(team.Id, ruser) + + if err := SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.Locale, siteURL); err != nil { + l4g.Error(err.Error()) + } + + return ruser, nil +} + +func IsFirstUserAccount() bool { + if SessionCacheLength() == 0 { + if cr := <-Srv.Store.User().GetTotalUsersCount(); cr.Err != nil { + l4g.Error(cr.Err) + return false + } else { + count := cr.Data.(int64) + if count <= 0 { + return true + } + } + } + + return false +} + +func CreateUser(user *model.User) (*model.User, *model.AppError) { + if !user.IsSSOUser() && !CheckUserDomain(user, utils.Cfg.TeamSettings.RestrictCreationToDomains) { + return nil, model.NewLocAppError("CreateUser", "api.user.create_user.accepted_domain.app_error", nil, "") + } + + user.Roles = model.ROLE_SYSTEM_USER.Id + + // Below is a special case where the first user in the entire + // system is granted the system_admin role + if result := <-Srv.Store.User().GetTotalUsersCount(); result.Err != nil { + return nil, result.Err + } else { + count := result.Data.(int64) + if count <= 0 { + user.Roles = model.ROLE_SYSTEM_ADMIN.Id + " " + model.ROLE_SYSTEM_USER.Id + } + } + + user.MakeNonNil() + user.Locale = *utils.Cfg.LocalizationSettings.DefaultClientLocale + + if err := utils.IsPasswordValid(user.Password); user.AuthService == "" && err != nil { + return nil, err + } + + if result := <-Srv.Store.User().Save(user); result.Err != nil { + l4g.Error(utils.T("api.user.create_user.save.error"), result.Err) + return nil, result.Err + } else { + ruser := result.Data.(*model.User) + + if user.EmailVerified { + if err := VerifyUserEmail(ruser.Id); err != nil { + l4g.Error(utils.T("api.user.create_user.verified.error"), err) + } + } + + pref := model.Preference{UserId: ruser.Id, Category: model.PREFERENCE_CATEGORY_TUTORIAL_STEPS, Name: ruser.Id, Value: "0"} + if presult := <-Srv.Store.Preference().Save(&model.Preferences{pref}); presult.Err != nil { + l4g.Error(utils.T("api.user.create_user.tutorial.error"), presult.Err.Message) + } + + ruser.Sanitize(map[string]bool{}) + + // This message goes to everyone, so the teamId, channelId and userId are irrelevant + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil) + message.Add("user_id", ruser.Id) + go Publish(message) + + return ruser, nil + } +} + +func CreateOAuthUser(service string, userData io.Reader, teamId string) (*model.User, *model.AppError) { + var user *model.User + provider := einterfaces.GetOauthProvider(service) + if provider == nil { + return nil, model.NewLocAppError("CreateOAuthUser", "api.user.create_oauth_user.not_available.app_error", map[string]interface{}{"Service": strings.Title(service)}, "") + } else { + user = provider.GetUserFromJson(userData) + } + + if user == nil { + return nil, model.NewLocAppError("CreateOAuthUser", "api.user.create_oauth_user.create.app_error", map[string]interface{}{"Service": service}, "") + } + + suchan := Srv.Store.User().GetByAuth(user.AuthData, service) + euchan := Srv.Store.User().GetByEmail(user.Email) + + found := true + count := 0 + for found { + if found = IsUsernameTaken(user.Username); found { + user.Username = user.Username + strconv.Itoa(count) + count += 1 + } + } + + if result := <-suchan; result.Err == nil { + return nil, model.NewLocAppError("CreateOAuthUser", "api.user.create_oauth_user.already_used.app_error", map[string]interface{}{"Service": service}, "email="+user.Email) + } + + if result := <-euchan; result.Err == nil { + authService := result.Data.(*model.User).AuthService + if authService == "" { + return nil, model.NewLocAppError("CreateOAuthUser", "api.user.create_oauth_user.already_attached.app_error", + map[string]interface{}{"Service": service, "Auth": model.USER_AUTH_SERVICE_EMAIL}, "email="+user.Email) + } else { + return nil, model.NewLocAppError("CreateOAuthUser", "api.user.create_oauth_user.already_attached.app_error", + map[string]interface{}{"Service": service, "Auth": authService}, "email="+user.Email) + } + } + + user.EmailVerified = true + + ruser, err := CreateUser(user) + if err != nil { + return nil, err + } + + if len(teamId) > 0 { + err = AddUserToTeamByTeamId(teamId, user) + if err != nil { + return nil, err + } + + err = AddDirectChannels(teamId, user) + if err != nil { + l4g.Error(err.Error()) + } + } + + return ruser, nil +} + +// Check that a user's email domain matches a list of space-delimited domains as a string. +func CheckUserDomain(user *model.User, domains string) bool { + if len(domains) == 0 { + return true + } + + domainArray := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(strings.Replace(domains, "@", " ", -1), ",", " ", -1)))) + + matched := false + for _, d := range domainArray { + if strings.HasSuffix(strings.ToLower(user.Email), "@"+d) { + matched = true + break + } + } + + return matched +} + +// Check if the username is already used by another user. Return false if the username is invalid. +func IsUsernameTaken(name string) bool { + + if !model.IsValidUsername(name) { + return false + } + + if result := <-Srv.Store.User().GetByUsername(name); result.Err != nil { + return false + } else { + return true + } + + return false +} + +func GetUser(userId string) (*model.User, *model.AppError) { + if result := <-Srv.Store.User().Get(userId); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.User), nil + } +} + +func GetUserByUsername(username string) (*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetByUsername(username); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.User), nil + } +} + +func GetUserByEmail(email string) (*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetByEmail(email); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.User), nil + } +} + +func GetUserByAuth(authData *string, authService string) (*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetByAuth(authData, authService); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.User), nil + } +} + +func GetUserForLogin(loginId string, onlyLdap bool) (*model.User, *model.AppError) { + ldapAvailable := *utils.Cfg.LdapSettings.Enable && einterfaces.GetLdapInterface() != nil && utils.IsLicensed && *utils.License.Features.LDAP + + if result := <-Srv.Store.User().GetForLogin( + loginId, + *utils.Cfg.EmailSettings.EnableSignInWithUsername && !onlyLdap, + *utils.Cfg.EmailSettings.EnableSignInWithEmail && !onlyLdap, + ldapAvailable, + ); result.Err != nil && result.Err.Id == "store.sql_user.get_for_login.multiple_users" { + // don't fall back to LDAP in this case since we already know there's an LDAP user, but that it shouldn't work + result.Err.StatusCode = http.StatusBadRequest + return nil, result.Err + } else if result.Err != nil { + if !ldapAvailable { + // failed to find user and no LDAP server to fall back on + result.Err.StatusCode = http.StatusBadRequest + return nil, result.Err + } + + // fall back to LDAP server to see if we can find a user + if ldapUser, ldapErr := einterfaces.GetLdapInterface().GetUser(loginId); ldapErr != nil { + ldapErr.StatusCode = http.StatusBadRequest + return nil, ldapErr + } else { + return ldapUser, nil + } + } else { + return result.Data.(*model.User), nil + } +} + +func GetUsers(offset int, limit int) (map[string]*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetAllProfiles(offset, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(map[string]*model.User), nil + } +} + +func GetUsersEtag() string { + return (<-Srv.Store.User().GetEtagForAllProfiles()).Data.(string) +} + +func GetUsersInTeam(teamId string, offset int, limit int) (map[string]*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetProfiles(teamId, offset, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(map[string]*model.User), nil + } +} + +func GetUsersInTeamEtag(teamId string) string { + return (<-Srv.Store.User().GetEtagForProfiles(teamId)).Data.(string) +} + +func GetUsersInChannel(channelId string, offset int, limit int) (map[string]*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetProfilesInChannel(channelId, offset, limit, false); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(map[string]*model.User), nil + } +} + +func GetUsersNotInChannel(teamId string, channelId string, offset int, limit int) (map[string]*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetProfilesNotInChannel(teamId, channelId, offset, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(map[string]*model.User), nil + } +} + +func GetUsersByIds(userIds []string) (map[string]*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetProfileByIds(userIds, true); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(map[string]*model.User), nil + } +} + +func ActivateMfa(userId, token string) *model.AppError { + mfaInterface := einterfaces.GetMfaInterface() + if mfaInterface == nil { + err := model.NewLocAppError("ActivateMfa", "api.user.update_mfa.not_available.app_error", nil, "") + err.StatusCode = http.StatusNotImplemented + return err + } + + var user *model.User + if result := <-Srv.Store.User().Get(userId); result.Err != nil { + return result.Err + } else { + user = result.Data.(*model.User) + } + + if len(user.AuthService) > 0 && user.AuthService != model.USER_AUTH_SERVICE_LDAP { + return model.NewLocAppError("ActivateMfa", "api.user.activate_mfa.email_and_ldap_only.app_error", nil, "") + } + + if err := mfaInterface.Activate(user, token); err != nil { + return err + } + + return nil +} + +func DeactivateMfa(userId string) *model.AppError { + mfaInterface := einterfaces.GetMfaInterface() + if mfaInterface == nil { + err := model.NewLocAppError("DeactivateMfa", "api.user.update_mfa.not_available.app_error", nil, "") + err.StatusCode = http.StatusNotImplemented + return err + } + + if err := mfaInterface.Deactivate(userId); err != nil { + return err + } + + return nil +} + +func CreateProfileImage(username string, userId string) ([]byte, *model.AppError) { + colors := []color.NRGBA{ + {197, 8, 126, 255}, + {227, 207, 18, 255}, + {28, 181, 105, 255}, + {35, 188, 224, 255}, + {116, 49, 196, 255}, + {197, 8, 126, 255}, + {197, 19, 19, 255}, + {250, 134, 6, 255}, + {227, 207, 18, 255}, + {123, 201, 71, 255}, + {28, 181, 105, 255}, + {35, 188, 224, 255}, + {116, 49, 196, 255}, + {197, 8, 126, 255}, + {197, 19, 19, 255}, + {250, 134, 6, 255}, + {227, 207, 18, 255}, + {123, 201, 71, 255}, + {28, 181, 105, 255}, + {35, 188, 224, 255}, + {116, 49, 196, 255}, + {197, 8, 126, 255}, + {197, 19, 19, 255}, + {250, 134, 6, 255}, + {227, 207, 18, 255}, + {123, 201, 71, 255}, + } + + h := fnv.New32a() + h.Write([]byte(userId)) + seed := h.Sum32() + + initial := string(strings.ToUpper(username)[0]) + + fontBytes, err := ioutil.ReadFile(utils.FindDir("fonts") + utils.Cfg.FileSettings.InitialFont) + if err != nil { + return nil, model.NewLocAppError("CreateProfileImage", "api.user.create_profile_image.default_font.app_error", nil, err.Error()) + } + font, err := freetype.ParseFont(fontBytes) + if err != nil { + return nil, model.NewLocAppError("CreateProfileImage", "api.user.create_profile_image.default_font.app_error", nil, err.Error()) + } + + width := int(utils.Cfg.FileSettings.ProfileWidth) + height := int(utils.Cfg.FileSettings.ProfileHeight) + color := colors[int64(seed)%int64(len(colors))] + dstImg := image.NewRGBA(image.Rect(0, 0, width, height)) + srcImg := image.White + draw.Draw(dstImg, dstImg.Bounds(), &image.Uniform{color}, image.ZP, draw.Src) + size := float64((width + height) / 4) + + c := freetype.NewContext() + c.SetFont(font) + c.SetFontSize(size) + c.SetClip(dstImg.Bounds()) + c.SetDst(dstImg) + c.SetSrc(srcImg) + + pt := freetype.Pt(width/6, height*2/3) + _, err = c.DrawString(initial, pt) + if err != nil { + return nil, model.NewLocAppError("CreateProfileImage", "api.user.create_profile_image.initial.app_error", nil, err.Error()) + } + + buf := new(bytes.Buffer) + + if imgErr := png.Encode(buf, dstImg); imgErr != nil { + return nil, model.NewLocAppError("CreateProfileImage", "api.user.create_profile_image.encode.app_error", nil, imgErr.Error()) + } else { + return buf.Bytes(), nil + } +} + +func GetProfileImage(user *model.User) ([]byte, *model.AppError) { + var img []byte + + if len(utils.Cfg.FileSettings.DriverName) == 0 { + var err *model.AppError + if img, err = CreateProfileImage(user.Username, user.Id); err != nil { + return nil, err + } + } else { + path := "users/" + user.Id + "/profile.png" + + if data, err := ReadFile(path); err != nil { + if img, err = CreateProfileImage(user.Username, user.Id); err != nil { + return nil, err + } + + if user.LastPictureUpdate == 0 { + if err := WriteFile(img, path); err != nil { + return nil, err + } + } + + } else { + img = data + } + } + + return img, nil +} + +func SetProfileImage(userId string, imageData *multipart.FileHeader) *model.AppError { + file, err := imageData.Open() + defer file.Close() + if err != nil { + return model.NewLocAppError("SetProfileImage", "api.user.upload_profile_user.open.app_error", nil, err.Error()) + } + + // Decode image config first to check dimensions before loading the whole thing into memory later on + config, _, err := image.DecodeConfig(file) + if err != nil { + return model.NewLocAppError("SetProfileImage", "api.user.upload_profile_user.decode_config.app_error", nil, err.Error()) + } else if config.Width*config.Height > model.MaxImageSize { + return model.NewLocAppError("SetProfileImage", "api.user.upload_profile_user.too_large.app_error", nil, err.Error()) + } + + file.Seek(0, 0) + + // Decode image into Image object + img, _, err := image.Decode(file) + if err != nil { + return model.NewLocAppError("SetProfileImage", "api.user.upload_profile_user.decode.app_error", nil, err.Error()) + } + + // Scale profile image + img = imaging.Resize(img, utils.Cfg.FileSettings.ProfileWidth, utils.Cfg.FileSettings.ProfileHeight, imaging.Lanczos) + + buf := new(bytes.Buffer) + err = png.Encode(buf, img) + if err != nil { + return model.NewLocAppError("SetProfileImage", "api.user.upload_profile_user.encode.app_error", nil, err.Error()) + } + + path := "users/" + userId + "/profile.png" + + if err := WriteFile(buf.Bytes(), path); err != nil { + return model.NewLocAppError("SetProfileImage", "api.user.upload_profile_user.upload_profile.app_error", nil, "") + } + + Srv.Store.User().UpdateLastPictureUpdate(userId) + + if user, err := GetUser(userId); err != nil { + l4g.Error(utils.T("api.user.get_me.getting.error"), userId) + } else { + options := utils.Cfg.GetSanitizeOptions() + user.SanitizeProfile(options) + + omitUsers := make(map[string]bool, 1) + omitUsers[userId] = true + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_UPDATED, "", "", "", omitUsers) + message.Add("user", user) + + Publish(message) + } + + return nil +} + +func UpdateActiveNoLdap(userId string, active bool) (*model.User, *model.AppError) { + var user *model.User + var err *model.AppError + if user, err = GetUser(userId); err != nil { + return nil, err + } + + if user.IsLDAPUser() { + err := model.NewLocAppError("UpdateActive", "api.user.update_active.no_deactivate_ldap.app_error", nil, "userId="+user.Id) + err.StatusCode = http.StatusBadRequest + return nil, err + } + + return UpdateActive(user, active) +} + +func UpdateActive(user *model.User, active bool) (*model.User, *model.AppError) { + if active { + user.DeleteAt = 0 + } else { + user.DeleteAt = model.GetMillis() + } + + if result := <-Srv.Store.User().Update(user, true); result.Err != nil { + return nil, result.Err + } else { + if user.DeleteAt > 0 { + if err := RevokeAllSessions(user.Id); err != nil { + return nil, err + } + } + + if extra := <-Srv.Store.Channel().ExtraUpdateByUser(user.Id, model.GetMillis()); extra.Err != nil { + return nil, extra.Err + } + + ruser := result.Data.([2]*model.User)[0] + options := utils.Cfg.GetSanitizeOptions() + options["passwordupdate"] = false + ruser.Sanitize(options) + + if !active { + SetStatusOffline(ruser.Id, false) + } + + return ruser, nil + } +} + +func UpdateUser(user *model.User, siteURL string) (*model.User, *model.AppError) { + if result := <-Srv.Store.User().Update(user, false); result.Err != nil { + return nil, result.Err + } else { + rusers := result.Data.([2]*model.User) + + if rusers[0].Email != rusers[1].Email { + go func() { + if err := SendEmailChangeEmail(rusers[1].Email, rusers[0].Email, rusers[0].Locale, siteURL); err != nil { + l4g.Error(err.Error()) + } + }() + + if utils.Cfg.EmailSettings.RequireEmailVerification { + go func() { + if err := SendEmailChangeVerifyEmail(rusers[0].Id, rusers[0].Email, rusers[0].Locale, siteURL); err != nil { + l4g.Error(err.Error()) + } + }() + } + } + + if rusers[0].Username != rusers[1].Username { + go func() { + if err := SendChangeUsernameEmail(rusers[1].Username, rusers[0].Username, rusers[0].Email, rusers[0].Locale, siteURL); err != nil { + l4g.Error(err.Error()) + } + }() + } + + InvalidateCacheForUser(user.Id) + + return rusers[0], nil + } +} + +func UpdateUserNotifyProps(userId string, props map[string]string, siteURL string) (*model.User, *model.AppError) { + var user *model.User + var err *model.AppError + if user, err = GetUser(userId); err != nil { + return nil, err + } + + user.NotifyProps = props + + var ruser *model.User + if ruser, err = UpdateUser(user, siteURL); err != nil { + return nil, err + } + + return ruser, nil +} + +func UpdatePasswordByUserIdSendEmail(userId, newPassword, method, siteURL string) *model.AppError { + var user *model.User + var err *model.AppError + if user, err = GetUser(userId); err != nil { + return err + } + + return UpdatePasswordSendEmail(user, newPassword, method, siteURL) +} + +func UpdatePassword(user *model.User, newPassword string) *model.AppError { + if err := utils.IsPasswordValid(newPassword); err != nil { + return err + } + + hashedPassword := model.HashPassword(newPassword) + + if result := <-Srv.Store.User().UpdatePassword(user.Id, hashedPassword); result.Err != nil { + return model.NewLocAppError("UpdatePassword", "api.user.update_password.failed.app_error", nil, result.Err.Error()) + } + + return nil +} + +func UpdatePasswordSendEmail(user *model.User, newPassword, method, siteURL string) *model.AppError { + if err := UpdatePassword(user, newPassword); err != nil { + return err + } + + go func() { + if err := SendPasswordChangeEmail(user.Email, method, user.Locale, siteURL); err != nil { + l4g.Error(err.Error()) + } + }() + + return nil +} + +func SendPasswordReset(email string, siteURL string) (bool, *model.AppError) { + var user *model.User + var err *model.AppError + if user, err = GetUserByEmail(email); err != nil { + return false, nil + } + + if user.AuthData != nil && len(*user.AuthData) != 0 { + return false, model.NewLocAppError("SendPasswordReset", "api.user.send_password_reset.sso.app_error", nil, "userId="+user.Id) + } + + var recovery *model.PasswordRecovery + if recovery, err = CreatePasswordRecovery(user.Id); err != nil { + return false, err + } + + T := utils.GetUserTranslations(user.Locale) + + link := fmt.Sprintf("%s/reset_password_complete?code=%s", siteURL, url.QueryEscape(recovery.Code)) + + subject := T("api.templates.reset_subject") + + bodyPage := utils.NewHTMLTemplate("reset_body", user.Locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.reset_body.title") + bodyPage.Html["Info"] = template.HTML(T("api.templates.reset_body.info")) + bodyPage.Props["ResetUrl"] = link + bodyPage.Props["Button"] = T("api.templates.reset_body.button") + + if err := utils.SendMail(email, subject, bodyPage.Render()); err != nil { + return false, model.NewLocAppError("SendPasswordReset", "api.user.send_password_reset.send.app_error", nil, "err="+err.Message) + } + + return true, nil +} + +func ResetPasswordFromCode(code, newPassword, siteURL string) *model.AppError { + var recovery *model.PasswordRecovery + var err *model.AppError + if recovery, err = GetPasswordRecovery(code); err != nil { + return err + } else { + if model.GetMillis()-recovery.CreateAt >= model.PASSWORD_RECOVER_EXPIRY_TIME { + return model.NewLocAppError("resetPassword", "api.user.reset_password.link_expired.app_error", nil, "") + } + } + + var user *model.User + if user, err = GetUser(recovery.UserId); err != nil { + return err + } + + if user.IsSSOUser() { + return model.NewLocAppError("ResetPasswordFromCode", "api.user.reset_password.sso.app_error", nil, "userId="+user.Id) + } + + T := utils.GetUserTranslations(user.Locale) + + if err := UpdatePasswordSendEmail(user, newPassword, T("api.user.reset_password.method"), siteURL); err != nil { + return err + } + + if err := DeletePasswordRecoveryForUser(recovery.UserId); err != nil { + l4g.Error(err.Error()) + } + + return nil +} + +func CreatePasswordRecovery(userId string) (*model.PasswordRecovery, *model.AppError) { + recovery := &model.PasswordRecovery{} + recovery.UserId = userId + + if result := <-Srv.Store.PasswordRecovery().SaveOrUpdate(recovery); result.Err != nil { + return nil, result.Err + } + + return recovery, nil +} + +func GetPasswordRecovery(code string) (*model.PasswordRecovery, *model.AppError) { + if result := <-Srv.Store.PasswordRecovery().GetByCode(code); result.Err != nil { + return nil, model.NewLocAppError("GetPasswordRecovery", "api.user.reset_password.invalid_link.app_error", nil, result.Err.Error()) + } else { + return result.Data.(*model.PasswordRecovery), nil + } +} + +func DeletePasswordRecoveryForUser(userId string) *model.AppError { + if result := <-Srv.Store.PasswordRecovery().Delete(userId); result.Err != nil { + return result.Err + } + + return nil +} + +func UpdateUserRoles(userId string, newRoles string) (*model.User, *model.AppError) { + var user *model.User + var err *model.AppError + if user, err = GetUser(userId); err != nil { + err.StatusCode = http.StatusBadRequest + return nil, err + } + + user.Roles = newRoles + uchan := Srv.Store.User().Update(user, true) + schan := Srv.Store.Session().UpdateRoles(user.Id, newRoles) + + var ruser *model.User + if result := <-uchan; result.Err != nil { + return nil, result.Err + } else { + ruser = result.Data.([2]*model.User)[0] + } + + if result := <-schan; result.Err != nil { + // soft error since the user roles were still updated + l4g.Error(result.Err) + } + + ClearSessionCacheForUser(user.Id) + + return ruser, nil +} + +func PermanentDeleteUser(user *model.User) *model.AppError { + l4g.Warn(utils.T("api.user.permanent_delete_user.attempting.warn"), user.Email, user.Id) + if user.IsInRole(model.ROLE_SYSTEM_ADMIN.Id) { + l4g.Warn(utils.T("api.user.permanent_delete_user.system_admin.warn"), user.Email) + } + + if _, err := UpdateActive(user, false); err != nil { + return err + } + + if result := <-Srv.Store.Session().PermanentDeleteSessionsByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.OAuth().PermanentDeleteAuthDataByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Webhook().PermanentDeleteIncomingByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Webhook().PermanentDeleteOutgoingByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Command().PermanentDeleteByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Preference().PermanentDeleteByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Channel().PermanentDeleteMembersByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Post().PermanentDeleteByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.User().PermanentDelete(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Audit().PermanentDeleteByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.Team().RemoveAllMembersByUser(user.Id); result.Err != nil { + return result.Err + } + + if result := <-Srv.Store.PasswordRecovery().Delete(user.Id); result.Err != nil { + return result.Err + } + + l4g.Warn(utils.T("api.user.permanent_delete_user.deleted.warn"), user.Email, user.Id) + + return nil +} + +func PermanentDeleteAllUsers() *model.AppError { + if result := <-Srv.Store.User().GetAll(); result.Err != nil { + return result.Err + } else { + users := result.Data.([]*model.User) + for _, user := range users { + PermanentDeleteUser(user) + } + } + + return nil +} + +func VerifyUserEmail(userId string) *model.AppError { + if err := (<-Srv.Store.User().VerifyEmail(userId)).Err; err != nil { + return err + } + + return nil +} + +func SearchUsersInChannel(channelId string, term string, searchOptions map[string]bool) ([]*model.User, *model.AppError) { + if result := <-Srv.Store.User().SearchInChannel(channelId, term, searchOptions); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.User), nil + } +} + +func SearchUsersNotInChannel(teamId string, channelId string, term string, searchOptions map[string]bool) ([]*model.User, *model.AppError) { + if result := <-Srv.Store.User().SearchNotInChannel(teamId, channelId, term, searchOptions); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.User), nil + } +} + +func SearchUsersInTeam(teamId string, term string, searchOptions map[string]bool) ([]*model.User, *model.AppError) { + if result := <-Srv.Store.User().Search(teamId, term, searchOptions); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.User), nil + } +} + +func AutocompleteUsersInChannel(teamId string, channelId string, term string, searchOptions map[string]bool) (*model.UserAutocompleteInChannel, *model.AppError) { + uchan := Srv.Store.User().SearchInChannel(channelId, term, searchOptions) + nuchan := Srv.Store.User().SearchNotInChannel(teamId, channelId, term, searchOptions) + + autocomplete := &model.UserAutocompleteInChannel{} + + if result := <-uchan; result.Err != nil { + return nil, result.Err + } else { + autocomplete.InChannel = result.Data.([]*model.User) + } + + if result := <-nuchan; result.Err != nil { + return nil, result.Err + } else { + autocomplete.OutOfChannel = result.Data.([]*model.User) + } + + return autocomplete, nil +} + +func AutocompleteUsersInTeam(teamId string, term string, searchOptions map[string]bool) (*model.UserAutocompleteInTeam, *model.AppError) { + autocomplete := &model.UserAutocompleteInTeam{} + + if result := <-Srv.Store.User().Search(teamId, term, searchOptions); result.Err != nil { + return nil, result.Err + } else { + autocomplete.InTeam = result.Data.([]*model.User) + } + + return autocomplete, nil +} diff --git a/app/user_test.go b/app/user_test.go new file mode 100644 index 000000000..5b994d219 --- /dev/null +++ b/app/user_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "testing" +) + +func TestIsUsernameTaken(t *testing.T) { + th := Setup().InitBasic() + user := th.BasicUser + taken := IsUsernameTaken(user.Username) + + if !taken { + t.Logf("the username '%v' should be taken", user.Username) + t.FailNow() + } + + newUsername := "randomUsername" + taken = IsUsernameTaken(newUsername) + + if taken { + t.Logf("the username '%v' should not be taken", newUsername) + t.FailNow() + } +} + +func TestCheckUserDomain(t *testing.T) { + th := Setup().InitBasic() + user := th.BasicUser + + cases := []struct { + domains string + matched bool + }{ + {"simulator.amazonses.com", true}, + {"gmail.com", false}, + {"", true}, + {"gmail.com simulator.amazonses.com", true}, + } + for _, c := range cases { + matched := CheckUserDomain(user, c.domains) + if matched != c.matched { + if c.matched { + t.Logf("'%v' should have matched '%v'", user.Email, c.domains) + } else { + t.Logf("'%v' should not have matched '%v'", user.Email, c.domains) + } + t.FailNow() + } + } +} diff --git a/app/web_conn.go b/app/web_conn.go new file mode 100644 index 000000000..02c3b2642 --- /dev/null +++ b/app/web_conn.go @@ -0,0 +1,254 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "fmt" + "time" + + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + + l4g "github.com/alecthomas/log4go" + "github.com/gorilla/websocket" + goi18n "github.com/nicksnyder/go-i18n/i18n" +) + +const ( + WRITE_WAIT = 30 * time.Second + PONG_WAIT = 100 * time.Second + PING_PERIOD = (PONG_WAIT * 6) / 10 + AUTH_TIMEOUT = 5 * time.Second +) + +type WebConn struct { + WebSocket *websocket.Conn + Send chan model.WebSocketMessage + SessionToken string + SessionExpiresAt int64 + UserId string + T goi18n.TranslateFunc + Locale string + AllChannelMembers map[string]string + LastAllChannelMembersTime int64 +} + +func NewWebConn(ws *websocket.Conn, session model.Session, t goi18n.TranslateFunc, locale string) *WebConn { + if len(session.UserId) > 0 { + go SetStatusOnline(session.UserId, session.Id, false) + } + + return &WebConn{ + Send: make(chan model.WebSocketMessage, 256), + WebSocket: ws, + UserId: session.UserId, + SessionToken: session.Token, + SessionExpiresAt: session.ExpiresAt, + T: t, + Locale: locale, + } +} + +func (c *WebConn) ReadPump() { + defer func() { + HubUnregister(c) + c.WebSocket.Close() + }() + c.WebSocket.SetReadLimit(model.SOCKET_MAX_MESSAGE_SIZE_KB) + c.WebSocket.SetReadDeadline(time.Now().Add(PONG_WAIT)) + c.WebSocket.SetPongHandler(func(string) error { + c.WebSocket.SetReadDeadline(time.Now().Add(PONG_WAIT)) + if c.IsAuthenticated() { + go SetStatusAwayIfNeeded(c.UserId, false) + } + return nil + }) + + for { + var req model.WebSocketRequest + if err := c.WebSocket.ReadJSON(&req); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.read: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.read: closing websocket for userId=%v error=%v", c.UserId, err.Error())) + } + + return + } else { + Srv.WebSocketRouter.ServeWebSocket(c, &req) + } + } +} + +func (c *WebConn) WritePump() { + ticker := time.NewTicker(PING_PERIOD) + authTicker := time.NewTicker(AUTH_TIMEOUT) + + defer func() { + ticker.Stop() + authTicker.Stop() + c.WebSocket.Close() + }() + + for { + select { + case msg, ok := <-c.Send: + if !ok { + c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) + c.WebSocket.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) + if err := c.WebSocket.WriteMessage(websocket.TextMessage, msg.GetPreComputeJson()); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.send: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.send: closing websocket for userId=%v, error=%v", c.UserId, err.Error())) + } + + return + } + + if msg.EventType() == model.WEBSOCKET_EVENT_POSTED { + if einterfaces.GetMetricsInterface() != nil { + einterfaces.GetMetricsInterface().IncrementPostBroadcast() + } + } + + case <-ticker.C: + c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) + if err := c.WebSocket.WriteMessage(websocket.PingMessage, []byte{}); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.ticker: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.ticker: closing websocket for userId=%v error=%v", c.UserId, err.Error())) + } + + return + } + + case <-authTicker.C: + if c.SessionToken == "" { + l4g.Debug(fmt.Sprintf("websocket.authTicker: did not authenticate ip=%v", c.WebSocket.RemoteAddr())) + return + } + authTicker.Stop() + } + } +} + +func (webCon *WebConn) InvalidateCache() { + webCon.AllChannelMembers = nil + webCon.LastAllChannelMembersTime = 0 + webCon.SessionExpiresAt = 0 +} + +func (webCon *WebConn) IsAuthenticated() bool { + // Check the expiry to see if we need to check for a new session + if webCon.SessionExpiresAt < model.GetMillis() { + if webCon.SessionToken == "" { + return false + } + + session, err := GetSession(webCon.SessionToken) + if err != nil { + l4g.Error(utils.T("api.websocket.invalid_session.error"), err.Error()) + webCon.SessionToken = "" + webCon.SessionExpiresAt = 0 + return false + } + + webCon.SessionToken = session.Token + webCon.SessionExpiresAt = session.ExpiresAt + } + + return true +} + +func (webCon *WebConn) SendHello() { + msg := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_HELLO, "", "", webCon.UserId, nil) + msg.Add("server_version", fmt.Sprintf("%v.%v.%v", model.CurrentVersion, model.BuildNumber, utils.CfgHash)) + msg.DoPreComputeJson() + webCon.Send <- msg +} + +func (webCon *WebConn) ShouldSendEvent(msg *model.WebSocketEvent) bool { + // IMPORTANT: Do not send event if WebConn does not have a session + if !webCon.IsAuthenticated() { + return false + } + + // If the event is destined to a specific user + if len(msg.Broadcast.UserId) > 0 && webCon.UserId != msg.Broadcast.UserId { + return false + } + + // if the user is omitted don't send the message + if len(msg.Broadcast.OmitUsers) > 0 { + if _, ok := msg.Broadcast.OmitUsers[webCon.UserId]; ok { + return false + } + } + + // Only report events to users who are in the channel for the event + if len(msg.Broadcast.ChannelId) > 0 { + + // Only broadcast typing messages if less than 1K people in channel + if msg.Event == model.WEBSOCKET_EVENT_TYPING { + if Srv.Store.Channel().GetMemberCountFromCache(msg.Broadcast.ChannelId) > *utils.Cfg.TeamSettings.MaxNotificationsPerChannel { + return false + } + } + + if model.GetMillis()-webCon.LastAllChannelMembersTime > 1000*60*15 { // 15 minutes + webCon.AllChannelMembers = nil + webCon.LastAllChannelMembersTime = 0 + } + + if webCon.AllChannelMembers == nil { + if result := <-Srv.Store.Channel().GetAllChannelMembersForUser(webCon.UserId, true); result.Err != nil { + l4g.Error("webhub.shouldSendEvent: " + result.Err.Error()) + return false + } else { + webCon.AllChannelMembers = result.Data.(map[string]string) + webCon.LastAllChannelMembersTime = model.GetMillis() + } + } + + if _, ok := webCon.AllChannelMembers[msg.Broadcast.ChannelId]; ok { + return true + } else { + return false + } + } + + // Only report events to users who are in the team for the event + if len(msg.Broadcast.TeamId) > 0 { + return webCon.IsMemberOfTeam(msg.Broadcast.TeamId) + + } + + return true +} + +func (webCon *WebConn) IsMemberOfTeam(teamId string) bool { + session, err := GetSession(webCon.SessionToken) + if err != nil { + l4g.Error(utils.T("api.websocket.invalid_session.error"), err.Error()) + return false + } else { + member := session.GetTeamByTeamId(teamId) + + if member != nil { + return true + } else { + return false + } + } +} diff --git a/app/web_hub.go b/app/web_hub.go new file mode 100644 index 000000000..28d2c0095 --- /dev/null +++ b/app/web_hub.go @@ -0,0 +1,241 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "fmt" + "hash/fnv" + "runtime" + + l4g "github.com/alecthomas/log4go" + + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +type Hub struct { + connections map[*WebConn]bool + register chan *WebConn + unregister chan *WebConn + broadcast chan *model.WebSocketEvent + stop chan string + invalidateUser chan string +} + +var hubs []*Hub = make([]*Hub, 0) + +func NewWebHub() *Hub { + return &Hub{ + register: make(chan *WebConn), + unregister: make(chan *WebConn), + connections: make(map[*WebConn]bool, model.SESSION_CACHE_SIZE), + broadcast: make(chan *model.WebSocketEvent, 4096), + stop: make(chan string), + invalidateUser: make(chan string), + } +} + +func TotalWebsocketConnections() int { + // This is racy, but it's only used for reporting information + // so it's probably OK + count := 0 + for _, hub := range hubs { + count = count + len(hub.connections) + } + + return count +} + +func HubStart() { + l4g.Info(utils.T("api.web_hub.start.starting.debug"), runtime.NumCPU()*2) + + // Total number of hubs is twice the number of CPUs. + hubs = make([]*Hub, runtime.NumCPU()*2) + + for i := 0; i < len(hubs); i++ { + hubs[i] = NewWebHub() + hubs[i].Start() + } +} + +func HubStop() { + l4g.Info(utils.T("api.web_hub.start.stopping.debug")) + + for _, hub := range hubs { + hub.Stop() + } + + hubs = make([]*Hub, 0) +} + +func GetHubForUserId(userId string) *Hub { + hash := fnv.New32a() + hash.Write([]byte(userId)) + index := hash.Sum32() % uint32(len(hubs)) + return hubs[index] +} + +func HubRegister(webConn *WebConn) { + GetHubForUserId(webConn.UserId).Register(webConn) +} + +func HubUnregister(webConn *WebConn) { + GetHubForUserId(webConn.UserId).Unregister(webConn) +} + +func Publish(message *model.WebSocketEvent) { + message.DoPreComputeJson() + for _, hub := range hubs { + hub.Broadcast(message) + } + + if einterfaces.GetClusterInterface() != nil { + einterfaces.GetClusterInterface().Publish(message) + } +} + +func PublishSkipClusterSend(message *model.WebSocketEvent) { + message.DoPreComputeJson() + for _, hub := range hubs { + hub.Broadcast(message) + } +} + +func InvalidateCacheForChannel(channelId string) { + InvalidateCacheForChannelSkipClusterSend(channelId) + + if cluster := einterfaces.GetClusterInterface(); cluster != nil { + cluster.InvalidateCacheForChannel(channelId) + } +} + +func InvalidateCacheForChannelSkipClusterSend(channelId string) { + Srv.Store.User().InvalidateProfilesInChannelCache(channelId) + Srv.Store.Channel().InvalidateMemberCount(channelId) + Srv.Store.Channel().InvalidateChannel(channelId) +} + +func InvalidateCacheForChannelPosts(channelId string) { + InvalidateCacheForChannelPostsSkipClusterSend(channelId) + + if cluster := einterfaces.GetClusterInterface(); cluster != nil { + cluster.InvalidateCacheForChannelPosts(channelId) + } +} + +func InvalidateCacheForChannelPostsSkipClusterSend(channelId string) { + Srv.Store.Post().InvalidateLastPostTimeCache(channelId) +} + +func InvalidateCacheForUser(userId string) { + InvalidateCacheForUserSkipClusterSend(userId) + + if einterfaces.GetClusterInterface() != nil { + einterfaces.GetClusterInterface().InvalidateCacheForUser(userId) + } +} + +func InvalidateCacheForUserSkipClusterSend(userId string) { + Srv.Store.Channel().InvalidateAllChannelMembersForUser(userId) + Srv.Store.User().InvalidateProfilesInChannelCacheByUser(userId) + Srv.Store.User().InvalidatProfileCacheForUser(userId) + + if len(hubs) != 0 { + GetHubForUserId(userId).InvalidateUser(userId) + } +} + +func InvalidateWebConnSessionCacheForUser(userId string) { + if len(hubs) != 0 { + GetHubForUserId(userId).InvalidateUser(userId) + } +} + +func (h *Hub) Register(webConn *WebConn) { + h.register <- webConn + + if webConn.IsAuthenticated() { + webConn.SendHello() + } +} + +func (h *Hub) Unregister(webConn *WebConn) { + h.unregister <- webConn +} + +func (h *Hub) Broadcast(message *model.WebSocketEvent) { + if message != nil { + h.broadcast <- message + } +} + +func (h *Hub) InvalidateUser(userId string) { + h.invalidateUser <- userId +} + +func (h *Hub) Stop() { + h.stop <- "all" +} + +func (h *Hub) Start() { + go func() { + for { + select { + case webCon := <-h.register: + h.connections[webCon] = true + + case webCon := <-h.unregister: + userId := webCon.UserId + if _, ok := h.connections[webCon]; ok { + delete(h.connections, webCon) + close(webCon.Send) + } + + if len(userId) == 0 { + continue + } + + found := false + for webCon := range h.connections { + if userId == webCon.UserId { + found = true + break + } + } + + if !found { + go SetStatusOffline(userId, false) + } + + case userId := <-h.invalidateUser: + for webCon := range h.connections { + if webCon.UserId == userId { + webCon.InvalidateCache() + } + } + + case msg := <-h.broadcast: + for webCon := range h.connections { + if webCon.ShouldSendEvent(msg) { + select { + case webCon.Send <- msg: + default: + l4g.Error(fmt.Sprintf("webhub.broadcast: cannot send, closing websocket for userId=%v", webCon.UserId)) + close(webCon.Send) + delete(h.connections, webCon) + } + } + } + + case <-h.stop: + for webCon := range h.connections { + webCon.WebSocket.Close() + } + + return + } + } + }() +} diff --git a/app/webhook.go b/app/webhook.go new file mode 100644 index 000000000..70ba1d07a --- /dev/null +++ b/app/webhook.go @@ -0,0 +1,189 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "crypto/tls" + "io" + "io/ioutil" + "net/http" + "regexp" + "strings" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +const ( + TRIGGERWORDS_FULL = 0 + TRIGGERWORDS_STARTSWITH = 1 +) + +func handleWebhookEvents(post *model.Post, team *model.Team, channel *model.Channel, user *model.User) *model.AppError { + if !utils.Cfg.ServiceSettings.EnableOutgoingWebhooks { + return nil + } + + if channel.Type != model.CHANNEL_OPEN { + return nil + } + + hchan := Srv.Store.Webhook().GetOutgoingByTeam(team.Id) + result := <-hchan + if result.Err != nil { + return result.Err + } + + hooks := result.Data.([]*model.OutgoingWebhook) + if len(hooks) == 0 { + return nil + } + + splitWords := strings.Fields(post.Message) + if len(splitWords) == 0 { + return nil + } + firstWord := splitWords[0] + + relevantHooks := []*model.OutgoingWebhook{} + for _, hook := range hooks { + if hook.ChannelId == post.ChannelId || len(hook.ChannelId) == 0 { + if hook.ChannelId == post.ChannelId && len(hook.TriggerWords) == 0 { + relevantHooks = append(relevantHooks, hook) + } else if hook.TriggerWhen == TRIGGERWORDS_FULL && hook.HasTriggerWord(firstWord) { + relevantHooks = append(relevantHooks, hook) + } else if hook.TriggerWhen == TRIGGERWORDS_STARTSWITH && hook.TriggerWordStartsWith(firstWord) { + relevantHooks = append(relevantHooks, hook) + } + } + } + + for _, hook := range relevantHooks { + go func(hook *model.OutgoingWebhook) { + payload := &model.OutgoingWebhookPayload{ + Token: hook.Token, + TeamId: hook.TeamId, + TeamDomain: team.Name, + ChannelId: post.ChannelId, + ChannelName: channel.Name, + Timestamp: post.CreateAt, + UserId: post.UserId, + UserName: user.Username, + PostId: post.Id, + Text: post.Message, + TriggerWord: firstWord, + } + var body io.Reader + var contentType string + if hook.ContentType == "application/json" { + body = strings.NewReader(payload.ToJSON()) + contentType = "application/json" + } else { + body = strings.NewReader(payload.ToFormValues()) + contentType = "application/x-www-form-urlencoded" + } + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: *utils.Cfg.ServiceSettings.EnableInsecureOutgoingConnections}, + } + client := &http.Client{Transport: tr} + + for _, url := range hook.CallbackURLs { + go func(url string) { + req, _ := http.NewRequest("POST", url, body) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/json") + if resp, err := client.Do(req); err != nil { + l4g.Error(utils.T("api.post.handle_webhook_events_and_forget.event_post.error"), err.Error()) + } else { + defer func() { + ioutil.ReadAll(resp.Body) + resp.Body.Close() + }() + respProps := model.MapFromJson(resp.Body) + + if text, ok := respProps["text"]; ok { + if _, err := CreateWebhookPost(hook.CreatorId, hook.TeamId, post.ChannelId, text, respProps["username"], respProps["icon_url"], post.Props, post.Type); err != nil { + l4g.Error(utils.T("api.post.handle_webhook_events_and_forget.create_post.error"), err) + } + } + } + }(url) + } + + }(hook) + } + + return nil +} + +func CreateWebhookPost(userId, teamId, channelId, text, overrideUsername, overrideIconUrl string, props model.StringInterface, postType string) (*model.Post, *model.AppError) { + // parse links into Markdown format + linkWithTextRegex := regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`) + text = linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})") + + post := &model.Post{UserId: userId, ChannelId: channelId, Message: text, Type: postType} + post.AddProp("from_webhook", "true") + + if utils.Cfg.ServiceSettings.EnablePostUsernameOverride { + if len(overrideUsername) != 0 { + post.AddProp("override_username", overrideUsername) + } else { + post.AddProp("override_username", model.DEFAULT_WEBHOOK_USERNAME) + } + } + + if utils.Cfg.ServiceSettings.EnablePostIconOverride { + if len(overrideIconUrl) != 0 { + post.AddProp("override_icon_url", overrideIconUrl) + } + } + + if len(props) > 0 { + for key, val := range props { + if key == "attachments" { + if list, success := val.([]interface{}); success { + // parse attachment links into Markdown format + for i, aInt := range list { + attachment := aInt.(map[string]interface{}) + if aText, ok := attachment["text"].(string); ok { + aText = linkWithTextRegex.ReplaceAllString(aText, "[${2}](${1})") + attachment["text"] = aText + list[i] = attachment + } + if aText, ok := attachment["pretext"].(string); ok { + aText = linkWithTextRegex.ReplaceAllString(aText, "[${2}](${1})") + attachment["pretext"] = aText + list[i] = attachment + } + if fVal, ok := attachment["fields"]; ok { + if fields, ok := fVal.([]interface{}); ok { + // parse attachment field links into Markdown format + for j, fInt := range fields { + field := fInt.(map[string]interface{}) + if fValue, ok := field["value"].(string); ok { + fValue = linkWithTextRegex.ReplaceAllString(fValue, "[${2}](${1})") + field["value"] = fValue + fields[j] = field + } + } + attachment["fields"] = fields + list[i] = attachment + } + } + } + post.AddProp(key, list) + } + } else if key != "override_icon_url" && key != "override_username" && key != "from_webhook" { + post.AddProp(key, val) + } + } + } + + if _, err := CreatePost(post, teamId, false); err != nil { + return nil, model.NewLocAppError("CreateWebhookPost", "api.post.create_webhook_post.creating.app_error", nil, "err="+err.Message) + } + + return post, nil +} diff --git a/app/websocket_router.go b/app/websocket_router.go new file mode 100644 index 000000000..984b9d17e --- /dev/null +++ b/app/websocket_router.go @@ -0,0 +1,96 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + l4g "github.com/alecthomas/log4go" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +type webSocketHandler interface { + ServeWebSocket(*WebConn, *model.WebSocketRequest) +} + +type WebSocketRouter struct { + handlers map[string]webSocketHandler +} + +func NewWebSocketRouter() *WebSocketRouter { + router := &WebSocketRouter{} + router.handlers = make(map[string]webSocketHandler) + return router +} + +func (wr *WebSocketRouter) Handle(action string, handler webSocketHandler) { + wr.handlers[action] = handler +} + +func (wr *WebSocketRouter) ServeWebSocket(conn *WebConn, r *model.WebSocketRequest) { + if r.Action == "" { + err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.no_action.app_error", nil, "") + ReturnWebSocketError(conn, r, err) + return + } + + if r.Seq <= 0 { + err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.bad_seq.app_error", nil, "") + ReturnWebSocketError(conn, r, err) + return + } + + if r.Action == model.WEBSOCKET_AUTHENTICATION_CHALLENGE { + token, ok := r.Data["token"].(string) + if !ok { + conn.WebSocket.Close() + return + } + + session, err := GetSession(token) + + if err != nil { + conn.WebSocket.Close() + } else { + go SetStatusOnline(session.UserId, session.Id, false) + + conn.SessionToken = session.Token + conn.UserId = session.UserId + + resp := model.NewWebSocketResponse(model.STATUS_OK, r.Seq, nil) + resp.DoPreComputeJson() + conn.Send <- resp + conn.SendHello() + } + + return + } + + if !conn.IsAuthenticated() { + err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.not_authenticated.app_error", nil, "") + ReturnWebSocketError(conn, r, err) + return + } + + var handler webSocketHandler + if h, ok := wr.handlers[r.Action]; !ok { + err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.bad_action.app_error", nil, "") + ReturnWebSocketError(conn, r, err) + return + } else { + handler = h + } + + handler.ServeWebSocket(conn, r) +} + +func ReturnWebSocketError(conn *WebConn, r *model.WebSocketRequest, err *model.AppError) { + l4g.Error(utils.T("api.web_socket_router.log.error"), r.Seq, conn.UserId, err.SystemMessage(utils.T), err.DetailedError) + + err.DetailedError = "" + errorResp := model.NewWebSocketError(r.Seq, err) + errorResp.DoPreComputeJson() + + conn.Send <- errorResp +} diff --git a/app/webtrc.go b/app/webtrc.go new file mode 100644 index 000000000..b526c96a6 --- /dev/null +++ b/app/webtrc.go @@ -0,0 +1,33 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "crypto/tls" + "encoding/base64" + "net/http" + "strings" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func RevokeWebrtcToken(sessionId string) { + token := base64.StdEncoding.EncodeToString([]byte(sessionId)) + data := make(map[string]string) + data["janus"] = "remove_token" + data["token"] = token + data["transaction"] = model.NewId() + data["admin_secret"] = *utils.Cfg.WebrtcSettings.GatewayAdminSecret + + rq, _ := http.NewRequest("POST", *utils.Cfg.WebrtcSettings.GatewayAdminUrl, strings.NewReader(model.MapToJson(data))) + rq.Header.Set("Content-Type", "application/json") + + // we do not care about the response + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: *utils.Cfg.ServiceSettings.EnableInsecureOutgoingConnections}, + } + httpClient := &http.Client{Transport: tr} + httpClient.Do(rq) +} |