diff --git a/README.md b/README.md index c2b0fbf2d8..b12581444d 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,21 @@ Mattermost is a team communication service. It brings team messaging and file sh We built Mattermost to help teams focus on what matters most to them. It works for us, we hope it works for you too. +Learn More +========== + Installing the Mattermost ========================= You're installing "Mattermost Preview", a pre-released 0.50 version intended for an early look at what we're building. While SpinPunch runs this version internally, it's not recommended for production deployments since we can't guarantee API stability or backwards compatibility until our 1.0 version release. -That said, any issues at all, please let us know on the Mattermost forum at: http://bit.ly/1MY1kul +That said, any issues at all, please let us know on the Mattermost forum at: http://forum.mattermost.org Local Machine Setup (Docker) ----------------------------- @@ -33,38 +41,38 @@ Local Machine Setup (Docker) 6. When docker is done fetching the image, open http://dockerhost:8065/ in your browser ### Ubuntu ### -1. Follow the instructions at https://docs.docker.com/installation/ubuntulinux/ or use the summery below. +1. Follow the instructions at https://docs.docker.com/installation/ubuntulinux/ or use the summary below. -`sudo apt-get update` + ``` bash + sudo apt-get update + sudo apt-get install wget + wget -qO- https://get.docker.com/ | sh + sudo usermod -aG docker + sudo service docker start + newgrp docker + ``` -`sudo apt-get install wget` - -`wget -qO- https://get.docker.com/ | sh` - -`sudo usermod -aG docker ` - -`sudo service docker start` - -`newgrp docker` - -2. Run `docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:helium +2. Run `docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:helium` 3. When docker is done fetching the image, open http://localhost:8065/ in your browser ### Arch ### -1. Install docker using the following commands +1. Install docker using the following commands: -`pacman -S docker` + ``` bash + pacman -S docker + systemctl enable docker.service + systemctl start docker.service + gpasswd -a docker + newgrp docker + ``` -`systemctl enable docker.service` +2. Start docker container: -`systemctl start docker.service` + ``` bash + docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:helium + ``` -`gpasswd -a docker` - -`newgrp docker` - -2. docker run --name mattermost-dev -d --publish 8065:80 mattermost/platform:helium -3. When docker is done fetching the image, open http://localhost:8065/ in your browser +3. When docker is done fetching the image, open http://localhost:8065/ in your browser. ### Notes ### If your ISP blocks port 25 then you may install locally but email will not be sent. @@ -80,6 +88,8 @@ If you wish to remove mattermost-dev use the following commands 1. `docker stop mattermost-dev` 2. `docker rm -v mattermost-dev` +If you wish to gain access to the container use the following commands +1. `docker exec -ti mattermost-dev /bin/bash` AWS Elastic Beanstalk Setup (Docker) ------------------------------------ @@ -89,7 +99,7 @@ AWS Elastic Beanstalk Setup (Docker) 2. Select "Create New Application" from the top right. 3. Name the application and press next 4. Select "Create a web server" environment. - 5. If asked, select create and AIM role and instance profile and press next. + 5. If asked, select create and IAM role and instance profile and press next. 6. For predefined configuration select docker. For environment type select single instance. 7. For application source, select upload your own and upload Dockerrun.aws.json from docker/Dockerrun.aws.json. Everything else may be left at default. 8. Select an environment name, this is how you will refer to your environment. Make sure the URL is available then press next. @@ -107,7 +117,7 @@ AWS Elastic Beanstalk Setup (Docker) 18. Modify an existing CNAME record set or create a new one with the name * and the value of the domain you copied in step 1.13. 19. Save the record set -3. Set the enviroment variable "MATTERMOST\_DOMAIN" to the domain you mapped above (example.com not www.example.com) +3. Set the environment variable "MATTERMOST\_DOMAIN" to the domain you mapped above (example.com not www.example.com) 20. Return the Elastic Beanstalk from the AWS console. 21. Select the environment you created. 22. Select configuration from the sidebar. @@ -119,6 +129,11 @@ AWS Elastic Beanstalk Setup (Docker) 26. Return to the dashboard on the sidebar and wait for beanstalk update the environment. 27. Try it out by entering the domain you mapped into your browser. +Contributing +------------ + +To contribute to this open source project please review the Mattermost Contribution Guidelines at http://www.mattermost.org/contribute-to-mattermost/. + License ------- @@ -126,3 +141,4 @@ Most Mattermost source files are made available under the terms of the GNU Affer As an exception, Admin Tools and Configuration Files are are made available under the terms of the Apache License, version 2.0. See LICENSE.txt for more information. + diff --git a/api/channel.go b/api/channel.go index d3f6ca2de9..c0c2d1548a 100644 --- a/api/channel.go +++ b/api/channel.go @@ -57,7 +57,7 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) { return } - if sc, err := CreateChannel(c, channel, r.URL.Path, true); err != nil { + if sc, err := CreateChannel(c, channel, true); err != nil { c.Err = err return } else { @@ -65,7 +65,7 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) { } } -func CreateChannel(c *Context, channel *model.Channel, path string, addMember bool) (*model.Channel, *model.AppError) { +func CreateChannel(c *Context, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) { if result := <-Srv.Store.Channel().Save(channel); result.Err != nil { return nil, result.Err } else { @@ -100,7 +100,7 @@ func createDirectChannel(c *Context, w http.ResponseWriter, r *http.Request) { return } - if sc, err := CreateDirectChannel(c, userId, r.URL.Path); err != nil { + if sc, err := CreateDirectChannel(c, userId); err != nil { c.Err = err return } else { @@ -108,7 +108,7 @@ func createDirectChannel(c *Context, w http.ResponseWriter, r *http.Request) { } } -func CreateDirectChannel(c *Context, otherUserId string, path string) (*model.Channel, *model.AppError) { +func CreateDirectChannel(c *Context, otherUserId string) (*model.Channel, *model.AppError) { if len(otherUserId) != 26 { return nil, model.NewAppError("CreateDirectChannel", "Invalid other user id ", otherUserId) } @@ -132,7 +132,7 @@ func CreateDirectChannel(c *Context, otherUserId string, path string) (*model.Ch return nil, model.NewAppError("CreateDirectChannel", "Invalid other user id ", otherUserId) } - if sc, err := CreateChannel(c, channel, path, true); err != nil { + if sc, err := CreateChannel(c, channel, true); err != nil { return nil, err } else { cm := &model.ChannelMember{ChannelId: sc.Id, UserId: otherUserId, @@ -146,6 +146,23 @@ func CreateDirectChannel(c *Context, otherUserId string, path string) (*model.Ch } } +func CreateDefaultChannels(c *Context, teamId string) ([]*model.Channel, *model.AppError) { + townSquare := &model.Channel{DisplayName: "Town Square", Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: teamId} + + if _, err := CreateChannel(c, townSquare, false); err != nil { + return nil, err + } + + offTopic := &model.Channel{DisplayName: "Off-Topic", Name: "off-topic", Type: model.CHANNEL_OPEN, TeamId: teamId} + + if _, err := CreateChannel(c, offTopic, false); err != nil { + return nil, err + } + + channels := []*model.Channel{townSquare, offTopic} + return channels, nil +} + func updateChannel(c *Context, w http.ResponseWriter, r *http.Request) { channel := model.ChannelFromJson(r.Body) @@ -303,7 +320,7 @@ func joinChannel(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) channelId := params["id"] - JoinChannel(c, channelId, r.URL.Path) + JoinChannel(c, channelId, "") if c.Err != nil { return @@ -314,7 +331,7 @@ func joinChannel(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(result))) } -func JoinChannel(c *Context, channelId string, path string) { +func JoinChannel(c *Context, channelId string, role string) { sc := Srv.Store.Channel().Get(channelId) uc := Srv.Store.User().Get(c.Session.UserId) @@ -340,7 +357,7 @@ func JoinChannel(c *Context, channelId string, path string) { } if channel.Type == model.CHANNEL_OPEN { - cm := &model.ChannelMember{ChannelId: channel.Id, UserId: c.Session.UserId, NotifyLevel: model.CHANNEL_NOTIFY_ALL} + cm := &model.ChannelMember{ChannelId: channel.Id, UserId: c.Session.UserId, NotifyLevel: model.CHANNEL_NOTIFY_ALL, Roles: role} if cmresult := <-Srv.Store.Channel().SaveMember(cm); cmresult.Err != nil { c.Err = cmresult.Err @@ -363,6 +380,32 @@ func JoinChannel(c *Context, channelId string, path string) { } } +func JoinDefaultChannels(c *Context, user *model.User, channelRole string) *model.AppError { + // We don't call JoinChannel here since c.Session is not populated on user creation + + var err *model.AppError = nil + + if result := <-Srv.Store.Channel().GetByName(user.TeamId, "town-square"); result.Err != nil { + err = result.Err + } else { + cm := &model.ChannelMember{ChannelId: result.Data.(*model.Channel).Id, UserId: user.Id, NotifyLevel: model.CHANNEL_NOTIFY_ALL, Roles: channelRole} + if cmResult := <-Srv.Store.Channel().SaveMember(cm); cmResult.Err != nil { + err = cmResult.Err + } + } + + if result := <-Srv.Store.Channel().GetByName(user.TeamId, "off-topic"); result.Err != nil { + err = result.Err + } else { + cm := &model.ChannelMember{ChannelId: result.Data.(*model.Channel).Id, UserId: user.Id, NotifyLevel: model.CHANNEL_NOTIFY_ALL, Roles: channelRole} + if cmResult := <-Srv.Store.Channel().SaveMember(cm); cmResult.Err != nil { + err = cmResult.Err + } + } + + return err +} + func leaveChannel(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) diff --git a/api/channel_test.go b/api/channel_test.go index e8aaf4e3f9..dfae840dce 100644 --- a/api/channel_test.go +++ b/api/channel_test.go @@ -35,8 +35,15 @@ func TestCreateChannel(t *testing.T) { } rget := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList) - if rget.Channels[0].Name != channel.Name { - t.Fatal("full name didn't match") + nameMatch := false + for _, c := range rget.Channels { + if c.Name == channel.Name { + nameMatch = true + } + } + + if !nameMatch { + t.Fatal("Did not create channel with correct name") } if _, err := Client.CreateChannel(rchannel.Data.(*model.Channel)); err == nil { @@ -679,6 +686,8 @@ func TestUpdateNotifyLevel(t *testing.T) { data["user_id"] = user.Id data["notify_level"] = model.CHANNEL_NOTIFY_MENTION + timeBeforeUpdate := model.GetMillis() + if _, err := Client.UpdateNotifyLevel(data); err != nil { t.Fatal(err) } @@ -689,6 +698,10 @@ func TestUpdateNotifyLevel(t *testing.T) { t.Fatal("NotifyLevel did not update properly") } + if rdata.Members[channel1.Id].LastUpdateAt <= timeBeforeUpdate { + t.Fatal("LastUpdateAt did not update") + } + data["user_id"] = "junk" if _, err := Client.UpdateNotifyLevel(data); err == nil { t.Fatal("Should have errored - bad user id") @@ -735,7 +748,7 @@ func TestUpdateNotifyLevel(t *testing.T) { } func TestFuzzyChannel(t *testing.T) { - Setup(); + Setup() team := &model.Team{Name: "Name", Domain: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) @@ -747,9 +760,9 @@ func TestFuzzyChannel(t *testing.T) { Client.LoginByEmail(team.Domain, user.Email, "pwd") // Strings that should pass as acceptable channel names - var fuzzyStringsPass = []string { + var fuzzyStringsPass = []string{ "*", "?", ".", "}{][)(><", "{}[]()<>", - + "qahwah ( قهوة)", "שָׁלוֹם עֲלֵיכֶם", "Ramen チャーシュー chāshū", diff --git a/api/command.go b/api/command.go index aedbe07cc1..810a8a07ed 100644 --- a/api/command.go +++ b/api/command.go @@ -197,7 +197,7 @@ func joinCommand(c *Context, command *model.Command) bool { return false } - JoinChannel(c, v.Id, "/command") + JoinChannel(c, v.Id, "") if c.Err != nil { return false diff --git a/api/command_test.go b/api/command_test.go index d3b0da455c..5b77346283 100644 --- a/api/command_test.go +++ b/api/command_test.go @@ -129,7 +129,7 @@ func TestJoinCommands(t *testing.T) { c1 := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList) - if len(c1.Channels) != 3 { // 3 because of town-square and direct + if len(c1.Channels) != 4 { // 4 because of town-square, off-topic and direct t.Fatal("didn't join channel") } diff --git a/api/post.go b/api/post.go index 99cbdcb852..650f47062d 100644 --- a/api/post.go +++ b/api/post.go @@ -227,7 +227,7 @@ func fireAndForgetNotifications(post *model.Post, teamId, teamUrl string) { channel = result.Data.(*model.Channel) if channel.Type == model.CHANNEL_DIRECT { bodyText = "You have one new message." - subjectText = "New Direct Message" + subjectText = "New Private Message" } else { bodyText = "You have one new mention." subjectText = "New Mention" @@ -273,7 +273,7 @@ func fireAndForgetNotifications(post *model.Post, teamId, teamUrl string) { } else { - // Find out who is a member of the channel only keep those profiles + // Find out who is a member of the channel, only keep those profiles if eResult := <-echan; eResult.Err != nil { l4g.Error("Failed to get channel members channel_id=%v err=%v", post.ChannelId, eResult.Err.Message) return @@ -306,13 +306,23 @@ func fireAndForgetNotifications(post *model.Post, teamId, teamUrl string) { } } } + + // Add @all to keywords if user has them turned on + if profile.NotifyProps["all"] == "true" { + keywordMap["@all"] = append(keywordMap["@all"], profile.Id) + } + + // Add @channel to keywords if user has them turned on + if profile.NotifyProps["channel"] == "true" { + keywordMap["@channel"] = append(keywordMap["@channel"], 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.FieldsFunc(strings.Replace(post.Message, "
", " ", -1), splitF) + splitMessage := strings.FieldsFunc(post.Message, splitF) for _, word := range splitMessage { // Non-case-sensitive check for regular keys diff --git a/api/team.go b/api/team.go index 775bc29ae2..15e4e2c17b 100644 --- a/api/team.go +++ b/api/team.go @@ -146,10 +146,8 @@ func createTeamFromSignup(c *Context, w http.ResponseWriter, r *http.Request) { } else { rteam := result.Data.(*model.Team) - channel := &model.Channel{DisplayName: "Town Square", Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: rteam.Id} - - if _, err := CreateChannel(c, channel, r.URL.Path, false); err != nil { - c.Err = err + if _, err := CreateDefaultChannels(c, rteam.Id); err != nil { + c.Err = nil return } @@ -197,10 +195,8 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) { } else { rteam := result.Data.(*model.Team) - channel := &model.Channel{DisplayName: "Town Square", Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: rteam.Id} - - if _, err := CreateChannel(c, channel, r.URL.Path, false); err != nil { - c.Err = err + if _, err := CreateDefaultChannels(c, rteam.Id); err != nil { + c.Err = nil return } @@ -488,12 +484,21 @@ func InviteMembers(team *model.Team, user *model.User, invites []string) { } else { sender = user.FullName } + + senderRole := "" + if strings.Contains(user.Roles, model.ROLE_ADMIN) || strings.Contains(user.Roles, model.ROLE_SYSTEM_ADMIN) { + senderRole = "administrator" + } else { + senderRole = "member" + } + subjectPage := NewServerTemplatePage("invite_subject", teamUrl) subjectPage.Props["SenderName"] = sender subjectPage.Props["TeamName"] = team.Name bodyPage := NewServerTemplatePage("invite_body", teamUrl) bodyPage.Props["TeamName"] = team.Name bodyPage.Props["SenderName"] = sender + bodyPage.Props["SenderStatus"] = senderRole bodyPage.Props["Email"] = invite diff --git a/api/team_test.go b/api/team_test.go index 042c0a2e95..bb77d43a06 100644 --- a/api/team_test.go +++ b/api/team_test.go @@ -55,6 +55,11 @@ func TestCreateFromSignupTeam(t *testing.T) { } } + c1 := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList) + if len(c1.Channels) != 2 { + t.Fatal("default channels not created") + } + ts.Data = "garbage" _, err = Client.CreateTeamFromSignup(&ts) if err == nil { @@ -71,6 +76,17 @@ func TestCreateTeam(t *testing.T) { t.Fatal(err) } + user := &model.User{TeamId: rteam.Data.(*model.Team).Id, Email: model.NewId() + "corey@test.com", FullName: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + Srv.Store.User().VerifyEmail(user.Id) + + Client.LoginByEmail(team.Domain, user.Email, "pwd") + + c1 := Client.Must(Client.GetChannels("")).Data.(*model.ChannelList) + if len(c1.Channels) != 2 { + t.Fatal("default channels not created") + } + if rteam.Data.(*model.Team).Name != team.Name { t.Fatal("full name didn't match") } diff --git a/api/templates/error.html b/api/templates/error.html index ab4d91378b..f38bb81a1e 100644 --- a/api/templates/error.html +++ b/api/templates/error.html @@ -5,7 +5,7 @@ - + diff --git a/api/templates/invite_body.html b/api/templates/invite_body.html index 06f48759cf..8be2ac0df2 100644 --- a/api/templates/invite_body.html +++ b/api/templates/invite_body.html @@ -18,7 +18,7 @@

