diff options
39 files changed, 1301 insertions, 841 deletions
diff --git a/api/license.go b/api/license.go index 4077c0e46..23e7946c8 100644 --- a/api/license.go +++ b/api/license.go @@ -81,9 +81,24 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { return } - if err := writeFileLocally(data, utils.LicenseLocation()); err != nil { - c.LogAudit("failed - could not save license file") - c.Err = model.NewLocAppError("addLicense", "api.license.add_license.save.app_error", nil, "path="+utils.LicenseLocation()) + record := &model.LicenseRecord{} + record.Id = license.Id + record.Bytes = string(data) + rchan := Srv.Store.License().Save(record) + + sysVar := &model.System{} + sysVar.Name = model.SYSTEM_ACTIVE_LICENSE_ID + sysVar.Value = license.Id + schan := Srv.Store.System().SaveOrUpdate(sysVar) + + if result := <-rchan; result.Err != nil { + c.Err = model.NewLocAppError("addLicense", "api.license.add_license.save.app_error", nil, "err="+result.Err.Error()) + utils.RemoveLicense() + return + } + + if result := <-schan; result.Err != nil { + c.Err = model.NewLocAppError("addLicense", "api.license.add_license.save_active.app_error", nil, "") utils.RemoveLicense() return } @@ -100,9 +115,14 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { func removeLicense(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("") - if ok := utils.RemoveLicense(); !ok { - c.LogAudit("failed - could not remove license file") - c.Err = model.NewLocAppError("removeLicense", "api.license.remove_license.remove.app_error", nil, "") + utils.RemoveLicense() + + sysVar := &model.System{} + sysVar.Name = model.SYSTEM_ACTIVE_LICENSE_ID + sysVar.Value = "" + + if result := <-Srv.Store.System().Update(sysVar); result.Err != nil { + c.Err = model.NewLocAppError("removeLicense", "api.license.remove_license.update.app_error", nil, "") return } diff --git a/api/post.go b/api/post.go index e8345b5e5..c17da262f 100644 --- a/api/post.go +++ b/api/post.go @@ -15,6 +15,7 @@ import ( "net/url" "path/filepath" "regexp" + "sort" "strconv" "strings" "time" @@ -231,6 +232,8 @@ func handlePostEventsAndForget(c *Context, post *model.Post, triggerWebhooks boo tchan := Srv.Store.Team().Get(c.Session.TeamId) cchan := Srv.Store.Channel().Get(post.ChannelId) uchan := Srv.Store.User().Get(post.UserId) + pchan := Srv.Store.User().GetProfiles(c.Session.TeamId) + mchan := Srv.Store.Channel().GetMembers(post.ChannelId) var team *model.Team if result := <-tchan; result.Err != nil { @@ -248,7 +251,24 @@ func handlePostEventsAndForget(c *Context, post *model.Post, triggerWebhooks boo channel = result.Data.(*model.Channel) } - sendNotificationsAndForget(c, post, team, channel) + var profiles map[string]*model.User + if result := <-pchan; result.Err != nil { + l4g.Error(utils.T("api.post.handle_post_events_and_forget.profiles.error"), c.Session.TeamId, result.Err) + return + } else { + profiles = result.Data.(map[string]*model.User) + } + + var members []model.ChannelMember + if result := <-mchan; result.Err != nil { + l4g.Error(utils.T("api.post.handle_post_events_and_forget.members.error"), post.ChannelId, result.Err) + return + } else { + members = result.Data.([]model.ChannelMember) + } + + go sendNotifications(c, post, team, channel, profiles, members) + go checkForOutOfChannelMentions(c, post, channel, profiles, members) var user *model.User if result := <-uchan; result.Err != nil { @@ -413,311 +433,290 @@ func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team } -func sendNotificationsAndForget(c *Context, post *model.Post, team *model.Team, channel *model.Channel) { +func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *model.Channel, profileMap map[string]*model.User, members []model.ChannelMember) { + var channelName string + var bodyText string + var subjectText string - go func() { - // Get a list of user names (to be used as keywords) and ids for the given team - uchan := Srv.Store.User().GetProfiles(c.Session.TeamId) - echan := Srv.Store.Channel().GetMembers(post.ChannelId) + var mentionedUsers []string - var channelName string - var bodyText string - var subjectText string + if _, ok := profileMap[post.UserId]; !ok { + l4g.Error(utils.T("api.post.send_notifications_and_forget.user_id.error"), post.UserId) + return + } + senderName := profileMap[post.UserId].Username - var mentionedUsers []string + toEmailMap := make(map[string]bool) - if result := <-uchan; result.Err != nil { - l4g.Error(utils.T("api.post.send_notifications_and_forget.retrive_profiles.error"), c.Session.TeamId, result.Err) - return + if channel.Type == model.CHANNEL_DIRECT { + + var otherUserId string + if userIds := strings.Split(channel.Name, "__"); userIds[0] == post.UserId { + otherUserId = userIds[1] + channelName = profileMap[userIds[1]].Username } else { - profileMap := result.Data.(map[string]*model.User) + otherUserId = userIds[0] + channelName = profileMap[userIds[0]].Username + } - if _, ok := profileMap[post.UserId]; !ok { - l4g.Error(utils.T("api.post.send_notifications_and_forget.user_id.error"), post.UserId) - return - } - senderName := profileMap[post.UserId].Username + otherUser := profileMap[otherUserId] + sendEmail := true + if _, ok := otherUser.NotifyProps["email"]; ok && otherUser.NotifyProps["email"] == "false" { + sendEmail = false + } + if sendEmail && (otherUser.IsOffline() || otherUser.IsAway()) { + toEmailMap[otherUserId] = true + } - toEmailMap := make(map[string]bool) + } else { + // Find out who is a member of the channel, only keep those profiles + tempProfileMap := make(map[string]*model.User) + for _, member := range members { + tempProfileMap[member.UserId] = profileMap[member.UserId] + } - if channel.Type == model.CHANNEL_DIRECT { + profileMap = tempProfileMap - var otherUserId string - if userIds := strings.Split(channel.Name, "__"); userIds[0] == post.UserId { - otherUserId = userIds[1] - channelName = profileMap[userIds[1]].Username - } else { - otherUserId = userIds[0] - channelName = profileMap[userIds[0]].Username - } + // Build map for keywords + keywordMap := make(map[string][]string) + for _, profile := range profileMap { + if len(profile.NotifyProps["mention_keys"]) > 0 { - otherUser := profileMap[otherUserId] - sendEmail := true - if _, ok := otherUser.NotifyProps["email"]; ok && otherUser.NotifyProps["email"] == "false" { - sendEmail = false + // Add all the user's mention keys + splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",") + for _, k := range splitKeys { + keywordMap[k] = append(keywordMap[strings.ToLower(k)], profile.Id) } - if sendEmail && (otherUser.IsOffline() || otherUser.IsAway()) { - toEmailMap[otherUserId] = true - } - - } else { - - // Find out who is a member of the channel, only keep those profiles - if eResult := <-echan; eResult.Err != nil { - l4g.Error(utils.T("api.post.send_notifications_and_forget.members.error"), post.ChannelId, eResult.Err.Message) - return - } else { - tempProfileMap := make(map[string]*model.User) - members := eResult.Data.([]model.ChannelMember) - for _, member := range members { - tempProfileMap[member.UserId] = profileMap[member.UserId] - } + } - profileMap = tempProfileMap - } + // If turned on, add the user's case sensitive first name + if profile.NotifyProps["first_name"] == "true" { + keywordMap[profile.FirstName] = append(keywordMap[profile.FirstName], profile.Id) + } - // Build map for keywords - keywordMap := make(map[string][]string) - for _, profile := range profileMap { - if len(profile.NotifyProps["mention_keys"]) > 0 { + // Add @all to keywords if user has them turned on + // if profile.NotifyProps["all"] == "true" { + // keywordMap["@all"] = append(keywordMap["@all"], profile.Id) + // } - // Add all the user's mention keys - splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",") - for _, k := range splitKeys { - keywordMap[k] = append(keywordMap[strings.ToLower(k)], profile.Id) - } - } + // Add @channel to keywords if user has them turned on + if profile.NotifyProps["channel"] == "true" { + keywordMap["@channel"] = append(keywordMap["@channel"], profile.Id) + } + } - // If turned on, add the user's case sensitive first name - if profile.NotifyProps["first_name"] == "true" { - keywordMap[profile.FirstName] = append(keywordMap[profile.FirstName], profile.Id) - } + // Build a map as a list of unique user_ids that are mentioned in this post + splitF := func(c rune) bool { + return model.SplitRunes[c] + } + splitMessage := strings.Fields(post.Message) + for _, word := range splitMessage { + var userIds []string - // Add @all to keywords if user has them turned on - // if profile.NotifyProps["all"] == "true" { - // keywordMap["@all"] = append(keywordMap["@all"], profile.Id) - // } + // Non-case-sensitive check for regular keys + if ids, match := keywordMap[strings.ToLower(word)]; match { + userIds = append(userIds, ids...) + } - // Add @channel to keywords if user has them turned on - if profile.NotifyProps["channel"] == "true" { - keywordMap["@channel"] = append(keywordMap["@channel"], profile.Id) - } - } + // Case-sensitive check for first name + if ids, match := keywordMap[word]; match { + userIds = append(userIds, ids...) + } - // Build a map as a list of unique user_ids that are mentioned in this post - splitF := func(c rune) bool { - return model.SplitRunes[c] - } - splitMessage := strings.Fields(post.Message) - for _, word := range splitMessage { - var userIds []string + if len(userIds) == 0 { + // No matches were found with the string split just on whitespace so try further splitting + // the message on punctuation + splitWords := strings.FieldsFunc(word, splitF) + for _, splitWord := range splitWords { // Non-case-sensitive check for regular keys - if ids, match := keywordMap[strings.ToLower(word)]; match { + if ids, match := keywordMap[strings.ToLower(splitWord)]; match { userIds = append(userIds, ids...) } // Case-sensitive check for first name - if ids, match := keywordMap[word]; match { + if ids, match := keywordMap[splitWord]; match { userIds = append(userIds, ids...) } - - if len(userIds) == 0 { - // No matches were found with the string split just on whitespace so try further splitting - // the message on punctuation - splitWords := strings.FieldsFunc(word, splitF) - - for _, splitWord := range splitWords { - // Non-case-sensitive check for regular keys - if ids, match := keywordMap[strings.ToLower(splitWord)]; match { - userIds = append(userIds, ids...) - } - - // Case-sensitive check for first name - if ids, match := keywordMap[splitWord]; match { - userIds = append(userIds, ids...) - } - } - } - - for _, userId := range userIds { - if post.UserId == userId { - continue - } - sendEmail := true - if _, ok := profileMap[userId].NotifyProps["email"]; ok && profileMap[userId].NotifyProps["email"] == "false" { - sendEmail = false - } - if sendEmail && (profileMap[userId].IsAway() || profileMap[userId].IsOffline()) { - toEmailMap[userId] = true - } else { - toEmailMap[userId] = false - } - } } + } - for id := range toEmailMap { - updateMentionCountAndForget(post.ChannelId, id) + for _, userId := range userIds { + if post.UserId == userId { + continue + } + sendEmail := true + if _, ok := profileMap[userId].NotifyProps["email"]; ok && profileMap[userId].NotifyProps["email"] == "false" { + sendEmail = false + } + if sendEmail && (profileMap[userId].IsAway() || profileMap[userId].IsOffline()) { + toEmailMap[userId] = true + } else { + toEmailMap[userId] = false } } + } - if len(toEmailMap) != 0 { - mentionedUsers = make([]string, 0, len(toEmailMap)) - for k := range toEmailMap { - mentionedUsers = append(mentionedUsers, k) - } + for id := range toEmailMap { + updateMentionCountAndForget(post.ChannelId, id) + } + } - teamURL := c.GetSiteURL() + "/" + team.Name + if len(toEmailMap) != 0 { + mentionedUsers = make([]string, 0, len(toEmailMap)) + for k := range toEmailMap { + mentionedUsers = append(mentionedUsers, k) + } - // Build and send the emails - tm := time.Unix(post.CreateAt/1000, 0) + teamURL := c.GetSiteURL() + "/" + team.Name - for id, doSend := range toEmailMap { + // Build and send the emails + tm := time.Unix(post.CreateAt/1000, 0) - if !doSend { - continue - } + for id, doSend := range toEmailMap { - // skip if inactive - if profileMap[id].DeleteAt > 0 { - continue - } + if !doSend { + continue + } - userLocale := utils.GetUserTranslations(profileMap[id].Locale) + // skip if inactive + if profileMap[id].DeleteAt > 0 { + continue + } - 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") - } else { - bodyText = userLocale("api.post.send_notifications_and_forget.mention_body") - subjectText = userLocale("api.post.send_notifications_and_forget.mention_subject") - channelName = channel.DisplayName - } + userLocale := utils.GetUserTranslations(profileMap[id].Locale) - month := userLocale(tm.Month().String()) - day := fmt.Sprintf("%d", tm.Day()) - year := fmt.Sprintf("%d", tm.Year()) - zone, _ := tm.Zone() - - subjectPage := NewServerTemplatePage("post_subject", c.Locale) - subjectPage.Props["Subject"] = userLocale("api.templates.post_subject", - map[string]interface{}{"SubjectText": subjectText, "TeamDisplayName": team.DisplayName, - "Month": month[:3], "Day": day, "Year": year}) - - bodyPage := NewServerTemplatePage("post_body", c.Locale) - bodyPage.Props["SiteURL"] = c.GetSiteURL() - bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message) - bodyPage.Props["TeamLink"] = teamURL + "/channels/" + channel.Name - 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})) - - // attempt to fill in a message body if the post doesn't have any text - if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 && len(post.Filenames) > 0 { - // extract the filenames from their paths and determine what type of files are attached - filenames := make([]string, len(post.Filenames)) - onlyImages := true - for i, filename := range post.Filenames { - var err error - if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil { - // this should never error since filepath was escaped using url.QueryEscape - filenames[i] = filepath.Base(filename) - } + 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") + } else { + bodyText = userLocale("api.post.send_notifications_and_forget.mention_body") + subjectText = userLocale("api.post.send_notifications_and_forget.mention_subject") + channelName = channel.DisplayName + } - ext := filepath.Ext(filename) - onlyImages = onlyImages && model.IsFileExtImage(ext) - } - filenamesString := strings.Join(filenames, ", ") + month := userLocale(tm.Month().String()) + day := fmt.Sprintf("%d", tm.Day()) + year := fmt.Sprintf("%d", tm.Year()) + zone, _ := tm.Zone() + + subjectPage := NewServerTemplatePage("post_subject", c.Locale) + subjectPage.Props["Subject"] = userLocale("api.templates.post_subject", + map[string]interface{}{"SubjectText": subjectText, "TeamDisplayName": team.DisplayName, + "Month": month[:3], "Day": day, "Year": year}) + + bodyPage := NewServerTemplatePage("post_body", c.Locale) + bodyPage.Props["SiteURL"] = c.GetSiteURL() + bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message) + bodyPage.Props["TeamLink"] = teamURL + "/channels/" + channel.Name + 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})) + + // attempt to fill in a message body if the post doesn't have any text + if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 && len(post.Filenames) > 0 { + // extract the filenames from their paths and determine what type of files are attached + filenames := make([]string, len(post.Filenames)) + onlyImages := true + for i, filename := range post.Filenames { + var err error + if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil { + // this should never error since filepath was escaped using url.QueryEscape + filenames[i] = filepath.Base(filename) + } - var attachmentPrefix string - if onlyImages { - attachmentPrefix = "Image" - } else { - attachmentPrefix = "File" - } - if len(post.Filenames) > 1 { - attachmentPrefix += "s" - } + ext := filepath.Ext(filename) + onlyImages = onlyImages && model.IsFileExtImage(ext) + } + filenamesString := strings.Join(filenames, ", ") - bodyPage.Props["PostMessage"] = userLocale("api.post.send_notifications_and_forget.sent", - map[string]interface{}{"Prefix": attachmentPrefix, "Filenames": filenamesString}) - } + var attachmentPrefix string + if onlyImages { + attachmentPrefix = "Image" + } else { + attachmentPrefix = "File" + } + if len(post.Filenames) > 1 { + attachmentPrefix += "s" + } - if err := utils.SendMail(profileMap[id].Email, subjectPage.Render(), bodyPage.Render()); err != nil { - l4g.Error(utils.T("api.post.send_notifications_and_forget.send.error"), profileMap[id].Email, err) - } + bodyPage.Props["PostMessage"] = userLocale("api.post.send_notifications_and_forget.sent", + map[string]interface{}{"Prefix": attachmentPrefix, "Filenames": filenamesString}) + } - if *utils.Cfg.EmailSettings.SendPushNotifications { - sessionChan := Srv.Store.Session().GetSessions(id) - if result := <-sessionChan; result.Err != nil { - l4g.Error(utils.T("api.post.send_notifications_and_forget.sessions.error"), id, result.Err) - } else { - sessions := result.Data.([]*model.Session) - alreadySeen := make(map[string]string) - - for _, session := range sessions { - if len(session.DeviceId) > 0 && alreadySeen[session.DeviceId] == "" && - (strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") || strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":")) { - alreadySeen[session.DeviceId] = session.DeviceId - - msg := model.PushNotification{} - msg.Badge = 1 - msg.ServerId = utils.CfgDiagnosticId - - if strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") { - msg.Platform = model.PUSH_NOTIFY_APPLE - msg.DeviceId = strings.TrimPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") - } else if strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":") { - msg.Platform = model.PUSH_NOTIFY_ANDROID - msg.DeviceId = strings.TrimPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":") - } + if err := utils.SendMail(profileMap[id].Email, subjectPage.Render(), bodyPage.Render()); err != nil { + l4g.Error(utils.T("api.post.send_notifications_and_forget.send.error"), profileMap[id].Email, err) + } - if channel.Type == model.CHANNEL_DIRECT { - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") - } else { - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName - } + if *utils.Cfg.EmailSettings.SendPushNotifications { + sessionChan := Srv.Store.Session().GetSessions(id) + if result := <-sessionChan; result.Err != nil { + l4g.Error(utils.T("api.post.send_notifications_and_forget.sessions.error"), id, result.Err) + } else { + sessions := result.Data.([]*model.Session) + alreadySeen := make(map[string]string) + + for _, session := range sessions { + if len(session.DeviceId) > 0 && alreadySeen[session.DeviceId] == "" && + (strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") || strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":")) { + alreadySeen[session.DeviceId] = session.DeviceId + + msg := model.PushNotification{} + msg.Badge = 1 + msg.ServerId = utils.CfgDiagnosticId + + if strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") { + msg.Platform = model.PUSH_NOTIFY_APPLE + msg.DeviceId = strings.TrimPrefix(session.DeviceId, model.PUSH_NOTIFY_APPLE+":") + } else if strings.HasPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":") { + msg.Platform = model.PUSH_NOTIFY_ANDROID + msg.DeviceId = strings.TrimPrefix(session.DeviceId, model.PUSH_NOTIFY_ANDROID+":") + } - httpClient := http.Client{} - request, _ := http.NewRequest("POST", *utils.Cfg.EmailSettings.PushNotificationServer+"/api/v1/send_push", strings.NewReader(msg.ToJson())) + if channel.Type == model.CHANNEL_DIRECT { + msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") + } else { + msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName + } - l4g.Debug(utils.T("api.post.send_notifications_and_forget.push_notification.debug"), msg.DeviceId, msg.Message) - if _, err := httpClient.Do(request); err != nil { - l4g.Error(utils.T("api.post.send_notifications_and_forget.push_notification.error"), id, err) - } - } + httpClient := http.Client{} + request, _ := http.NewRequest("POST", *utils.Cfg.EmailSettings.PushNotificationServer+"/api/v1/send_push", strings.NewReader(msg.ToJson())) + + l4g.Debug(utils.T("api.post.send_notifications_and_forget.push_notification.debug"), msg.DeviceId, msg.Message) + if _, err := httpClient.Do(request); err != nil { + l4g.Error(utils.T("api.post.send_notifications_and_forget.push_notification.error"), id, err) } } } } } } + } - message := model.NewMessage(c.Session.TeamId, post.ChannelId, post.UserId, model.ACTION_POSTED) - message.Add("post", post.ToJson()) - message.Add("channel_type", channel.Type) + message := model.NewMessage(c.Session.TeamId, post.ChannelId, post.UserId, model.ACTION_POSTED) + message.Add("post", post.ToJson()) + message.Add("channel_type", channel.Type) - if len(post.Filenames) != 0 { - message.Add("otherFile", "true") + if len(post.Filenames) != 0 { + message.Add("otherFile", "true") - for _, filename := range post.Filenames { - ext := filepath.Ext(filename) - if model.IsFileExtImage(ext) { - message.Add("image", "true") - break - } + for _, filename := range post.Filenames { + ext := filepath.Ext(filename) + if model.IsFileExtImage(ext) { + message.Add("image", "true") + break } } + } - if len(mentionedUsers) != 0 { - message.Add("mentions", model.ArrayToJson(mentionedUsers)) - } + if len(mentionedUsers) != 0 { + message.Add("mentions", model.ArrayToJson(mentionedUsers)) + } - PublishAndForget(message) - }() + PublishAndForget(message) } func updateMentionCountAndForget(channelId, userId string) { @@ -728,6 +727,95 @@ func updateMentionCountAndForget(channelId, userId string) { }() } +func checkForOutOfChannelMentions(c *Context, post *model.Post, channel *model.Channel, allProfiles map[string]*model.User, members []model.ChannelMember) { + // don't check for out of channel mentions in direct channels + if channel.Type == model.CHANNEL_DIRECT { + return + } + + mentioned := getOutOfChannelMentions(post, allProfiles, members) + if len(mentioned) == 0 { + return + } + + usernames := make([]string, len(mentioned)) + for i, user := range mentioned { + usernames[i] = user.Username + } + sort.Strings(usernames) + + var message string + if len(usernames) == 1 { + message = c.T("api.post.check_for_out_of_channel_mentions.message.one", map[string]interface{}{ + "Username": usernames[0], + }) + } else { + message = c.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( + c.Session.TeamId, + post.UserId, + &model.Post{ + ChannelId: post.ChannelId, + Message: message, + CreateAt: post.CreateAt + 1, + }, + ) +} + +// Gets a list of users that were mentioned in a given post that aren't in the channel that the post was made in +func getOutOfChannelMentions(post *model.Post, allProfiles map[string]*model.User, members []model.ChannelMember) []*model.User { + // copy the profiles map since we'll be removing items from it + profiles := make(map[string]*model.User) + for id, profile := range allProfiles { + profiles[id] = profile + } + + // only keep profiles which aren't in the current channel + for _, member := range members { + delete(profiles, member.UserId) + } + + var mentioned []*model.User + + for _, profile := range profiles { + if pattern, err := regexp.Compile(`(\W|^)@` + regexp.QuoteMeta(profile.Username) + `(\W|$)`); err != nil { + l4g.Error(utils.T("api.post.get_out_of_channel_mentions.regex.error"), profile.Id, err) + } else if pattern.MatchString(post.Message) { + mentioned = append(mentioned, profile) + } + } + + return mentioned +} + +func SendEphemeralPost(teamId, userId string, 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{} + } + if post.Filenames == nil { + post.Filenames = []string{} + } + + message := model.NewMessage(teamId, post.ChannelId, userId, model.ACTION_EPHEMERAL_MESSAGE) + message.Add("post", post.ToJson()) + + PublishAndForget(message) +} + func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { post := model.PostFromJson(r.Body) diff --git a/api/post_test.go b/api/post_test.go index 1a9fd2579..027043766 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -8,6 +8,7 @@ import ( "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" "net/http" + "strings" "testing" "time" ) @@ -857,3 +858,97 @@ func TestMakeDirectChannelVisible(t *testing.T) { t.Fatal("Failed to set direct channel to be visible for user2") } } + +func TestGetOutOfChannelMentions(t *testing.T) { + Setup() + + team1 := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Type: model.TEAM_OPEN} + team1 = Client.Must(Client.CreateTeam(team1)).Data.(*model.Team) + + user1 := &model.User{TeamId: team1.Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd", Username: "user1"} + user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user1.Id)) + + user2 := &model.User{TeamId: team1.Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd", Username: "user2"} + user2 = Client.Must(Client.CreateUser(user2, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user2.Id)) + + user3 := &model.User{TeamId: team1.Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd", Username: "user3"} + user3 = Client.Must(Client.CreateUser(user3, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user3.Id)) + + Client.Must(Client.LoginByEmail(team1.Name, user1.Email, "pwd")) + + channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team1.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + var allProfiles map[string]*model.User + if result := <-Srv.Store.User().GetProfiles(team1.Id); result.Err != nil { + t.Fatal(result.Err) + } else { + allProfiles = result.Data.(map[string]*model.User) + } + + var members []model.ChannelMember + if result := <-Srv.Store.Channel().GetMembers(channel1.Id); result.Err != nil { + t.Fatal(result.Err) + } else { + members = result.Data.([]model.ChannelMember) + } + + // test a post that doesn't @mention anybody + post1 := &model.Post{ChannelId: channel1.Id, Message: "user1 user2 user3"} + if mentioned := getOutOfChannelMentions(post1, allProfiles, members); len(mentioned) != 0 { + t.Fatalf("getOutOfChannelMentions returned %v when no users were mentioned", mentioned) + } + + // test a post that @mentions someone in the channel + post2 := &model.Post{ChannelId: channel1.Id, Message: "@user1 is user1"} + if mentioned := getOutOfChannelMentions(post2, allProfiles, members); len(mentioned) != 0 { + t.Fatalf("getOutOfChannelMentions returned %v when only users in the channel were mentioned", mentioned) + } + + // test a post that @mentions someone not in the channel + post3 := &model.Post{ChannelId: channel1.Id, Message: "@user2 and @user3 aren't in the channel"} + if mentioned := getOutOfChannelMentions(post3, allProfiles, members); len(mentioned) != 2 || (mentioned[0].Id != user2.Id && mentioned[0].Id != user3.Id) || (mentioned[1].Id != user2.Id && mentioned[1].Id != user3.Id) { + t.Fatalf("getOutOfChannelMentions returned %v when two users outside the channel were mentioned", mentioned) + } + + // test a post that @mentions someone not in the channel as well as someone in the channel + post4 := &model.Post{ChannelId: channel1.Id, Message: "@user2 and @user1 might be in the channel"} + if mentioned := getOutOfChannelMentions(post4, allProfiles, members); len(mentioned) != 1 || mentioned[0].Id != user2.Id { + t.Fatalf("getOutOfChannelMentions returned %v when someone in the channel and someone outside the channel were mentioned", mentioned) + } + + Client.Must(Client.Logout()) + + team2 := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Type: model.TEAM_OPEN} + team2 = Client.Must(Client.CreateTeam(team2)).Data.(*model.Team) + + user4 := &model.User{TeamId: team2.Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd", Username: "user4"} + user4 = Client.Must(Client.CreateUser(user4, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user4.Id)) + + Client.Must(Client.LoginByEmail(team2.Name, user4.Email, "pwd")) + + channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team2.Id} + channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) + + if result := <-Srv.Store.User().GetProfiles(team2.Id); result.Err != nil { + t.Fatal(result.Err) + } else { + allProfiles = result.Data.(map[string]*model.User) + } + + if result := <-Srv.Store.Channel().GetMembers(channel2.Id); result.Err != nil { + t.Fatal(result.Err) + } else { + members = result.Data.([]model.ChannelMember) + } + + // test a post that @mentions someone on a different team + post5 := &model.Post{ChannelId: channel2.Id, Message: "@user2 and @user3 might be in the channel"} + if mentioned := getOutOfChannelMentions(post5, allProfiles, members); len(mentioned) != 0 { + t.Fatalf("getOutOfChannelMentions returned %v when two users on a different team were mentioned", mentioned) + } +} diff --git a/api/web_team_hub.go b/api/web_team_hub.go index 55300c828..9d1c56f15 100644 --- a/api/web_team_hub.go +++ b/api/web_team_hub.go @@ -101,6 +101,9 @@ func ShouldSendEvent(webCon *WebConn, msg *model.Message) bool { return false } else if msg.Action == model.ACTION_PREFERENCE_CHANGED { return false + } else if msg.Action == model.ACTION_EPHEMERAL_MESSAGE { + // For now, ephemeral messages are sent directly to individual users + return false } // Only report events to a user who is the subject of the event, or is in the channel of the event diff --git a/i18n/en.json b/i18n/en.json index 74a873204..88d857fce 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -612,6 +612,10 @@ "translation": "License did not save properly." }, { + "id": "api.license.add_license.save_active.app_error", + "translation": "Active license ID did not save properly." + }, + { "id": "api.license.add_license.unique_users.app_error", "translation": "This license only supports {{.Users}} users, when your system has {{.Count}} unique users. Unique users are counted distinctly by email address. You can see total user count under Site Reports -> View Statistics." }, @@ -676,6 +680,14 @@ "translation": "Error getting access token from DB before deletion" }, { + "id": "api.post.check_for_out_of_channel_mentions.message.one", + "translation": "{{.Username}} was mentioned, but they do not belong to this channel." + }, + { + "id": "api.post.check_for_out_of_channel_mentions.message.multiple", + "translation": "{{.Usernames}} and {{.LastUsername}} were mentioned, but they do not belong to this channel." + }, + { "id": "api.post.create_post.bad_filename.error", "translation": "Bad filename discarded, filename=%v" }, @@ -704,6 +716,10 @@ "translation": "You do not have the appropriate permissions" }, { + "id": "api.post.get_out_of_channel_mentions.regex.error", + "translation": "Failed to compile @mention regex user_id=%v, err=%v" + }, + { "id": "api.post.get_post.permissions.app_error", "translation": "You do not have the appropriate permissions" }, @@ -712,6 +728,14 @@ "translation": "Encountered error getting channel, channel_id=%s, err=%v" }, { + "id": "api.post.handle_post_events_and_forget.members.error", + "translation": "Failed to get channel members channel_id=%v err=%v" + }, + { + "id": "api.post.handle_post_events_and_forget.profiles.error", + "translation": "Failed to retrieve user profiles team_id=%v, err=%v" + }, + { "id": "api.post.handle_post_events_and_forget.team.error", "translation": "Encountered error getting team, team_id=%s, err=%v" }, @@ -752,10 +776,6 @@ "translation": "Failed to update direct channel preference user_id=%v other_user_id=%v err=%v" }, { - "id": "api.post.send_notifications_and_forget.members.error", - "translation": "Failed to get channel members channel_id=%v err=%v" - }, - { "id": "api.post.send_notifications_and_forget.mention_body", "translation": "You have one new mention." }, @@ -788,10 +808,6 @@ "translation": "Failed to send push notificationid=%v, err=%v" }, { - "id": "api.post.send_notifications_and_forget.retrive_profiles.error", - "translation": "Failed to retrieve user profiles team_id=%v, err=%v" - }, - { "id": "api.post.send_notifications_and_forget.send.error", "translation": "Failed to send mention email successfully email=%v err=%v" }, @@ -1732,6 +1748,10 @@ "translation": "Unable to get channels" }, { + "id": "mattermost.load_license.find.warn", + "translation": "Unable to find active license" + }, + { "id": "mattermost.bulletin.subject", "translation": "Mattermost Security Bulletin" }, @@ -2916,6 +2936,18 @@ "translation": "We encountered an error updating the system property" }, { + "id": "store.sql_license.save.app_error", + "translation": "We encountered an error saving the license" + }, + { + "id": "store.sql_license.get.app_error", + "translation": "We encountered an error getting the license" + }, + { + "id": "store.sql_license.get.missing.app_error", + "translation": "A license with that ID was not found" + }, + { "id": "store.sql_team.get.find.app_error", "translation": "We couldn't find the existing team" }, @@ -3204,10 +3236,6 @@ "translation": "No valid enterprise license found" }, { - "id": "utils.license.load_license.open_find.warn", - "translation": "Unable to open/find license file" - }, - { "id": "utils.license.remove_license.unable.error", "translation": "Unable to remove license file, err=%v" }, @@ -3531,4 +3559,4 @@ "id": "web.watcher_fail.error", "translation": "Failed to add directory to watcher %v" } -]
\ No newline at end of file +] diff --git a/i18n/es.json b/i18n/es.json index 2fe02b2b0..6bc1fd602 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -712,6 +712,14 @@ "translation": "Se encontró un error obteniendo el canal, channel_id=%s, err=%v" }, { + "id": "api.post.handle_post_events_and_forget.members.error", + "translation": "Falla al obtener los miembros del canal channel_id=%v err=%v" + }, + { + "id": "api.post.handle_post_events_and_forget.profiles.error", + "translation": "Falla al recuperar los perfiles de usuario team_id=%v, err=%v" + }, + { "id": "api.post.handle_post_events_and_forget.team.error", "translation": "Se encontró un error obteniendo el equipo, team_id=%s, err=%v" }, @@ -752,10 +760,6 @@ "translation": "Falla al actualizar las preferencias del canal directo user_id=%v other_user_id=%v err=%v" }, { - "id": "api.post.send_notifications_and_forget.members.error", - "translation": "Falla al obtener los miembros del canal channel_id=%v err=%v" - }, - { "id": "api.post.send_notifications_and_forget.mention_body", "translation": "Tienes una mención nueva." }, @@ -788,10 +792,6 @@ "translation": "Falló el envio de la notificación push notificationid=%v, err=%v" }, { - "id": "api.post.send_notifications_and_forget.retrive_profiles.error", - "translation": "Falla al recuperar los perfiles de usuario team_id=%v, err=%v" - }, - { "id": "api.post.send_notifications_and_forget.send.error", "translation": "Falla al enviar el correo con la mención satisfactoriamente email=%v err=%v" }, @@ -3204,10 +3204,6 @@ "translation": "No se encontró una licencia enterprise válida" }, { - "id": "utils.license.load_license.open_find.warn", - "translation": "No pudimos encontrar/abrir el achivo de licencia" - }, - { "id": "utils.license.remove_license.unable.error", "translation": "No se pudo remover el archivo de la licencia, err=%v" }, @@ -3531,4 +3527,4 @@ "id": "web.watcher_fail.error", "translation": "Falla al agregar el directorio a ser vigilado %v" } -]
\ No newline at end of file +] diff --git a/mattermost.go b/mattermost.go index 43fa06601..5a18e2f40 100644 --- a/mattermost.go +++ b/mattermost.go @@ -69,7 +69,7 @@ func main() { web.InitWeb() if model.BuildEnterpriseReady == "true" { - utils.LoadLicense() + loadLicense() } if flagRunCmds { @@ -95,6 +95,26 @@ func main() { } } +func loadLicense() { + licenseId := "" + if result := <-api.Srv.Store.System().Get(); result.Err == nil { + props := result.Data.(model.StringMap) + licenseId = props[model.SYSTEM_ACTIVE_LICENSE_ID] + } + + if len(licenseId) != 26 { + l4g.Warn(utils.T("mattermost.load_license.find.warn")) + return + } + + if result := <-api.Srv.Store.License().Get(licenseId); result.Err == nil { + record := result.Data.(*model.LicenseRecord) + utils.LoadLicense([]byte(record.Bytes)) + } else { + l4g.Warn(utils.T("mattermost.load_license.find.warn")) + } +} + func setDiagnosticId() { if result := <-api.Srv.Store.System().Get(); result.Err == nil { props := result.Data.(model.StringMap) diff --git a/model/license.go b/model/license.go index a271b46b7..ea66fef0d 100644 --- a/model/license.go +++ b/model/license.go @@ -8,6 +8,12 @@ import ( "io" ) +type LicenseRecord struct { + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + Bytes string `json:"-"` +} + type License struct { Id string `json:"id"` IssuedAt int64 `json:"issued_at"` @@ -83,3 +89,23 @@ func LicenseFromJson(data io.Reader) *License { return nil } } + +func (lr *LicenseRecord) IsValid() *AppError { + if len(lr.Id) != 26 { + return NewLocAppError("LicenseRecord.IsValid", "model.license_record.is_valid.id.app_error", nil, "") + } + + if lr.CreateAt == 0 { + return NewLocAppError("LicenseRecord.IsValid", "model.license_record.is_valid.create_at.app_error", nil, "") + } + + if len(lr.Bytes) == 0 || len(lr.Bytes) > 10000 { + return NewLocAppError("LicenseRecord.IsValid", "model.license_record.is_valid.create_at.app_error", nil, "") + } + + return nil +} + +func (lr *LicenseRecord) PreSave() { + lr.CreateAt = GetMillis() +} diff --git a/model/message.go b/model/message.go index 1cb350bbf..cce0ec094 100644 --- a/model/message.go +++ b/model/message.go @@ -18,6 +18,7 @@ const ( ACTION_USER_ADDED = "user_added" ACTION_USER_REMOVED = "user_removed" ACTION_PREFERENCE_CHANGED = "preference_changed" + ACTION_EPHEMERAL_MESSAGE = "ephemeral_message" ) type Message struct { diff --git a/model/post.go b/model/post.go index f9f5a4d1c..8a451831c 100644 --- a/model/post.go +++ b/model/post.go @@ -13,8 +13,10 @@ const ( POST_SYSTEM_MESSAGE_PREFIX = "system_" POST_DEFAULT = "" POST_SLACK_ATTACHMENT = "slack_attachment" + POST_SYSTEM_GENERIC = "system_generic" POST_JOIN_LEAVE = "system_join_leave" POST_HEADER_CHANGE = "system_header_change" + POST_EPHEMERAL = "system_ephemeral" ) type Post struct { diff --git a/model/system.go b/model/system.go index 70db529d5..b387749f6 100644 --- a/model/system.go +++ b/model/system.go @@ -12,6 +12,7 @@ const ( SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" + SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" ) type System struct { diff --git a/store/sql_license_store.go b/store/sql_license_store.go new file mode 100644 index 000000000..f5d67bc5d --- /dev/null +++ b/store/sql_license_store.go @@ -0,0 +1,83 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" +) + +type SqlLicenseStore struct { + *SqlStore +} + +func NewSqlLicenseStore(sqlStore *SqlStore) LicenseStore { + ls := &SqlLicenseStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.LicenseRecord{}, "Licenses").SetKeys(false, "Id") + table.ColMap("Id").SetMaxSize(26) + table.ColMap("Bytes").SetMaxSize(10000) + } + + return ls +} + +func (ls SqlLicenseStore) UpgradeSchemaIfNeeded() { +} + +func (ls SqlLicenseStore) CreateIndexesIfNotExists() { +} + +func (ls SqlLicenseStore) Save(license *model.LicenseRecord) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + license.PreSave() + if result.Err = license.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + // Only insert if not exists + if err := ls.GetReplica().SelectOne(&model.LicenseRecord{}, "SELECT * FROM Licenses WHERE Id = :Id", map[string]interface{}{"Id": license.Id}); err != nil { + if err := ls.GetMaster().Insert(license); err != nil { + result.Err = model.NewLocAppError("SqlLicenseStore.Save", "store.sql_license.save.app_error", nil, "license_id="+license.Id+", "+err.Error()) + } else { + result.Data = license + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (ls SqlLicenseStore) Get(id string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if obj, err := ls.GetReplica().Get(model.LicenseRecord{}, id); err != nil { + result.Err = model.NewLocAppError("SqlLicenseStore.Get", "store.sql_license.get.app_error", nil, "license_id="+id+", "+err.Error()) + } else if obj == nil { + result.Err = model.NewLocAppError("SqlLicenseStore.Get", "store.sql_license.get.missing.app_error", nil, "license_id="+id) + } else { + result.Data = obj.(*model.LicenseRecord) + } + + storeChannel <- result + close(storeChannel) + + }() + + return storeChannel +} diff --git a/store/sql_license_store_test.go b/store/sql_license_store_test.go new file mode 100644 index 000000000..ad24a6af7 --- /dev/null +++ b/store/sql_license_store_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "testing" +) + +func TestLicenseStoreSave(t *testing.T) { + Setup() + + l1 := model.LicenseRecord{} + l1.Id = model.NewId() + l1.Bytes = "junk" + + if err := (<-store.License().Save(&l1)).Err; err != nil { + t.Fatal("couldn't save license record", err) + } + + if err := (<-store.License().Save(&l1)).Err; err != nil { + t.Fatal("shouldn't fail on trying to save existing license record", err) + } +} + +func TestLicenseStoreGet(t *testing.T) { + Setup() + + l1 := model.LicenseRecord{} + l1.Id = model.NewId() + l1.Bytes = "junk" + + Must(store.License().Save(&l1)) + + if r := <-store.License().Get(l1.Id); r.Err != nil { + t.Fatal("couldn't get license", r.Err) + } else { + if r.Data.(*model.LicenseRecord).Bytes != l1.Bytes { + t.Fatal("license bytes didn't match") + } + } +} diff --git a/store/sql_store.go b/store/sql_store.go index 8517eb1a2..a994ec57e 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -49,6 +49,7 @@ type SqlStore struct { webhook WebhookStore command CommandStore preference PreferenceStore + license LicenseStore } func NewSqlStore() Store { @@ -103,6 +104,7 @@ func NewSqlStore() Store { sqlStore.webhook = NewSqlWebhookStore(sqlStore) sqlStore.command = NewSqlCommandStore(sqlStore) sqlStore.preference = NewSqlPreferenceStore(sqlStore) + sqlStore.license = NewSqlLicenseStore(sqlStore) err := sqlStore.master.CreateTablesIfNotExists() if err != nil { @@ -120,6 +122,7 @@ func NewSqlStore() Store { sqlStore.webhook.(*SqlWebhookStore).UpgradeSchemaIfNeeded() sqlStore.command.(*SqlCommandStore).UpgradeSchemaIfNeeded() sqlStore.preference.(*SqlPreferenceStore).UpgradeSchemaIfNeeded() + sqlStore.license.(*SqlLicenseStore).UpgradeSchemaIfNeeded() sqlStore.team.(*SqlTeamStore).CreateIndexesIfNotExists() sqlStore.channel.(*SqlChannelStore).CreateIndexesIfNotExists() @@ -132,6 +135,7 @@ func NewSqlStore() Store { sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists() sqlStore.command.(*SqlCommandStore).CreateIndexesIfNotExists() sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists() + sqlStore.license.(*SqlLicenseStore).CreateIndexesIfNotExists() sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures() @@ -523,6 +527,10 @@ func (ss SqlStore) Preference() PreferenceStore { return ss.preference } +func (ss SqlStore) License() LicenseStore { + return ss.license +} + type mattermConverter struct{} func (me mattermConverter) ToDb(val interface{}) (interface{}, error) { diff --git a/store/sql_system_store.go b/store/sql_system_store.go index cfd4a670f..f8da06cec 100644 --- a/store/sql_system_store.go +++ b/store/sql_system_store.go @@ -1,4 +1,4 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. package store @@ -47,6 +47,30 @@ func (s SqlSystemStore) Save(system *model.System) StoreChannel { return storeChannel } +func (s SqlSystemStore) SaveOrUpdate(system *model.System) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if err := s.GetReplica().SelectOne(&model.System{}, "SELECT * FROM Systems WHERE Name = :Name", map[string]interface{}{"Name": system.Name}); err == nil { + if _, err := s.GetMaster().Update(system); err != nil { + result.Err = model.NewLocAppError("SqlSystemStore.SaveOrUpdate", "store.sql_system.update.app_error", nil, "") + } + } else { + if err := s.GetMaster().Insert(system); err != nil { + result.Err = model.NewLocAppError("SqlSystemStore.SaveOrUpdate", "store.sql_system.save.app_error", nil, "") + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlSystemStore) Update(system *model.System) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_system_store_test.go b/store/sql_system_store_test.go index 8ff5445cc..ce149e97a 100644 --- a/store/sql_system_store_test.go +++ b/store/sql_system_store_test.go @@ -31,3 +31,19 @@ func TestSqlSystemStore(t *testing.T) { t.Fatal() } } + +func TestSqlSystemStoreSaveOrUpdate(t *testing.T) { + Setup() + + system := &model.System{Name: model.NewId(), Value: "value"} + + if err := (<-store.System().SaveOrUpdate(system)).Err; err != nil { + t.Fatal(err) + } + + system.Value = "value2" + + if r := <-store.System().SaveOrUpdate(system); r.Err != nil { + t.Fatal(r.Err) + } +} diff --git a/store/store.go b/store/store.go index b6b86e0d9..952b96e87 100644 --- a/store/store.go +++ b/store/store.go @@ -1,4 +1,4 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. package store @@ -39,6 +39,7 @@ type Store interface { Webhook() WebhookStore Command() CommandStore Preference() PreferenceStore + License() LicenseStore MarkSystemRanUnitTests() Close() } @@ -164,6 +165,7 @@ type OAuthStore interface { type SystemStore interface { Save(system *model.System) StoreChannel + SaveOrUpdate(system *model.System) StoreChannel Update(system *model.System) StoreChannel Get() StoreChannel } @@ -203,3 +205,8 @@ type PreferenceStore interface { PermanentDeleteByUser(userId string) StoreChannel IsFeatureEnabled(feature, userId string) StoreChannel } + +type LicenseStore interface { + Save(license *model.LicenseRecord) StoreChannel + Get(id string) StoreChannel +} diff --git a/utils/license.go b/utils/license.go index 0d1cd597c..b773a163e 100644 --- a/utils/license.go +++ b/utils/license.go @@ -1,19 +1,15 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. package utils import ( - "bytes" "crypto" "crypto/rsa" "crypto/sha512" "crypto/x509" "encoding/base64" "encoding/pem" - "io" - "os" - "path/filepath" "strconv" "strings" @@ -22,10 +18,6 @@ import ( "github.com/mattermost/platform/model" ) -const ( - LICENSE_FILENAME = "active.dat" -) - var IsLicensed bool = false var License *model.License = &model.License{} var ClientLicense map[string]string = make(map[string]string) @@ -41,18 +33,8 @@ NxpC+5KFhU+xSeeklNqwCgnlOyZ7qSTxmdJHb+60SwuYnnGIYzLJhY4LYDr4J+KR 1wIDAQAB -----END PUBLIC KEY-----`) -func LoadLicense() { - file, err := os.Open(LicenseLocation()) - if err != nil { - l4g.Warn(T("utils.license.load_license.open_find.warn")) - return - } - defer file.Close() - - buf := bytes.NewBuffer(nil) - io.Copy(buf, file) - - if success, licenseStr := ValidateLicense(buf.Bytes()); success { +func LoadLicense(licenseBytes []byte) { + if success, licenseStr := ValidateLicense(licenseBytes); success { license := model.LicenseFromJson(strings.NewReader(licenseStr)) SetLicense(license) return @@ -74,21 +56,10 @@ func SetLicense(license *model.License) bool { return false } -func LicenseLocation() string { - return filepath.Dir(CfgFileName) + "/" + LICENSE_FILENAME -} - -func RemoveLicense() bool { +func RemoveLicense() { License = &model.License{} IsLicensed = false ClientLicense = getClientLicense(License) - - if err := os.Remove(LicenseLocation()); err != nil { - l4g.Error(T("utils.license.remove_license.unable.error"), err.Error()) - return false - } - - return true } func ValidateLicense(signed []byte) (bool, string) { diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx index 98b1d7cc1..af4d3fb0f 100644 --- a/web/react/components/access_history_modal.jsx +++ b/web/react/components/access_history_modal.jsx @@ -73,7 +73,8 @@ class AccessHistoryModal extends React.Component { content = ( <AuditTable audits={this.state.audits} - moreInfo={this.state.moreInfo} + showIp={true} + showSession={true} /> ); } diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index 360ae3ef3..695e2083a 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -160,7 +160,7 @@ export default class AdminController extends React.Component { } else if (this.state.selected === 'ldap_settings') { tab = <LdapSettingsTab config={this.state.config} />; } else if (this.state.selected === 'license') { - tab = <LicenseSettingsTab />; + tab = <LicenseSettingsTab config={this.state.config} />; } else if (this.state.selected === 'team_users') { if (this.state.teams) { tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />; diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index 642bfe9d7..eadd8d412 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -225,7 +225,7 @@ export default class AdminSidebar extends React.Component { > <FormattedMessage id='admin.sidebar.audits' - defaultMessage='Audits' + defaultMessage='Compliance and Auditing' /> </a> </li> @@ -454,6 +454,7 @@ export default class AdminSidebar extends React.Component { </ul> <ul className='nav nav__sub-menu padded'> {licenseSettings} + {audits} <li> <a href='#' @@ -466,7 +467,6 @@ export default class AdminSidebar extends React.Component { /> </a> </li> - {audits} </ul> </li> </ul> diff --git a/web/react/components/admin_console/audits.jsx b/web/react/components/admin_console/audits.jsx index 866539b3d..173e63b45 100644 --- a/web/react/components/admin_console/audits.jsx +++ b/web/react/components/admin_console/audits.jsx @@ -60,8 +60,9 @@ export default class Audits extends React.Component { <div style={{margin: '10px'}}> <AuditTable audits={this.state.audits} - oneLine={true} showUserId={true} + showIp={true} + showSession={true} /> </div> ); @@ -72,7 +73,7 @@ export default class Audits extends React.Component { <h3> <FormattedMessage id='admin.audits.title' - defaultMessage='Server Audits' + defaultMessage='User Activity' /> </h3> <button diff --git a/web/react/components/admin_console/license_settings.jsx b/web/react/components/admin_console/license_settings.jsx index 539acd869..3332f37ef 100644 --- a/web/react/components/admin_console/license_settings.jsx +++ b/web/react/components/admin_console/license_settings.jsx @@ -109,7 +109,17 @@ class LicenseSettings extends React.Component { ); licenseType = ( <FormattedHTMLMessage - id='admin.license.entrepriseType' + id='admin.license.enterpriseType' + values={{ + terms: global.window.mm_config.TermsOfServiceLink, + name: global.window.mm_license.Name, + company: global.window.mm_license.Company, + users: global.window.mm_license.Users, + issued: Utils.displayDate(parseInt(global.window.mm_license.IssuedAt, 10)) + ' ' + Utils.displayTime(parseInt(global.window.mm_license.IssuedAt, 10), true), + start: Utils.displayDate(parseInt(global.window.mm_license.StartsAt, 10)), + expires: Utils.displayDate(parseInt(global.window.mm_license.ExpiresAt, 10)), + ldap: global.window.mm_license.LDAP + }} defaultMessage='<div><p>This compiled release of Mattermost platform is provided under a <a href="http://mattermost.com" target="_blank">commercial license</a> from Mattermost, Inc. based on your subscription level and is subject to the <a href="{terms}" target="_blank">Terms of Service.</a></p> <p>Your subscription details are as follows:</p> @@ -126,6 +136,7 @@ class LicenseSettings extends React.Component { licenseKey = ( <div className='col-sm-8'> <button + disabled={this.props.config.LdapSettings.Enable} className='btn btn-danger' onClick={this.handleRemove} id='remove-button' @@ -256,7 +267,8 @@ class LicenseSettings extends React.Component { } LicenseSettings.propTypes = { - intl: intlShape.isRequired + intl: intlShape.isRequired, + config: React.PropTypes.object }; -export default injectIntl(LicenseSettings);
\ No newline at end of file +export default injectIntl(LicenseSettings); diff --git a/web/react/components/audit_table.jsx b/web/react/components/audit_table.jsx index cdca7e8d6..49892ff98 100644 --- a/web/react/components/audit_table.jsx +++ b/web/react/components/audit_table.jsx @@ -183,389 +183,388 @@ const holders = defineMessages({ loginFailure: { id: 'audit_table.loginFailure', defaultMessage: ' (Login failure)' - }, - userId: { - id: 'audit_table.userId', - defaultMessage: 'User ID' } }); class AuditTable extends React.Component { constructor(props) { super(props); - - this.handleMoreInfo = this.handleMoreInfo.bind(this); - this.formatAuditInfo = this.formatAuditInfo.bind(this); - this.handleRevokedSession = this.handleRevokedSession.bind(this); - - this.state = {moreInfo: []}; - } - handleMoreInfo(index) { - var newMoreInfo = this.state.moreInfo; - newMoreInfo[index] = true; - this.setState({moreInfo: newMoreInfo}); } - handleRevokedSession(sessionId) { - return this.props.intl.formatMessage(holders.sessionRevoked, {sessionId: sessionId}); - } - formatAuditInfo(currentAudit) { - const currentActionURL = currentAudit.action.replace(/\/api\/v[1-9]/, ''); + render() { + var accessList = []; const {formatMessage} = this.props.intl; - let currentAuditDesc = ''; - - if (currentActionURL.indexOf('/channels') === 0) { - const channelInfo = currentAudit.extra_info.split(' '); - const channelNameField = channelInfo[0].split('='); - - let channelURL = ''; - let channelObj; - let channelName = ''; - if (channelNameField.indexOf('name') >= 0) { - channelURL = channelNameField[channelNameField.indexOf('name') + 1]; - channelObj = ChannelStore.getByName(channelURL); - if (channelObj) { - channelName = channelObj.display_name; - } else { - channelName = channelURL; - } - } + for (var i = 0; i < this.props.audits.length; i++) { + const audit = this.props.audits[i]; + const auditInfo = formatAuditInfo(audit, formatMessage); - switch (currentActionURL) { - case '/channels/create': - currentAuditDesc = formatMessage(holders.channelCreated, {channelName: channelName}); - break; - case '/channels/create_direct': - currentAuditDesc = formatMessage(holders.establishedDM, {username: Utils.getDirectTeammate(channelObj.id).username}); - break; - case '/channels/update': - currentAuditDesc = formatMessage(holders.nameUpdated, {channelName: channelName}); - break; - case '/channels/update_desc': // support the old path - case '/channels/update_header': - currentAuditDesc = formatMessage(holders.headerUpdated, {channelName: channelName}); - break; - default: { - let userIdField = []; - let userId = ''; - let username = ''; - - if (channelInfo[1]) { - userIdField = channelInfo[1].split('='); - - if (userIdField.indexOf('user_id') >= 0) { - userId = userIdField[userIdField.indexOf('user_id') + 1]; - username = UserStore.getProfile(userId).username; - } - } + let uContent; + if (this.props.showUserId) { + uContent = <td>{auditInfo.userId}</td>; + } - if (/\/channels\/[A-Za-z0-9]+\/delete/.test(currentActionURL)) { - currentAuditDesc = formatMessage(holders.channelDeleted, {url: channelURL}); - } else if (/\/channels\/[A-Za-z0-9]+\/add/.test(currentActionURL)) { - currentAuditDesc = formatMessage(holders.userAdded, {username: username, channelName: channelName}); - } else if (/\/channels\/[A-Za-z0-9]+\/remove/.test(currentActionURL)) { - currentAuditDesc = formatMessage(holders.userRemoved, {username: username, channelName: channelName}); - } + let iContent; + if (this.props.showIp) { + iContent = <td>{auditInfo.ip}</td>; + } - break; + let sContent; + if (this.props.showSession) { + sContent = <td>{auditInfo.sessionId}</td>; } + + let descStyle = {}; + if (auditInfo.desc.toLowerCase().indexOf('fail') !== -1) { + descStyle.color = 'red'; } - } else if (currentActionURL.indexOf('/oauth') === 0) { - const oauthInfo = currentAudit.extra_info.split(' '); - switch (currentActionURL) { - case '/oauth/register': { - const clientIdField = oauthInfo[0].split('='); + accessList[i] = ( + <tr key={audit.id}> + <td>{auditInfo.timestamp}</td> + {uContent} + <td style={descStyle}>{auditInfo.desc}</td> + {iContent} + {sContent} + </tr> + ); + } - if (clientIdField[0] === 'client_id') { - currentAuditDesc = formatMessage(holders.attemptedRegisterApp, {id: clientIdField[1]}); - } + let userIdContent; + if (this.props.showUserId) { + userIdContent = ( + <th> + <FormattedMessage + id='audit_table.userId' + defaultMessage='User ID' + /> + </th> + ); + } - break; - } - case '/oauth/allow': - if (oauthInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedAllowOAuthAccess); - } else if (oauthInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullOAuthAccess); - } else if (oauthInfo[0] === 'fail - redirect_uri did not match registered callback') { - currentAuditDesc = formatMessage(holders.failedOAuthAccess); - } + let ipContent; + if (this.props.showIp) { + ipContent = ( + <th> + <FormattedMessage + id='audit_table.ip' + defaultMessage='IP Address' + /> + </th> + ); + } - break; - case '/oauth/access_token': - if (oauthInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedOAuthToken); - } else if (oauthInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullOAuthToken); - } else { - const oauthTokenFailure = oauthInfo[0].split('-'); - - if (oauthTokenFailure[0].trim() === 'fail' && oauthTokenFailure[1]) { - currentAuditDesc = formatMessage(oauthTokenFailure, {token: oauthTokenFailure[1].trim()}); - } - } + let sessionContent; + if (this.props.showSession) { + sessionContent = ( + <th> + <FormattedMessage + id='audit_table.session' + defaultMessage='Session ID' + /> + </th> + ); + } - break; - default: - break; - } - } else if (currentActionURL.indexOf('/users') === 0) { - const userInfo = currentAudit.extra_info.split(' '); - - switch (currentActionURL) { - case '/users/login': - if (userInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedLogin); - } else if (userInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullLogin); - } else if (userInfo[0]) { - currentAuditDesc = formatMessage(holders.failedLogin); - } + return ( + <table className='table'> + <thead> + <tr> + <th> + <FormattedMessage + id='audit_table.timestamp' + defaultMessage='Timestamp' + /> + </th> + {userIdContent} + <th> + <FormattedMessage + id='audit_table.action' + defaultMessage='Action' + /> + </th> + {ipContent} + {sessionContent} + </tr> + </thead> + <tbody> + {accessList} + </tbody> + </table> + ); + } +} - break; - case '/users/revoke_session': - currentAuditDesc = this.handleRevokedSession(userInfo[0].split('=')[1]); - break; - case '/users/newimage': - currentAuditDesc = formatMessage(holders.updatePicture); - break; - case '/users/update': - currentAuditDesc = formatMessage(holders.updateGeneral); - break; - case '/users/newpassword': - if (userInfo[0] === 'attempted') { - currentAuditDesc = formatMessage(holders.attemptedPassword); - } else if (userInfo[0] === 'completed') { - currentAuditDesc = formatMessage(holders.successfullPassword); - } else if (userInfo[0] === 'failed - tried to update user password who was logged in through oauth') { - currentAuditDesc = formatMessage(holders.failedPassword); - } +AuditTable.propTypes = { + intl: intlShape.isRequired, + audits: React.PropTypes.array.isRequired, + showUserId: React.PropTypes.bool, + showIp: React.PropTypes.bool, + showSession: React.PropTypes.bool +}; - break; - case '/users/update_roles': { - const userRoles = userInfo[0].split('=')[1]; +export default injectIntl(AuditTable); - currentAuditDesc = formatMessage(holders.updatedRol); - if (userRoles.trim()) { - currentAuditDesc += userRoles; - } else { - currentAuditDesc += formatMessage(holders.member); +export function formatAuditInfo(audit, formatMessage) { + const actionURL = audit.action.replace(/\/api\/v[1-9]/, ''); + let auditDesc = ''; + + if (actionURL.indexOf('/channels') === 0) { + const channelInfo = audit.extra_info.split(' '); + const channelNameField = channelInfo[0].split('='); + + let channelURL = ''; + let channelObj; + let channelName = ''; + if (channelNameField.indexOf('name') >= 0) { + channelURL = channelNameField[channelNameField.indexOf('name') + 1]; + channelObj = ChannelStore.getByName(channelURL); + if (channelObj) { + channelName = channelObj.display_name; + } else { + channelName = channelURL; + } + } + + switch (actionURL) { + case '/channels/create': + auditDesc = formatMessage(holders.channelCreated, {channelName: channelName}); + break; + case '/channels/create_direct': + auditDesc = formatMessage(holders.establishedDM, {username: Utils.getDirectTeammate(channelObj.id).username}); + break; + case '/channels/update': + auditDesc = formatMessage(holders.nameUpdated, {channelName: channelName}); + break; + case '/channels/update_desc': // support the old path + case '/channels/update_header': + auditDesc = formatMessage(holders.headerUpdated, {channelName: channelName}); + break; + default: { + let userIdField = []; + let userId = ''; + let username = ''; + + if (channelInfo[1]) { + userIdField = channelInfo[1].split('='); + + if (userIdField.indexOf('user_id') >= 0) { + userId = userIdField[userIdField.indexOf('user_id') + 1]; + username = UserStore.getProfile(userId).username; } + } - break; + if (/\/channels\/[A-Za-z0-9]+\/delete/.test(actionURL)) { + auditDesc = formatMessage(holders.channelDeleted, {url: channelURL}); + } else if (/\/channels\/[A-Za-z0-9]+\/add/.test(actionURL)) { + auditDesc = formatMessage(holders.userAdded, {username: username, channelName: channelName}); + } else if (/\/channels\/[A-Za-z0-9]+\/remove/.test(actionURL)) { + auditDesc = formatMessage(holders.userRemoved, {username: username, channelName: channelName}); } - case '/users/update_active': { - const updateType = userInfo[0].split('=')[0]; - const updateField = userInfo[0].split('=')[1]; - - /* Either describes account activation/deactivation or a revoked session as part of an account deactivation */ - if (updateType === 'active') { - if (updateField === 'true') { - currentAuditDesc = formatMessage(holders.accountActive); - } else if (updateField === 'false') { - currentAuditDesc = formatMessage(holders.accountInactive); - } - const actingUserInfo = userInfo[1].split('='); - if (actingUserInfo[0] === 'session_user') { - const actingUser = UserStore.getProfile(actingUserInfo[1]); - const currentUser = UserStore.getCurrentUser(); - if (currentUser && actingUser && (Utils.isAdmin(currentUser.roles) || Utils.isSystemAdmin(currentUser.roles))) { - currentAuditDesc += formatMessage(holders.by, {username: actingUser.username}); - } else if (currentUser && actingUser) { - currentAuditDesc += formatMessage(holders.byAdmin); - } - } - } else if (updateType === 'session_id') { - currentAuditDesc = this.handleRevokedSession(updateField); - } + break; + } + } + } else if (actionURL.indexOf('/oauth') === 0) { + const oauthInfo = audit.extra_info.split(' '); + + switch (actionURL) { + case '/oauth/register': { + const clientIdField = oauthInfo[0].split('='); - break; + if (clientIdField[0] === 'client_id') { + auditDesc = formatMessage(holders.attemptedRegisterApp, {id: clientIdField[1]}); } - case '/users/send_password_reset': - currentAuditDesc = formatMessage(holders.sentEmail, {email: userInfo[0].split('=')[1]}); - break; - case '/users/reset_password': - if (userInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedReset); - } else if (userInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullReset); - } - break; - case '/users/update_notify': - currentAuditDesc = formatMessage(holders.updateGlobalNotifications); - break; - default: - break; + break; + } + case '/oauth/allow': + if (oauthInfo[0] === 'attempt') { + auditDesc = formatMessage(holders.attemptedAllowOAuthAccess); + } else if (oauthInfo[0] === 'success') { + auditDesc = formatMessage(holders.successfullOAuthAccess); + } else if (oauthInfo[0] === 'fail - redirect_uri did not match registered callback') { + auditDesc = formatMessage(holders.failedOAuthAccess); } - } else if (currentActionURL.indexOf('/hooks') === 0) { - const webhookInfo = currentAudit.extra_info.split(' '); - - switch (currentActionURL) { - case '/hooks/incoming/create': - if (webhookInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedWebhookCreate); - } else if (webhookInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.succcessfullWebhookCreate); - } else if (webhookInfo[0] === 'fail - bad channel permissions') { - currentAuditDesc = formatMessage(holders.failedWebhookCreate); - } - break; - case '/hooks/incoming/delete': - if (webhookInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedWebhookDelete); - } else if (webhookInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullWebhookDelete); - } else if (webhookInfo[0] === 'fail - inappropriate conditions') { - currentAuditDesc = formatMessage(holders.failedWebhookDelete); + break; + case '/oauth/access_token': + if (oauthInfo[0] === 'attempt') { + auditDesc = formatMessage(holders.attemptedOAuthToken); + } else if (oauthInfo[0] === 'success') { + auditDesc = formatMessage(holders.successfullOAuthToken); + } else { + const oauthTokenFailure = oauthInfo[0].split('-'); + + if (oauthTokenFailure[0].trim() === 'fail' && oauthTokenFailure[1]) { + auditDesc = formatMessage(oauthTokenFailure, {token: oauthTokenFailure[1].trim()}); } + } - break; - default: - break; + break; + default: + break; + } + } else if (actionURL.indexOf('/users') === 0) { + const userInfo = audit.extra_info.split(' '); + + switch (actionURL) { + case '/users/login': + if (userInfo[0] === 'attempt') { + auditDesc = formatMessage(holders.attemptedLogin); + } else if (userInfo[0] === 'success') { + auditDesc = formatMessage(holders.successfullLogin); + } else if (userInfo[0]) { + auditDesc = formatMessage(holders.failedLogin); } - } else { - switch (currentActionURL) { - case '/logout': - currentAuditDesc = formatMessage(holders.logout); - break; - case '/verify_email': - currentAuditDesc = formatMessage(holders.verified); - break; - default: - break; + + break; + case '/users/revoke_session': + auditDesc = formatMessage(holders.sessionRevoked, {sessionId: userInfo[0].split('=')[1]}); + break; + case '/users/newimage': + auditDesc = formatMessage(holders.updatePicture); + break; + case '/users/update': + auditDesc = formatMessage(holders.updateGeneral); + break; + case '/users/newpassword': + if (userInfo[0] === 'attempted') { + auditDesc = formatMessage(holders.attemptedPassword); + } else if (userInfo[0] === 'completed') { + auditDesc = formatMessage(holders.successfullPassword); + } else if (userInfo[0] === 'failed - tried to update user password who was logged in through oauth') { + auditDesc = formatMessage(holders.failedPassword); } - } - /* If all else fails... */ - if (!currentAuditDesc) { - /* Currently not called anywhere */ - if (currentAudit.extra_info.indexOf('revoked_all=') >= 0) { - currentAuditDesc = formatMessage(holders.revokedAll); + break; + case '/users/update_roles': { + const userRoles = userInfo[0].split('=')[1]; + + auditDesc = formatMessage(holders.updatedRol); + if (userRoles.trim()) { + auditDesc += userRoles; } else { - let currentActionDesc = ''; - if (currentActionURL && currentActionURL.lastIndexOf('/') !== -1) { - currentActionDesc = currentActionURL.substring(currentActionURL.lastIndexOf('/') + 1).replace('_', ' '); - currentActionDesc = Utils.toTitleCase(currentActionDesc); - } + auditDesc += formatMessage(holders.member); + } - let currentExtraInfoDesc = ''; - if (currentAudit.extra_info) { - currentExtraInfoDesc = currentAudit.extra_info; + break; + } + case '/users/update_active': { + const updateType = userInfo[0].split('=')[0]; + const updateField = userInfo[0].split('=')[1]; + + /* Either describes account activation/deactivation or a revoked session as part of an account deactivation */ + if (updateType === 'active') { + if (updateField === 'true') { + auditDesc = formatMessage(holders.accountActive); + } else if (updateField === 'false') { + auditDesc = formatMessage(holders.accountInactive); + } - if (currentExtraInfoDesc.indexOf('=') !== -1) { - currentExtraInfoDesc = currentExtraInfoDesc.substring(currentExtraInfoDesc.indexOf('=') + 1); + const actingUserInfo = userInfo[1].split('='); + if (actingUserInfo[0] === 'session_user') { + const actingUser = UserStore.getProfile(actingUserInfo[1]); + const user = UserStore.getCurrentUser(); + if (user && actingUser && (Utils.isAdmin(user.roles) || Utils.isSystemAdmin(user.roles))) { + auditDesc += formatMessage(holders.by, {username: actingUser.username}); + } else if (user && actingUser) { + auditDesc += formatMessage(holders.byAdmin); } } - currentAuditDesc = currentActionDesc + ' ' + currentExtraInfoDesc; + } else if (updateType === 'session_id') { + auditDesc = formatMessage(holders.sessionRevoked, {sessionId: updateField}); } - } - const currentDate = new Date(currentAudit.create_at); - let currentAuditInfo = currentDate.toLocaleDateString(global.window.mm_locale, {month: 'short', day: '2-digit', year: 'numeric'}) + ' - ' + currentDate.toLocaleTimeString(global.window.mm_locale, {hour: '2-digit', minute: '2-digit'}); + break; + } + case '/users/send_password_reset': + auditDesc = formatMessage(holders.sentEmail, {email: userInfo[0].split('=')[1]}); + break; + case '/users/reset_password': + if (userInfo[0] === 'attempt') { + auditDesc = formatMessage(holders.attemptedReset); + } else if (userInfo[0] === 'success') { + auditDesc = formatMessage(holders.successfullReset); + } - if (this.props.showUserId) { - currentAuditInfo += ' | ' + formatMessage(holders.userId) + ': ' + currentAudit.user_id; + break; + case '/users/update_notify': + auditDesc = formatMessage(holders.updateGlobalNotifications); + break; + default: + break; } + } else if (actionURL.indexOf('/hooks') === 0) { + const webhookInfo = audit.extra_info.split(' '); + + switch (actionURL) { + case '/hooks/incoming/create': + if (webhookInfo[0] === 'attempt') { + auditDesc = formatMessage(holders.attemptedWebhookCreate); + } else if (webhookInfo[0] === 'success') { + auditDesc = formatMessage(holders.succcessfullWebhookCreate); + } else if (webhookInfo[0] === 'fail - bad channel permissions') { + auditDesc = formatMessage(holders.failedWebhookCreate); + } - currentAuditInfo += ' | ' + currentAuditDesc; + break; + case '/hooks/incoming/delete': + if (webhookInfo[0] === 'attempt') { + auditDesc = formatMessage(holders.attemptedWebhookDelete); + } else if (webhookInfo[0] === 'success') { + auditDesc = formatMessage(holders.successfullWebhookDelete); + } else if (webhookInfo[0] === 'fail - inappropriate conditions') { + auditDesc = formatMessage(holders.failedWebhookDelete); + } - return currentAuditInfo; + break; + default: + break; + } + } else { + switch (actionURL) { + case '/logout': + auditDesc = formatMessage(holders.logout); + break; + case '/verify_email': + auditDesc = formatMessage(holders.verified); + break; + default: + break; + } } - render() { - var accessList = []; - const {formatMessage} = this.props.intl; - for (var i = 0; i < this.props.audits.length; i++) { - const currentAudit = this.props.audits[i]; - const currentAuditInfo = this.formatAuditInfo(currentAudit); - - let moreInfo; - if (!this.props.oneLine) { - moreInfo = ( - <a - href='#' - className='theme' - onClick={this.handleMoreInfo.bind(this, i)} - > - <FormattedMessage - id='audit_table.moreInfo' - defaultMessage='More info' - /> - </a> - ); + /* If all else fails... */ + if (!auditDesc) { + /* Currently not called anywhere */ + if (audit.extra_info.indexOf('revoked_all=') >= 0) { + auditDesc = formatMessage(holders.revokedAll); + } else { + let actionDesc = ''; + if (actionURL && actionURL.lastIndexOf('/') !== -1) { + actionDesc = actionURL.substring(actionURL.lastIndexOf('/') + 1).replace('_', ' '); + actionDesc = Utils.toTitleCase(actionDesc); } - if (this.state.moreInfo[i]) { - if (!currentAudit.session_id) { - currentAudit.session_id = 'N/A'; + let extraInfoDesc = ''; + if (audit.extra_info) { + extraInfoDesc = audit.extra_info; - if (currentAudit.action.search('/users/login') >= 0) { - if (currentAudit.extra_info === 'attempt') { - currentAudit.session_id += formatMessage(holders.loginAttempt); - } else { - currentAudit.session_id += formatMessage(holders.loginFailure); - } - } + if (extraInfoDesc.indexOf('=') !== -1) { + extraInfoDesc = extraInfoDesc.substring(extraInfoDesc.indexOf('=') + 1); } - - moreInfo = ( - <div> - <div> - <FormattedMessage - id='audit_table.ip' - defaultMessage='IP: {ip}' - values={{ - ip: currentAudit.ip_address - }} - /> - </div> - <div> - <FormattedMessage - id='audit_table.session' - defaultMessage='Session ID: {id}' - values={{ - id: currentAudit.session_id - }} - /> - </div> - </div> - ); } - - var divider = null; - if (i < this.props.audits.length - 1) { - divider = (<div className='divider-light'></div>); - } - - accessList[i] = ( - <div - key={'accessHistoryEntryKey' + i} - className='access-history__table' - > - <div className='access__report'> - <div className='report__time'>{currentAuditInfo}</div> - <div className='report__info'> - {moreInfo} - </div> - {divider} - </div> - </div> - ); + auditDesc = actionDesc + ' ' + extraInfoDesc; } - - return <form role='form'>{accessList}</form>; } -} -AuditTable.propTypes = { - intl: intlShape.isRequired, - audits: React.PropTypes.array.isRequired, - oneLine: React.PropTypes.bool, - showUserId: React.PropTypes.bool -}; + const date = new Date(audit.create_at); + let auditInfo = {}; + auditInfo.timestamp = date.toLocaleDateString(global.window.mm_locale, {month: 'short', day: '2-digit', year: 'numeric'}) + ' - ' + date.toLocaleTimeString(global.window.mm_locale, {hour: '2-digit', minute: '2-digit'}); + auditInfo.userId = audit.user_id; + auditInfo.desc = auditDesc; + auditInfo.ip = audit.ip_address; + auditInfo.sessionId = audit.session_id; -export default injectIntl(AuditTable); + return auditInfo; +} diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index 8c49315e7..9e7c67515 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -51,6 +51,7 @@ class CreateComment extends React.Component { this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this); this.handleUserInput = this.handleUserInput.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleUploadClick = this.handleUploadClick.bind(this); this.handleUploadStart = this.handleUploadStart.bind(this); this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this); this.handleUploadError = this.handleUploadError.bind(this); @@ -74,6 +75,8 @@ class CreateComment extends React.Component { componentDidMount() { PreferenceStore.addChangeListener(this.onPreferenceChange); window.addEventListener('resize', this.handleResize); + + this.refs.textbox.focus(); } componentWillUnmount() { PreferenceStore.removeChangeListener(this.onPreferenceChange); @@ -94,6 +97,10 @@ class CreateComment extends React.Component { $('.post-right__scroll').perfectScrollbar('update'); } } + + if (prevProps.rootId !== this.props.rootId) { + this.refs.textbox.focus(); + } } handleSubmit(e) { e.preventDefault(); @@ -218,6 +225,9 @@ class CreateComment extends React.Component { }); } } + handleUploadClick() { + this.refs.textbox.focus(); + } handleUploadStart(clientIds) { let draft = PostStore.getCommentDraft(this.props.rootId); @@ -225,6 +235,10 @@ class CreateComment extends React.Component { PostStore.storeCommentDraft(this.props.rootId, draft); this.setState({uploadsInProgress: draft.uploadsInProgress}); + + // this is a bit redundant with the code that sets focus when the file input is clicked, + // but this also resets the focus after a drag and drop + this.refs.textbox.focus(); } handleFileUploadComplete(filenames, clientIds) { let draft = PostStore.getCommentDraft(this.props.rootId); @@ -365,6 +379,7 @@ class CreateComment extends React.Component { <FileUpload ref='fileUpload' getFileCount={this.getFileCount} + onClick={this.handleUploadClick} onUploadStart={this.handleUploadStart} onFileUpload={this.handleFileUploadComplete} onUploadError={this.handleUploadError} diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 20892898e..6ea80cd13 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -40,10 +40,6 @@ const holders = defineMessages({ write: { id: 'create_post.write', defaultMessage: 'Write a message...' - }, - deleteMsg: { - id: 'create_post.deleteMsg', - defaultMessage: '(message deleted)' } }); @@ -57,7 +53,7 @@ class CreatePost extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.postMsgKeyPress = this.postMsgKeyPress.bind(this); this.handleUserInput = this.handleUserInput.bind(this); - this.resizePostHolder = this.resizePostHolder.bind(this); + this.handleUploadClick = this.handleUploadClick.bind(this); this.handleUploadStart = this.handleUploadStart.bind(this); this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this); this.handleUploadError = this.handleUploadError.bind(this); @@ -66,11 +62,9 @@ class CreatePost extends React.Component { this.onPreferenceChange = this.onPreferenceChange.bind(this); this.getFileCount = this.getFileCount.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleResize = this.handleResize.bind(this); this.sendMessage = this.sendMessage.bind(this); PostStore.clearDraftUploads(); - PostStore.deleteMessage(this.props.intl.formatMessage(holders.deleteMsg)); const draft = this.getCurrentDraft(); @@ -81,34 +75,10 @@ class CreatePost extends React.Component { previews: draft.previews, submitting: false, initialText: draft.messageText, - windowWidth: Utils.windowWidth(), - windowHeight: Utils.windowHeight(), ctrlSend: false, showTutorialTip: false }; } - handleResize() { - this.setState({ - windowWidth: Utils.windowWidth(), - windowHeight: Utils.windowHeight() - }); - } - componentDidUpdate(prevProps, prevState) { - if (prevState.previews.length !== this.state.previews.length) { - this.resizePostHolder(); - return; - } - - if (prevState.uploadsInProgress !== this.state.uploadsInProgress) { - this.resizePostHolder(); - return; - } - - if (prevState.windowWidth !== this.state.windowWidth || prevState.windowHeight !== this.state.windowHeight) { - this.resizePostHolder(); - return; - } - } getCurrentDraft() { const draft = PostStore.getCurrentDraft(); const safeDraft = {previews: [], messageText: '', uploadsInProgress: []}; @@ -245,10 +215,8 @@ class CreatePost extends React.Component { draft.message = messageText; PostStore.storeCurrentDraft(draft); } - resizePostHolder() { - if (this.state.windowWidth > 960) { - $('#post_textbox').focus(); - } + handleUploadClick() { + this.refs.textbox.focus(); } handleUploadStart(clientIds, channelId) { const draft = PostStore.getDraft(channelId); @@ -257,6 +225,10 @@ class CreatePost extends React.Component { PostStore.storeDraft(channelId, draft); this.setState({uploadsInProgress: draft.uploadsInProgress}); + + // this is a bit redundant with the code that sets focus when the file input is clicked, + // but this also resets the focus after a drag and drop + this.refs.textbox.focus(); } handleFileUploadComplete(filenames, clientIds, channelId) { const draft = PostStore.getDraft(channelId); @@ -333,13 +305,16 @@ class CreatePost extends React.Component { componentDidMount() { ChannelStore.addChangeListener(this.onChange); PreferenceStore.addChangeListener(this.onPreferenceChange); - this.resizePostHolder(); - window.addEventListener('resize', this.handleResize); + this.refs.textbox.focus(); + } + componentDidUpdate(prevProps, prevState) { + if (prevState.channelId !== this.state.channelId) { + this.refs.textbox.focus(); + } } componentWillUnmount() { ChannelStore.removeChangeListener(this.onChange); PreferenceStore.removeChangeListener(this.onPreferenceChange); - window.removeEventListener('resize', this.handleResize); } onChange() { const channelId = ChannelStore.getCurrentId(); @@ -462,7 +437,6 @@ class CreatePost extends React.Component { onUserInput={this.handleUserInput} onKeyPress={this.postMsgKeyPress} onKeyDown={this.handleKeyDown} - onHeightChange={this.resizePostHolder} messageText={this.state.messageText} createMessage={this.props.intl.formatMessage(holders.write)} channelId={this.state.channelId} @@ -472,6 +446,7 @@ class CreatePost extends React.Component { <FileUpload ref='fileUpload' getFileCount={this.getFileCount} + onClick={this.handleUploadClick} onUploadStart={this.handleUploadStart} onFileUpload={this.handleFileUploadComplete} onUploadError={this.handleUploadError} @@ -506,4 +481,4 @@ CreatePost.propTypes = { intl: intlShape.isRequired }; -export default injectIntl(CreatePost);
\ No newline at end of file +export default injectIntl(CreatePost); diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx index 34fd724f5..9d7dcb3e5 100644 --- a/web/react/components/delete_post_modal.jsx +++ b/web/react/components/delete_post_modal.jsx @@ -88,7 +88,7 @@ export default class DeletePostModal extends React.Component { } } - PostStore.removePost(this.state.post.id, this.state.post.channel_id); + PostStore.deletePost(this.state.post); AsyncClient.getPosts(this.state.post.channel_id); }, (err) => { diff --git a/web/react/components/file_upload.jsx b/web/react/components/file_upload.jsx index 746289653..f5c32c825 100644 --- a/web/react/components/file_upload.jsx +++ b/web/react/components/file_upload.jsx @@ -310,6 +310,7 @@ class FileUpload extends React.Component { ref='fileInput' type='file' onChange={this.handleChange} + onClick={this.props.onClick} multiple={multiple} accept={accept} /> @@ -322,6 +323,7 @@ FileUpload.propTypes = { intl: intlShape.isRequired, onUploadError: React.PropTypes.func, getFileCount: React.PropTypes.func, + onClick: React.PropTypes.func, onFileUpload: React.PropTypes.func, onUploadStart: React.PropTypes.func, onTextDrop: React.PropTypes.func, diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 16f8528b2..d71ac6ec7 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -44,7 +44,6 @@ class PostBody extends React.Component { this.state = { links: linkData.links, - message: linkData.text, post: this.props.post, hasUserProfiles: profiles && Object.keys(profiles).length > 1 }; @@ -106,7 +105,9 @@ class PostBody extends React.Component { if (this.props.post.filenames.length === 0 && this.state.links && this.state.links.length > 0) { this.embed = this.createEmbed(linkData.links[0]); } - this.setState({links: linkData.links, message: linkData.text}); + this.setState({ + links: linkData.links + }); } createEmbed(link) { @@ -310,6 +311,23 @@ class PostBody extends React.Component { ); } + let message; + if (this.props.post.state === Constants.POST_DELETED) { + message = ( + <FormattedMessage + id='post_body.deleted' + defaultMessage='(message deleted)' + /> + ); + } else { + message = ( + <span + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message)}} + /> + ); + } + return ( <div> {comment} @@ -320,11 +338,7 @@ class PostBody extends React.Component { className={postClass} > {loading} - <span - ref='message_span' - onClick={TextFormatting.handleClick} - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} - /> + {message} </div> <PostBodyAdditionalContent post={this.state.post} @@ -346,4 +360,4 @@ PostBody.propTypes = { handleCommentClick: React.PropTypes.func.isRequired }; -export default injectIntl(PostBody);
\ No newline at end of file +export default injectIntl(PostBody); diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index ddb393520..b1bc8ca14 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -23,13 +23,14 @@ export default class PostInfo extends React.Component { }; this.handlePermalinkCopy = this.handlePermalinkCopy.bind(this); + this.removePost = this.removePost.bind(this); } createDropdown() { var post = this.props.post; var isOwner = UserStore.getCurrentId() === post.user_id; var isAdmin = Utils.isAdmin(UserStore.getCurrentUser().roles); - if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || post.state === Constants.POST_DELETED) { + if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || Utils.isPostEphemeral(post)) { return ''; } @@ -166,6 +167,25 @@ export default class PostInfo extends React.Component { this.setState({copiedLink: false}); } } + removePost() { + EventHelpers.emitRemovePost(this.props.post); + } + createRemovePostButton(post) { + if (!Utils.isPostEphemeral(post)) { + return null; + } + + return ( + <a + href='#' + className='post__remove theme' + type='button' + onClick={this.removePost} + > + {'×'} + </a> + ); + } render() { var post = this.props.post; var comments = ''; @@ -178,7 +198,7 @@ export default class PostInfo extends React.Component { commentCountText = ''; } - if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && post.state !== Constants.POST_DELETED) { + if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && !Utils.isPostEphemeral(post)) { comments = ( <a href='#' @@ -264,6 +284,7 @@ export default class PostInfo extends React.Component { > {permalinkOverlay} </Overlay> + {this.createRemovePostButton(post)} </li> </ul> ); diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx index 00e5ace98..ec299087d 100644 --- a/web/react/components/textbox.jsx +++ b/web/react/components/textbox.jsx @@ -20,6 +20,7 @@ export default class Textbox extends React.Component { constructor(props) { super(props); + this.focus = this.focus.bind(this); this.getStateFromStores = this.getStateFromStores.bind(this); this.onRecievedError = this.onRecievedError.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); @@ -81,6 +82,10 @@ export default class Textbox extends React.Component { } } + focus() { + this.refs.message.getTextbox().focus(); + } + resize() { const textbox = this.refs.message.getTextbox(); const $textbox = $(textbox); @@ -90,8 +95,6 @@ export default class Textbox extends React.Component { const borders = parseInt($textbox.css('border-bottom-width'), 10) + parseInt($textbox.css('border-top-width'), 10); const maxHeight = parseInt($textbox.css('max-height'), 10) - borders; - const prevHeight = $textbox.height(); - // set the height to auto and remove the scrollbar so we can get the actual size of the contents $textbox.css('height', 'auto').css('overflow-y', 'hidden'); @@ -116,10 +119,6 @@ export default class Textbox extends React.Component { if (this.state.preview) { $(ReactDOM.findDOMNode(this.refs.preview)).height(height + borders); } - - if (height !== prevHeight && this.props.onHeightChange) { - this.props.onHeightChange(); - } } showPreview(e) { @@ -211,7 +210,6 @@ Textbox.propTypes = { messageText: React.PropTypes.string.isRequired, onUserInput: React.PropTypes.func.isRequired, onKeyPress: React.PropTypes.func.isRequired, - onHeightChange: React.PropTypes.func, createMessage: React.PropTypes.string.isRequired, onKeyDown: React.PropTypes.func, supportsCommands: React.PropTypes.bool.isRequired diff --git a/web/react/dispatcher/event_helpers.jsx b/web/react/dispatcher/event_helpers.jsx index 5eb319320..c1041e438 100644 --- a/web/react/dispatcher/event_helpers.jsx +++ b/web/react/dispatcher/event_helpers.jsx @@ -9,6 +9,7 @@ import Constants from '../utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; import * as AsyncClient from '../utils/async_client.jsx'; import * as Client from '../utils/client.jsx'; +import * as Utils from '../utils/utils.jsx'; export function emitChannelClickEvent(channel) { AsyncClient.getChannels(true); @@ -180,3 +181,27 @@ export function emitPreferenceChangedEvent(preference) { preference }); } + +export function emitRemovePost(post) { + AppDispatcher.handleViewAction({ + type: Constants.ActionTypes.REMOVE_POST, + post + }); +} + +export function sendEphemeralPost(message, channelId) { + const timestamp = Utils.getTimestamp(); + const post = { + id: Utils.generateId(), + user_id: '0', + channel_id: channelId || ChannelStore.getCurrentId(), + message, + type: Constants.POST_TYPE_EPHEMERAL, + create_at: timestamp, + update_at: timestamp, + filenames: [], + props: {} + }; + + emitPostRecievedEvent(post); +} diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 08ffef822..8ff58f685 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -57,6 +57,7 @@ class PostStoreClass extends EventEmitter { this.clearFocusedPost = this.clearFocusedPost.bind(this); this.clearChannelVisibility = this.clearChannelVisibility.bind(this); + this.deletePost = this.deletePost.bind(this); this.removePost = this.removePost.bind(this); this.getPendingPosts = this.getPendingPosts.bind(this); @@ -65,10 +66,6 @@ class PostStoreClass extends EventEmitter { this.clearPendingPosts = this.clearPendingPosts.bind(this); this.updatePendingPost = this.updatePendingPost.bind(this); - this.storeUnseenDeletedPost = this.storeUnseenDeletedPost.bind(this); - this.getUnseenDeletedPosts = this.getUnseenDeletedPosts.bind(this); - this.clearUnseenDeletedPosts = this.clearUnseenDeletedPosts.bind(this); - // These functions are bad and work should be done to remove this system when the RHS dies this.storeSelectedPost = this.storeSelectedPost.bind(this); this.getSelectedPost = this.getSelectedPost.bind(this); @@ -211,28 +208,6 @@ class PostStoreClass extends EventEmitter { postList.order = this.postsInfo[id].pendingPosts.order.concat(postList.order); } - // Add deleted posts - if (this.postsInfo[id].hasOwnProperty('deletedPosts')) { - Object.assign(postList.posts, this.postsInfo[id].deletedPosts); - - for (const postID in this.postsInfo[id].deletedPosts) { - if (this.postsInfo[id].deletedPosts.hasOwnProperty(postID)) { - postList.order.push(postID); - } - } - - // Merge would be faster - postList.order.sort((a, b) => { - if (postList.posts[a].create_at > postList.posts[b].create_at) { - return -1; - } - if (postList.posts[a].create_at < postList.posts[b].create_at) { - return 1; - } - return 0; - }); - } - return postList; } @@ -286,15 +261,6 @@ class PostStoreClass extends EventEmitter { if (combinedPosts.order.indexOf(pid) === -1) { combinedPosts.order.push(pid); } - } else { - if (pid in combinedPosts.posts) { - Reflect.deleteProperty(combinedPosts.posts, pid); - } - - const index = combinedPosts.order.indexOf(pid); - if (index !== -1) { - combinedPosts.order.splice(index, 1); - } } } } @@ -365,6 +331,22 @@ class PostStoreClass extends EventEmitter { this.postsInfo[id].atBottom = atBottom; } + deletePost(post) { + const postList = this.postsInfo[post.channel_id].postList; + + if (isPostListNull(postList)) { + return; + } + + if (post.id in postList.posts) { + // make sure to copy the post so that component state changes work properly + postList.posts[post.id] = Object.assign({}, post, { + state: Constants.POST_DELETED, + filenames: [] + }); + } + } + removePost(post) { const channelId = post.channel_id; this.makePostsInfo(channelId); @@ -439,37 +421,6 @@ class PostStoreClass extends EventEmitter { this.emitChange(); } - storeUnseenDeletedPost(post) { - let posts = this.getUnseenDeletedPosts(post.channel_id); - - if (!posts) { - posts = {}; - } - - post.message = this.delete_message; - post.state = Constants.POST_DELETED; - post.filenames = []; - - posts[post.id] = post; - - this.makePostsInfo(post.channel_id); - this.postsInfo[post.channel_id].deletedPosts = posts; - } - - getUnseenDeletedPosts(channelId) { - if (this.postsInfo.hasOwnProperty(channelId)) { - return this.postsInfo[channelId].deletedPosts; - } - - return null; - } - - clearUnseenDeletedPosts(channelId) { - if (this.postsInfo.hasOwnProperty(channelId)) { - Reflect.deleteProperty(this.postsInfo[channelId], 'deletedPosts'); - } - } - storeSelectedPost(postList) { this.selectedPost = postList; } @@ -581,9 +532,6 @@ class PostStoreClass extends EventEmitter { return commentCount; } - deleteMessage(msg) { - this.delete_message = msg; - } } var PostStore = new PostStoreClass(); @@ -615,7 +563,6 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => { case ActionTypes.CLICK_CHANNEL: PostStore.clearFocusedPost(); PostStore.clearChannelVisibility(action.id, true); - PostStore.clearUnseenDeletedPosts(action.prev); break; case ActionTypes.CREATE_POST: PostStore.storePendingPost(action.post); @@ -623,7 +570,10 @@ PostStore.dispatchToken = AppDispatcher.register((payload) => { PostStore.jumpPostsViewToBottom(); break; case ActionTypes.POST_DELETED: - PostStore.storeUnseenDeletedPost(action.post); + PostStore.deletePost(action.post); + PostStore.emitChange(); + break; + case ActionTypes.REMOVE_POST: PostStore.removePost(action.post); PostStore.emitChange(); break; diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx index 96f460e1c..9c3270f68 100644 --- a/web/react/stores/socket_store.jsx +++ b/web/react/stores/socket_store.jsx @@ -118,6 +118,7 @@ class SocketStoreClass extends EventEmitter { handleMessage(msg) { switch (msg.action) { case SocketEvents.POSTED: + case SocketEvents.EPHEMERAL_MESSAGE: handleNewPostEvent(msg, this.translations); break; @@ -188,7 +189,6 @@ function handleNewPostEvent(msg, translations) { mentions = JSON.parse(msg.props.mentions); } - const channelType = msgProps.channel_type; const channel = ChannelStore.get(msg.channel_id); const user = UserStore.getCurrentUser(); const member = ChannelStore.getMember(msg.channel_id); @@ -200,7 +200,7 @@ function handleNewPostEvent(msg, translations) { if (notifyLevel === 'none') { return; - } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channelType !== Constants.DM_CHANNEL) { + } else if (notifyLevel === 'mention' && mentions.indexOf(user.id) === -1 && channel.type !== Constants.DM_CHANNEL) { return; } diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 11a8da669..c1bd41b88 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -12,6 +12,7 @@ export default { LEAVE_CHANNEL: null, CREATE_POST: null, POST_DELETED: null, + REMOVE_POST: null, RECIEVED_CHANNELS: null, RECIEVED_CHANNEL: null, @@ -78,7 +79,8 @@ export default { USER_ADDED: 'user_added', USER_REMOVED: 'user_removed', TYPING: 'typing', - PREFERENCE_CHANGED: 'preference_changed' + PREFERENCE_CHANGED: 'preference_changed', + EPHEMERAL_MESSAGE: 'ephemeral_message' }, //SPECIAL_MENTIONS: ['all', 'channel'], @@ -126,6 +128,7 @@ export default { POST_LOADING: 'loading', POST_FAILED: 'failed', POST_DELETED: 'deleted', + POST_TYPE_EPHEMERAL: 'system_ephemeral', POST_TYPE_JOIN_LEAVE: 'system_join_leave', SYSTEM_MESSAGE_PREFIX: 'system_', SYSTEM_MESSAGE_PROFILE_NAME: 'System', diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 494c38bdb..e8cfc82bc 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -1355,3 +1355,7 @@ export function languages() { ] ); } + +export function isPostEphemeral(post) { + return post.type === Constants.POST_TYPE_EPHEMERAL || post.state === Constants.POST_DELETED; +} diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index 2ff49c9b7..77b66a1a8 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -408,7 +408,7 @@ body.ios { @include legacy-pie-clearfix; &:hover { - .dropdown, .comment-icon__container, .post__reply { + .dropdown, .comment-icon__container, .post__reply, .post__remove { visibility: visible; } .permalink-icon { @@ -646,6 +646,13 @@ body.ios { } } + .post__remove { + display: inline-block; + visibility: hidden; + margin-right: 5px; + top: -1px; + } + .post__body { word-wrap: break-word; padding: 0.2em 0.5em 0em; diff --git a/web/static/i18n/en.json b/web/static/i18n/en.json index a160aa58f..4481e58ae 100644 --- a/web/static/i18n/en.json +++ b/web/static/i18n/en.json @@ -8,6 +8,54 @@ "about.date": "Build Date:", "about.hash": "Build Hash:", "about.close": "Close", + "audit_table.sessionRevoked": "The session with id {sessionId} was revoked", + "audit_table.channelCreated": "Created the {channelName} channel/group", + "audit_table.establishedDM": "Established a direct message channel with {username}", + "audit_table.nameUpdated": "Updated the {channelName} channel/group name", + "audit_table.headerUpdated": "Updated the {channelName} channel/group header", + "audit_table.channelDeleted": "Deleted the channel/group with the URL {url}", + "audit_table.userAdded": "Added {username} to the {channelName} channel/group", + "audit_table.userRemoved": "Removed {username} to the {channelName} channel/group", + "audit_table.attemptedRegisterApp": "Attempted to register a new OAuth Application with ID {id}", + "audit_table.attemptedAllowOAuthAccess": "Attempted to allow a new OAuth service access", + "audit_table.successfullOAuthAccess": "Successfully gave a new OAuth service access", + "audit_table.failedOAuthAccess": "Failed to allow a new OAuth service access - the redirect URI did not match the previously registered callback", + "audit_table.attemptedOAuthToken": "Attempted to get an OAuth access token", + "audit_table.successfullOAuthToken": "Successfully added a new OAuth service", + "audit_table.oauthTokenFailed": "Failed to get an OAuth access token - {token}", + "audit_table.attemptedLogin": "Attempted to login", + "audit_table.successfullLogin": "Successfully logged in", + "audit_table.failedLogin": "FAILED login attempt", + "audit_table.updatePicture": "Updated your profile picture", + "audit_table.updateGeneral": "Updated the general settings of your account", + "audit_table.attemptedPassword": "Attempted to change password", + "audit_table.successfullPassword": "Successfully changed password", + "audit_table.failedPassword": "Failed to change password - tried to update user password who was logged in through oauth", + "audit_table.updatedRol": "Updated user role(s) to ", + "audit_table.member": "member", + "audit_table.accountActive": "Account made active", + "audit_table.accountInactive": "Account made inactive", + "audit_table.by": " by {username}", + "audit_table.byAdmin": " by an admin", + "audit_table.sentEmail": "Sent an email to {email} to reset your password", + "audit_table.attemptedReset": "Attempted to reset password", + "audit_table.successfullReset": "Successfully reset password", + "audit_table.updateGlobalNotifications": "Updated your global notification settings", + "audit_table.attemptedWebhookCreate": "Attempted to create a webhook", + "audit_table.successfullWebhookCreate": "Successfully created a webhook", + "audit_table.failedWebhookCreate": "Failed to create a webhook - bad channel permissions", + "audit_table.attemptedWebhookDelete": "Attempted to delete a webhook", + "audit_table.successfullWebhookDelete": "Successfully deleted a webhook", + "audit_table.failedWebhookDelete": "Failed to delete a webhook - inappropriate conditions", + "audit_table.logout": "Logged out of your account", + "audit_table.verified": "Sucessfully verified your email address", + "audit_table.revokedAll": "Revoked all current sessions for the team", + "audit_table.loginAttempt": " (Login attempt)", + "audit_table.loginFailure": " (Login failure)", + "audit_table.moreInfo": "More info", + "audit_table.ip": "IP Address", + "audit_table.session": "Session ID", + "audit_table.userId": "User ID", "access_history.title": "Access History", "activity_log_modal.iphoneNativeApp": "iPhone Native App", "activity_log_modal.androidNativeApp": "Android Native App", @@ -33,7 +81,6 @@ "admin.sidebar.statistics": "- Statistics", "admin.sidebar.ldap": "LDAP Settings", "admin.sidebar.license": "Edition and License", - "admin.sidebar.audits": "Audits", "admin.sidebar.reports": "SITE REPORTS", "admin.sidebar.view_statistics": "View Statistics", "admin.sidebar.settings": "SETTINGS", @@ -50,6 +97,8 @@ "admin.sidebar.teams": "TEAMS ({count})", "admin.sidebar.other": "OTHER", "admin.sidebar.logs": "Logs", + "admin.sidebar.audits": "Compliance and Auditing", + "admin.analytics.loading": "Loading...", "admin.analytics.totalUsers": "Total Users", "admin.analytics.publicChannels": "Public Channels", "admin.analytics.privateGroups": "Private Groups", @@ -67,8 +116,6 @@ "admin.analytics.recentActive": "Recent Active Users", "admin.analytics.newlyCreated": "Newly Created Users", "admin.analytics.title": "Statistics for {title}", - "admin.audits.title": "Server Audits", - "admin.audits.reload": "Reload", "admin.email.notificationDisplayExample": "Ex: \"Mattermost Notification\", \"System\", \"No-Reply\"", "admin.email.notificationEmailExample": "Ex: \"mattermost@yourcompany.com\", \"admin@yourcompany.com\"", "admin.email.smtpUsernameExample": "Ex: \"admin@yourcompany.com\", \"AKIADTOVBGERKLCBV\"", @@ -255,7 +302,7 @@ "admin.license.removing": "Removing License...", "admin.license.uploading": "Uploading License...", "admin.license.enterpriseEdition": "Mattermost Enterprise Edition. Designed for enterprise-scale communication.", - "admin.license.entrepriseType": "<div><p>This compiled release of Mattermost platform is provided under a <a href=\"http://mattermost.com\" target=\"_blank\">commercial license</a>\n from Mattermost, Inc. based on your subscription level and is subject to the <a href=\"{terms}\" target=\"_blank\">Terms of Service.</a></p>\n <p>Your subscription details are as follows:</p>\n Name: {name}<br />\n Company or organization name: {company}<br/>\n Number of users: {users}<br/>\n License issued: {issued}<br/>\n Start date of license: {start}<br/>\n Expiry date of license: {expires}<br/>\n LDAP: {ldap}<br/></div>", + "admin.license.enterpriseType": "<div><p>This compiled release of Mattermost platform is provided under a <a href=\"http://mattermost.com\" target=\"_blank\">commercial license</a> from Mattermost, Inc. based on your subscription level and is subject to the <a href=\"{terms}\" target=\"_blank\">Terms of Service.</a></p><p>Your subscription details are as follows:</p>Name: {name}<br />Company or organization name: {company}<br/>Number of users: {users}<br/>License issued: {issued}<br/>Start date of license: {start}<br/>Expiry date of license: {expires}<br/>LDAP: {ldap}<br/></div>", "admin.license.keyRemove": "Remove Enterprise License and Downgrade Server", "admin.licence.keyMigration": "If you’re migrating servers you may need to remove your license key from this server in order to install it on a new server. To start,\n <a href=\"http://mattermost.com\" target=\"_blank\">disable all Enterprise Edition features on this server</a>.\n This will enable the ability to remove the license key and downgrade this server from Enterprise Edition to Team Edition.", "admin.license.teamEdition": "Mattermost Team Edition. Designed for teams from 5 to 50 users.", @@ -293,6 +340,8 @@ "admin.log.save": "Save", "admin.logs.title": "Server Logs", "admin.logs.reload": "Reload", + "admin.audits.title": "User Activity", + "admin.audits.reload": "Reload", "admin.privacy.saving": "Saving Config...", "admin.privacy.title": "Privacy Settings", "admin.privacy.showEmailTitle": "Show Email Address: ", @@ -440,54 +489,6 @@ "admin.user_item.makeActive": "Make Active", "admin.user_item.makeInactive": "Make Inactive", "admin.user_item.resetPwd": "Reset Password", - "audit_table.sessionRevoked": "The session with id {sessionId} was revoked", - "audit_table.channelCreated": "Created the {channelName} channel/group", - "audit_table.establishedDM": "Established a direct message channel with {username}", - "audit_table.nameUpdated": "Updated the {channelName} channel/group name", - "audit_table.headerUpdated": "Updated the {channelName} channel/group header", - "audit_table.channelDeleted": "Deleted the channel/group with the URL {url}", - "audit_table.userAdded": "Added {username} to the {channelName} channel/group", - "audit_table.userRemoved": "Removed {username} to the {channelName} channel/group", - "audit_table.attemptedRegisterApp": "Attempted to register a new OAuth Application with ID {id}", - "audit_table.attemptedAllowOAuthAccess": "Attempted to allow a new OAuth service access", - "audit_table.successfullOAuthAccess": "Successfully gave a new OAuth service access", - "audit_table.failedOAuthAccess": "Failed to allow a new OAuth service access - the redirect URI did not match the previously registered callback", - "audit_table.attemptedOAuthToken": "Attempted to get an OAuth access token", - "audit_table.successfullOAuthToken": "Successfully added a new OAuth service", - "audit_table.oauthTokenFailed": "Failed to get an OAuth access token - {token}", - "audit_table.attemptedLogin": "Attempted to login", - "audit_table.successfullLogin": "Successfully logged in", - "audit_table.failedLogin": "FAILED login attempt", - "audit_table.updatePicture": "Updated your profile picture", - "audit_table.updateGeneral": "Updated the general settings of your account", - "audit_table.attemptedPassword": "Attempted to change password", - "audit_table.successfullPassword": "Successfully changed password", - "audit_table.failedPassword": "Failed to change password - tried to update user password who was logged in through oauth", - "audit_table.updatedRol": "Updated user role(s) to ", - "audit_table.member": "member", - "audit_table.accountActive": "Account made active", - "audit_table.accountInactive": "Account made inactive", - "audit_table.by": " by {username}", - "audit_table.byAdmin": " by an admin", - "audit_table.sentEmail": "Sent an email to {email} to reset your password", - "audit_table.attemptedReset": "Attempted to reset password", - "audit_table.successfullReset": "Successfully reset password", - "audit_table.updateGlobalNotifications": "Updated your global notification settings", - "audit_table.attemptedWebhookCreate": "Attempted to create a webhook", - "audit_table.successfullWebhookCreate": "Successfully created a webhook", - "audit_table.failedWebhookCreate": "Failed to create a webhook - bad channel permissions", - "audit_table.attemptedWebhookDelete": "Attempted to delete a webhook", - "audit_table.successfullWebhookDelete": "Successfully deleted a webhook", - "audit_table.failedWebhookDelete": "Failed to delete a webhook - inappropriate conditions", - "audit_table.logout": "Logged out of your account", - "audit_table.verified": "Sucessfully verified your email address", - "audit_table.revokedAll": "Revoked all current sessions for the team", - "audit_table.loginAttempt": " (Login attempt)", - "audit_table.loginFailure": " (Login failure)", - "audit_table.userId": "User ID", - "audit_table.moreInfo": "More info", - "audit_table.ip": "IP: {ip}", - "audit_table.session": "Session ID: {id}", "authorize.title": "An application would like to connect to your {teamName} account", "authorize.app": "The app <strong>{appName}</strong> would like the ability to access and modify your basic information.", "authorize.access": "Allow <strong>{appName}</strong> access?", @@ -566,7 +567,6 @@ "create_post.comment": "Comment", "create_post.post": "Post", "create_post.write": "Write a message...", - "create_post.deleteMsg": "(message deleted)", "create_post.tutorialTip": "<h4>Sending Messages</h4><p>Type here to write a message and press <strong>Enter</strong> to post it.</p><p>Click the <strong>Attachment</strong> button to upload an image or a file.</p>", "delete_channel.channel": "channel", "delete_channel.group": "group", @@ -772,6 +772,7 @@ "members_popover.title": "Members", "post_attachment.collapse": "▲ collapse text", "post_attachment.more": "▼ read more", + "post_body.deleted": "(message deleted)", "post_body.plusOne": " plus 1 other file", "post_body.plusMore": " plus {count} other files", "post_body.commentedOn": "Commented on {name}{apostrophe} message: ", @@ -1257,4 +1258,4 @@ "intro_messages.beginning": "Beginning of {name}", "intro_messages.invite": "Invite others to this {type}", "intro_messages.setHeader": "Set a Header" -}
\ No newline at end of file +} diff --git a/web/static/i18n/es.json b/web/static/i18n/es.json index 0a780c442..b65b3b027 100644 --- a/web/static/i18n/es.json +++ b/web/static/i18n/es.json @@ -211,7 +211,7 @@ "admin.licence.keyMigration": "Si estás migrando servidores es posible que necesites remover tu licencia de este servidor para poder instalarlo en un servidor nuevo. Para empezar,\n <a href=\"http://mattermost.com\" target=\"_blank\">deshabilita todas las características de la Edición Enterprise de este servidor</a>.\n Esta operación habilitará la opción para remover la licencia y degradar este servidor de la Edición Enterprise a la Edición Team.", "admin.license.edition": "Edición: ", "admin.license.enterpriseEdition": "Mattermost Edición Enterprise. Diseñada para comunicación de escala empresarial.", - "admin.license.entrepriseType": "<div><p>Esta versión compilada de la plataforma de Mattermost es proporcionada bajo una <a href=\"http://mattermost.com\" target=\"_blank\">licencia comercial</a>\n de Mattermost, Inc. basado en tu nivel de subscripción y sujeto a los <a href=\"{terms}\" target=\"_blank\">Términos del Servicio.</a></p>\n <p>Los detalles de tu subscripción son los siguientes:</p>\n Nombre: {name}<br />\n Nombre de compañia u organización: {company}<br/>\n Cantidad de usuarios: {users}<br/>\n Licencia emitida por: {issued}<br/>\n Inicio de la licencia: {start}<br/>\n Fecha de expiración: {expires}<br/>\n LDAP: {ldap}<br/></div>", + "admin.license.enterpriseType": "<div><p>Esta versión compilada de la plataforma de Mattermost es proporcionada bajo una <a href=\"http://mattermost.com\" target=\"_blank\">licencia comercial</a> de Mattermost, Inc. basado en tu nivel de subscripción y sujeto a los <a href=\"{terms}\" target=\"_blank\">Términos del Servicio.</a></p><p>Los detalles de tu subscripción son los siguientes:</p>Nombre: {name}<br />Nombre de compañia u organización: {company}<br/>Cantidad de usuarios: {users}<br/>Licencia emitida por: {issued}<br/>Inicio de la licencia: {start}<br/>Fecha de expiración: {expires}<br/>LDAP: {ldap}<br/></div>", "admin.license.key": "Llave de la Licencia: ", "admin.license.keyRemove": "Remover la Licencia Enterprise y Degradar el Servidor", "admin.license.removing": "Removiendo Licencia...", @@ -585,7 +585,6 @@ "create_comment.file": "Subiendo archivo", "create_comment.files": "Subiendo archivos", "create_post.comment": "Comentario", - "create_post.deleteMsg": "(mensaje eliminado)", "create_post.post": "Mensaje", "create_post.tutorialTip": "<h4>Enviar Mensajes</h4> <p>Escribe aquí para redactar un mensaje y presiona <strong>Retorno</strong> para enviarlo.</p><p>Pincha el botón de <strong>Adjuntar</strong> para subir una imagen o archivo.</p>", "create_post.write": "Escribe un mensaje...", @@ -805,6 +804,7 @@ "post_attachment.collapse": "▲ colapsar texto", "post_attachment.more": "▼ leer más", "post_body.commentedOn": "Comentó el mensaje de {name}{apostrophe}: ", + "post_body.deleted": "(mensaje eliminado)", "post_body.plusMore": " más {count} otros archivos", "post_body.plusOne": " más 1 archivo", "post_body.retry": "Reintentar", @@ -1216,4 +1216,4 @@ "view_image_popover.download": "Descargar", "view_image_popover.file": "Archivo {count} de {total}", "view_image_popover.publicLink": "Obtener Enlace Público" -}
\ No newline at end of file +} |