You've been invited

-

{{.Props.TeamName}} started using {{.SiteName}}.
The team administrator {{.Props.SenderName}}, has invited you to join {{.Props.TeamName}}.

+

{{.Props.TeamName}} started using {{.SiteName}}.
The team {{.Props.SenderStatus}} {{.Props.SenderName}}, has invited you to join {{.Props.TeamName}}.

Join Team

diff --git a/api/templates/post_body.html b/api/templates/post_body.html index 663ec66d2c..069cdf1fb8 100644 --- a/api/templates/post_body.html +++ b/api/templates/post_body.html @@ -20,7 +20,7 @@

You were mentioned

CHANNEL: {{.Props.ChannelName}}
{{.Props.SenderName}} - {{.Props.Hour}}:{{.Props.Minute}} GMT, {{.Props.Month}} {{.Props.Day}}

{{.Props.PostMessage}}

- Go To Channel + Go To Channel

diff --git a/api/user.go b/api/user.go index f8382cf2f8..292d2b61b7 100644 --- a/api/user.go +++ b/api/user.go @@ -176,21 +176,16 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User { } else { ruser := result.Data.(*model.User) - // Do not error if user cannot be added to the town-square channel - if cresult := <-Srv.Store.Channel().GetByName(team.Id, "town-square"); cresult.Err != nil { - l4g.Error("Failed to get town-square err=%v", cresult.Err) - } else { - cm := &model.ChannelMember{ChannelId: cresult.Data.(*model.Channel).Id, UserId: ruser.Id, NotifyLevel: model.CHANNEL_NOTIFY_ALL, Roles: channelRole} - if cmresult := <-Srv.Store.Channel().SaveMember(cm); cmresult.Err != nil { - l4g.Error("Failed to add member town-square err=%v", cmresult.Err) - } + // Soft error if there is an issue joining the default channels + if err := JoinDefaultChannels(c, ruser, channelRole); err != nil { + l4g.Error("Encountered an issue joining default channels user_id=%s, team_id=%s, err=%v", ruser.Id, ruser.TeamId, err) } //fireAndForgetWelcomeEmail(strings.Split(ruser.FullName, " ")[0], ruser.Email, team.Name, c.TeamUrl+"/channels/town-square") if user.EmailVerified { if cresult := <-Srv.Store.User().VerifyEmail(ruser.Id); cresult.Err != nil { - l4g.Error("Failed to get town-square err=%v", cresult.Err) + l4g.Error("Failed to set email verified err=%v", cresult.Err) } } else { FireAndForgetVerifyEmail(result.Data.(*model.User).Id, strings.Split(ruser.FullName, " ")[0], ruser.Email, team.Name, c.TeamUrl) @@ -198,7 +193,7 @@ func CreateUser(c *Context, team *model.Team, user *model.User) *model.User { ruser.Sanitize(map[string]bool{}) - //This message goes to every channel, so the channelId is irrelevant + // This message goes to every channel, so the channelId is irrelevant message := model.NewMessage(team.Id, "", ruser.Id, model.ACTION_NEW_USER) store.PublishAndForget(message) diff --git a/api/web_socket_test.go b/api/web_socket_test.go index c7b612cde9..15bc3baeb9 100644 --- a/api/web_socket_test.go +++ b/api/web_socket_test.go @@ -119,7 +119,7 @@ func TestSocket(t *testing.T) { } -func TestZZWebScoketTearDown(t *testing.T) { +func TestZZWebSocketTearDown(t *testing.T) { // *IMPORTANT* - Kind of hacky // This should be the last function in any test file // that calls Setup() diff --git a/config/config_docker.json b/config/config_docker.json index 6936f619a7..85f0d9c73b 100644 --- a/config/config_docker.json +++ b/config/config_docker.json @@ -10,7 +10,7 @@ "ServiceSettings": { "SiteName": "Mattermost", "Domain": "", - "Mode" : "prod", + "Mode" : "dev", "AllowTesting" : false, "UseSSL": false, "Port": "80", diff --git a/manualtesting/manual_testing.go b/manualtesting/manual_testing.go index ead4411085..929f7ab5d8 100644 --- a/manualtesting/manual_testing.go +++ b/manualtesting/manual_testing.go @@ -78,7 +78,7 @@ func manualTest(c *api.Context, w http.ResponseWriter, r *http.Request) { createdTeam := result.Data.(*model.Team) channel := &model.Channel{DisplayName: "Town Square", Name: "town-square", Type: model.CHANNEL_OPEN, TeamId: createdTeam.Id} - if _, err := api.CreateChannel(c, channel, r.URL.Path, false); err != nil { + if _, err := api.CreateChannel(c, channel, false); err != nil { c.Err = err return } diff --git a/model/channel_list.go b/model/channel_list.go index 088dbea2a1..09f14a9862 100644 --- a/model/channel_list.go +++ b/model/channel_list.go @@ -53,6 +53,12 @@ func (o *ChannelList) Etag() string { t = member.LastViewedAt id = v.Id } + + if member.LastUpdateAt > t { + t = member.LastUpdateAt + id = v.Id + } + } } diff --git a/model/channel_member.go b/model/channel_member.go index 720ac4c42d..50f51304bc 100644 --- a/model/channel_member.go +++ b/model/channel_member.go @@ -25,6 +25,7 @@ type ChannelMember struct { MsgCount int64 `json:"msg_count"` MentionCount int64 `json:"mention_count"` NotifyLevel string `json:"notify_level"` + LastUpdateAt int64 `json:"last_update_at"` } func (o *ChannelMember) ToJson() string { @@ -70,6 +71,10 @@ func (o *ChannelMember) IsValid() *AppError { return nil } +func (o *ChannelMember) PreSave() { + o.LastUpdateAt = GetMillis() +} + func IsChannelNotifyLevelValid(notifyLevel string) bool { return notifyLevel == CHANNEL_NOTIFY_ALL || notifyLevel == CHANNEL_NOTIFY_MENTION || notifyLevel == CHANNEL_NOTIFY_NONE || notifyLevel == CHANNEL_NOTIFY_QUIET } diff --git a/model/user.go b/model/user.go index 794adcad44..c516fae785 100644 --- a/model/user.go +++ b/model/user.go @@ -16,7 +16,7 @@ const ( ROLE_SYSTEM_ADMIN = "system_admin" ROLE_SYSTEM_SUPPORT = "system_support" USER_AWAY_TIMEOUT = 5 * 60 * 1000 // 5 minutes - USER_OFFLINE_TIMEOUT = 5 * 60 * 1000 // 5 minutes + USER_OFFLINE_TIMEOUT = 1 * 60 * 1000 // 1 minute USER_OFFLINE = "offline" USER_AWAY = "away" USER_ONLINE = "online" @@ -147,10 +147,13 @@ func (u *User) SetDefaultNotifications() { u.NotifyProps["email"] = "true" u.NotifyProps["desktop"] = USER_NOTIFY_ALL u.NotifyProps["desktop_sound"] = "true" - u.NotifyProps["mention_keys"] = u.Username - u.NotifyProps["first_name"] = "true" + u.NotifyProps["mention_keys"] = u.Username + ",@" + u.Username + u.NotifyProps["first_name"] = "false" + u.NotifyProps["all"] = "true" + u.NotifyProps["channel"] = "true" splitName := strings.Split(u.FullName, " ") if len(splitName) > 0 && splitName[0] != "" { + u.NotifyProps["first_name"] = "true" u.NotifyProps["mention_keys"] += "," + splitName[0] } } @@ -277,17 +280,17 @@ func ComparePassword(hash string, password string) bool { func IsUsernameValid(username string) bool { - var restrictedUsernames = []string { + var restrictedUsernames = []string{ BOT_USERNAME, "all", "channel", } - for _,restrictedUsername := range restrictedUsernames { + for _, restrictedUsername := range restrictedUsernames { if username == restrictedUsername { return false } - } + } return true } diff --git a/model/utils.go b/model/utils.go index 262bda319e..50e4276942 100644 --- a/model/utils.go +++ b/model/utils.go @@ -17,7 +17,7 @@ import ( ) const ( - ETAG_ROOT_VERSION = "10" + ETAG_ROOT_VERSION = "11" ) type StringMap map[string]string @@ -260,7 +260,7 @@ func Etag(parts ...interface{}) string { return etag } -var validHashtag = regexp.MustCompile(`^(#[A-Za-z]+[A-Za-z0-9_]*[A-Za-z0-9])$`) +var validHashtag = regexp.MustCompile(`^(#[A-Za-z]+[A-Za-z0-9_\-]*[A-Za-z0-9])$`) var puncStart = regexp.MustCompile(`^[.,()&$!\[\]{}"':;\\]+`) var puncEnd = regexp.MustCompile(`[.,()&$#!\[\]{}"':;\\]+$`) diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index 592657c1c3..463fce16f5 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -37,6 +37,7 @@ func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore { } func (s SqlChannelStore) UpgradeSchemaIfNeeded() { + s.CreateColumnIfNotExists("ChannelMembers", "LastUpdateAt", "NotifyLevel", "bigint(20)", "0") // Remove after 6/7/2015 prod push } func (s SqlChannelStore) CreateIndexesIfNotExists() { @@ -273,6 +274,7 @@ func (s SqlChannelStore) SaveMember(member *model.ChannelMember) StoreChannel { go func() { result := StoreResult{} + member.PreSave() if result.Err = member.IsValid(); result.Err != nil { storeChannel <- result return @@ -484,7 +486,8 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelId string, userId string) Sto SET ChannelMembers.MentionCount = 0, ChannelMembers.MsgCount = Channels.TotalMsgCount, - ChannelMembers.LastViewedAt = Channels.LastPostAt + ChannelMembers.LastViewedAt = Channels.LastPostAt, + ChannelMembers.LastUpdateAt = Channels.LastPostAt WHERE Channels.Id = ChannelMembers.ChannelId AND UserId = ? @@ -533,15 +536,18 @@ func (s SqlChannelStore) UpdateNotifyLevel(channelId, userId, notifyLevel string go func() { result := StoreResult{} + updateAt := model.GetMillis() + _, err := s.GetMaster().Exec( `UPDATE ChannelMembers SET - NotifyLevel = ? + NotifyLevel = ?, + LastUpdateAt = ? WHERE UserId = ? AND ChannelId = ?`, - notifyLevel, userId, channelId) + notifyLevel, updateAt, userId, channelId) if err != nil { result.Err = model.NewAppError("SqlChannelStore.UpdateNotifyLevel", "We couldn't update the notify level", "channel_id="+channelId+", user_id="+userId+", "+err.Error()) } diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 0ceebc02f3..01900023fc 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -356,9 +356,14 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht go func() { result := StoreResult{} + termMap := map[string]bool{} + searchType := "Message" if isHashtagSearch { searchType = "Hashtags" + for _,term := range strings.Split(terms, " ") { + termMap[term] = true; + } } // @ has a speical meaning in INNODB FULLTEXT indexes and @@ -394,6 +399,17 @@ func (s SqlPostStore) Search(teamId string, userId string, terms string, isHasht list := &model.PostList{Order: make([]string, 0, len(posts))} for _, p := range posts { + if searchType == "Hashtags" { + exactMatch := false + for _, tag := range strings.Split(p.Hashtags, " ") { + if termMap[tag] { + exactMatch = true + } + } + if !exactMatch { + continue + } + } list.AddPost(p) list.AddOrder(p.Id) } diff --git a/store/sql_store.go b/store/sql_store.go index a2deea6bab..bef8b48670 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -7,6 +7,9 @@ import ( l4g "code.google.com/p/log4go" "crypto/aes" "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" crand "crypto/rand" dbsql "database/sql" "encoding/base64" @@ -327,20 +330,26 @@ func encrypt(key []byte, text string) (string, error) { } plaintext := []byte(text) + skey := sha512.Sum512(key) + ekey, akey := skey[:32], skey[32:] - block, err := aes.NewCipher(key) + block, err := aes.NewCipher(ekey) if err != nil { return "", err } - ciphertext := make([]byte, aes.BlockSize+len(plaintext)) + macfn := hmac.New(sha256.New, akey) + ciphertext := make([]byte, aes.BlockSize+macfn.Size()+len(plaintext)) iv := ciphertext[:aes.BlockSize] if _, err := io.ReadFull(crand.Reader, iv); err != nil { return "", err } stream := cipher.NewCFBEncrypter(block, iv) - stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext) + stream.XORKeyStream(ciphertext[aes.BlockSize+macfn.Size():], plaintext) + macfn.Write(ciphertext[aes.BlockSize+macfn.Size():]) + mac := macfn.Sum(nil) + copy(ciphertext[aes.BlockSize:aes.BlockSize+macfn.Size()], mac) return base64.URLEncoding.EncodeToString(ciphertext), nil } @@ -351,9 +360,26 @@ func decrypt(key []byte, cryptoText string) (string, error) { return "{}", nil } - ciphertext, _ := base64.URLEncoding.DecodeString(cryptoText) + ciphertext, err := base64.URLEncoding.DecodeString(cryptoText) + if err != nil { + return "", err + } - block, err := aes.NewCipher(key) + skey := sha512.Sum512(key) + ekey, akey := skey[:32], skey[32:] + macfn := hmac.New(sha256.New, akey) + if len(ciphertext) < aes.BlockSize+macfn.Size() { + return "", errors.New("short ciphertext") + } + + macfn.Write(ciphertext[aes.BlockSize+macfn.Size():]) + expectedMac := macfn.Sum(nil) + mac := ciphertext[aes.BlockSize:aes.BlockSize+macfn.Size()] + if hmac.Equal(expectedMac, mac) != true { + return "", errors.New("Incorrect MAC for the given ciphertext") + } + + block, err := aes.NewCipher(ekey) if err != nil { return "", err } @@ -362,7 +388,7 @@ func decrypt(key []byte, cryptoText string) (string, error) { return "", errors.New("ciphertext too short") } iv := ciphertext[:aes.BlockSize] - ciphertext = ciphertext[aes.BlockSize:] + ciphertext = ciphertext[aes.BlockSize+macfn.Size():] stream := cipher.NewCFBDecrypter(block, iv) diff --git a/utils/mail.go b/utils/mail.go index 2fb7f801d5..3cd37ffef8 100644 --- a/utils/mail.go +++ b/utils/mail.go @@ -11,6 +11,7 @@ import ( "net" "net/mail" "net/smtp" + "html" ) func CheckMailSettings() *model.AppError { @@ -84,7 +85,7 @@ func SendMail(to, subject, body string) *model.AppError { headers := make(map[string]string) headers["From"] = fromMail.String() headers["To"] = toMail.String() - headers["Subject"] = subject + headers["Subject"] = html.UnescapeString(subject) headers["MIME-version"] = "1.0" headers["Content-Type"] = "text/html" diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index ade58a10a3..48cb4d13bf 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -15,17 +15,8 @@ var AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; -function getExtraInfoStateFromStores() { - return { - extra_info: ChannelStore.getCurrentExtraInfo() - }; -} - var ExtraMembers = React.createClass({ componentDidMount: function() { - ChannelStore.addExtraInfoChangeListener(this._onChange); - ChannelStore.addChangeListener(this._onChange); - var originalLeave = $.fn.popover.Constructor.prototype.leave; $.fn.popover.Constructor.prototype.leave = function(obj) { var self = obj instanceof this.constructor ? obj : $(obj.currentTarget)[this.type](this.getDelegateOptions()).data('bs.' + this.type); @@ -49,27 +40,21 @@ var ExtraMembers = React.createClass({ }); }, - componentWillUnmount: function() { - ChannelStore.removeExtraInfoChangeListener(this._onChange); - ChannelStore.removeChangeListener(this._onChange); - }, - _onChange: function() { - var newState = getExtraInfoStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { - this.setState(newState); - } - }, - getInitialState: function() { - return getExtraInfoStateFromStores(); - }, render: function() { - var count = this.state.extra_info.members.length == 0 ? "-" : this.state.extra_info.members.length; - count = this.state.extra_info.members.length > 19 ? "20+" : count; + var count = this.props.members.length == 0 ? "-" : this.props.members.length; + count = this.props.members.length > 19 ? "20+" : count; var data_content = ""; + var sortedMembers = this.props.members; - this.state.extra_info.members.forEach(function(m) { - data_content += "
" + m.username + "
"; - }); + if(sortedMembers) { + sortedMembers.sort(function(a,b) { + return a.username.localeCompare(b.username); + }) + + sortedMembers.forEach(function(m) { + data_content += "
" + m.username + "
"; + }); + } return (
@@ -228,7 +213,7 @@ module.exports = React.createClass({ {channelTitle} } - + { searchForm }
diff --git a/web/react/components/channel_info_modal.jsx b/web/react/components/channel_info_modal.jsx index 191297ce4f..18addb52fe 100644 --- a/web/react/components/channel_info_modal.jsx +++ b/web/react/components/channel_info_modal.jsx @@ -35,9 +35,18 @@ module.exports = React.createClass({

{channel.display_name}

-

Channel Name: {channel.display_name}

-

Channel Handle: {channel.name}

-

Channel ID: {channel.id}

+
+
Channel Name:
+
{channel.display_name}
+
+
+
Channel Handle:
+
{channel.name}
+
+
+
Channel ID:
+
{channel.id}
+
diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications.jsx index 085536a0aa..638d16576f 100644 --- a/web/react/components/channel_notifications.jsx +++ b/web/react/components/channel_notifications.jsx @@ -1,6 +1,8 @@ // Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. // See License.txt for license information. +var SettingItemMin = require('./setting_item_min.jsx'); +var SettingItemMax = require('./setting_item_max.jsx'); var utils = require('../utils/utils.jsx'); var client = require('../utils/client.jsx'); @@ -9,26 +11,50 @@ var ChannelStore = require('../stores/channel_store.jsx'); module.exports = React.createClass({ componentDidMount: function() { + ChannelStore.addChangeListener(this._onChange); + var self = this; $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) { var button = e.relatedTarget; var channel_id = button.dataset.channelid; var notifyLevel = ChannelStore.getMember(channel_id).notify_level; - self.setState({ notify_level: notifyLevel, title: button.dataset.title, channel_id: channel_id }); + var quietMode = false; + if (notifyLevel === "quiet") quietMode = true; + self.setState({ notify_level: notifyLevel, quiet_mode: quietMode, title: button.dataset.title, channel_id: channel_id }); }); }, - getInitialState: function() { - return { notify_level: "", title: "", channel_id: "" }; + componentWillUnmount: function() { + ChannelStore.removeChangeListener(this._onChange); }, - handleUpdate: function(e) { + _onChange: function() { + if (!this.state.channel_id) return; + var notifyLevel = ChannelStore.getMember(this.state.channel_id).notify_level; + var quietMode = false; + if (notifyLevel === "quiet") quietMode = true; + + var newState = this.state; + newState.notify_level = notifyLevel; + newState.quiet_mode = quietMode; + + if (!utils.areStatesEqual(this.state, newState)) { + this.setState(newState); + } + }, + updateSection: function(section) { + this.setState({ activeSection: section }); + }, + getInitialState: function() { + return { notify_level: "", title: "", channel_id: "", activeSection: "" }; + }, + handleUpdate: function() { var channel_id = this.state.channel_id; - var notify_level = this.state.notify_level; + var notify_level = this.state.quiet_mode ? "quiet" : this.state.notify_level; var data = {}; data["channel_id"] = channel_id; data["user_id"] = UserStore.getCurrentId(); - data["notify_level"] = this.state.notify_level; + data["notify_level"] = notify_level; if (!data["notify_level"] || data["notify_level"].length === 0) return; @@ -37,7 +63,7 @@ module.exports = React.createClass({ var member = ChannelStore.getMember(channel_id); member.notify_level = notify_level; ChannelStore.setChannelMember(member); - $(this.refs.modal.getDOMNode()).modal('hide'); + this.updateSection(""); }.bind(this), function(err) { this.setState({ server_error: err.message }); @@ -45,42 +71,138 @@ module.exports = React.createClass({ ); }, handleRadioClick: function(notifyLevel) { - this.setState({ notify_level: notifyLevel }); + this.setState({ notify_level: notifyLevel, quiet_mode: false }); this.refs.modal.getDOMNode().focus(); }, - handleQuietToggle: function() { - if (this.state.notify_level === "quiet") { - this.setState({ notify_level: "none" }); - this.refs.modal.getDOMNode().focus(); - } else { - this.setState({ notify_level: "quiet" }); - this.refs.modal.getDOMNode().focus(); - } + handleQuietToggle: function(quietMode) { + this.setState({ notify_level: "none", quiet_mode: quietMode }); + this.refs.modal.getDOMNode().focus(); }, render: function() { var server_error = this.state.server_error ?
: null; - var allActive = ""; - var mentionActive = ""; - var noneActive = ""; - var quietActive = ""; - var desktopHidden = ""; + var self = this; - if (this.state.notify_level === "quiet") { - desktopHidden = "hidden"; - quietActive = "active"; - } else if (this.state.notify_level === "mention") { - mentionActive = "active"; - } else if (this.state.notify_level === "none") { - noneActive = "active"; + var desktopSection; + if (this.state.activeSection === 'desktop') { + var notifyActive = [false, false, false]; + if (this.state.notify_level === "mention") { + notifyActive[1] = true; + } else if (this.state.notify_level === "all") { + notifyActive[0] = true; + } else { + notifyActive[2] = true; + } + + var inputs = []; + + inputs.push( +
+
+ +
+
+
+ +
+
+
+ +
+
+ ); + + desktopSection = ( + + ); } else { - allActive = "active"; + var describe = ""; + if (this.state.notify_level === "mention") { + describe = "Only for mentions"; + } else if (this.state.notify_level === "all") { + describe = "For all activity"; + } else { + describe = "Never"; + } + + desktopSection = ( + + ); + } + + var quietSection; + if (this.state.activeSection === 'quiet') { + var quietActive = ["",""]; + if (this.state.quiet_mode) { + quietActive[0] = "active"; + } else { + quietActive[1] = "active"; + } + + var inputs = []; + + inputs.push( +
+
+ + +
+
+ ); + + inputs.push( +
+
+ Enabling quiet mode will turn off desktop notifications and only mark the channel as unread if you have been mentioned. +
+ ); + + quietSection = ( + + ); + } else { + var describe = ""; + if (this.state.quiet_mode) { + describe = "On"; + } else { + describe = "Off"; + } + + quietSection = ( + + ); } var self = this; return (