diff options
26 files changed, 1612 insertions, 113 deletions
diff --git a/api/command.go b/api/command.go index 94b2cd2f8..52ff8fffd 100644 --- a/api/command.go +++ b/api/command.go @@ -145,7 +145,7 @@ func echoCommand(c *Context, command *model.Command) bool { time.Sleep(time.Duration(delay) * time.Second) - if _, err := CreatePost(c, post, false); err != nil { + if _, err := CreatePost(c, post, true); err != nil { l4g.Error("Unable to create /echo post, err=%v", err) } }() diff --git a/api/post.go b/api/post.go index 58fd3488a..73a63cb72 100644 --- a/api/post.go +++ b/api/post.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "path/filepath" + "regexp" "strconv" "strings" "time" @@ -55,11 +56,15 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { return } else { + if result := <-Srv.Store.Channel().UpdateLastViewedAt(post.ChannelId, c.Session.UserId); result.Err != nil { + l4g.Error("Encountered error updating last viewed, channel_id=%s, user_id=%s, err=%v", post.ChannelId, c.Session.UserId, result.Err) + } + w.Write([]byte(rp.ToJson())) } } -func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.Post, *model.AppError) { +func CreatePost(c *Context, post *model.Post, triggerWebhooks bool) (*model.Post, *model.AppError) { var pchan store.StoreChannel if len(post.RootId) > 0 { pchan = Srv.Store.Post().Get(post.RootId) @@ -130,50 +135,193 @@ func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.P var rpost *model.Post if result := <-Srv.Store.Post().Save(post); result.Err != nil { return nil, result.Err - } else if doUpdateLastViewed && (<-Srv.Store.Channel().UpdateLastViewedAt(post.ChannelId, c.Session.UserId)).Err != nil { - return nil, result.Err } else { rpost = result.Data.(*model.Post) - fireAndForgetNotifications(rpost, c.Session.TeamId, c.GetSiteURL()) + handlePostEventsAndForget(c, rpost, triggerWebhooks) } return rpost, nil } -func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) { +func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIconUrl string) (*model.Post, *model.AppError) { + // parse links into Markdown format + linkWithTextRegex := regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`) + text = linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})") + + linkRegex := regexp.MustCompile(`<\s*(\S*)\s*>`) + text = linkRegex.ReplaceAllString(text, "${1}") + + post := &model.Post{UserId: c.Session.UserId, ChannelId: channelId, Message: text} + post.AddProp("from_webhook", "true") + + if utils.Cfg.ServiceSettings.EnablePostUsernameOverride { + if len(overrideUsername) != 0 { + post.AddProp("override_username", overrideUsername) + } else { + post.AddProp("override_username", model.DEFAULT_WEBHOOK_USERNAME) + } + } + if utils.Cfg.ServiceSettings.EnablePostIconOverride { + if len(overrideIconUrl) != 0 { + post.AddProp("override_icon_url", overrideIconUrl) + } else { + post.AddProp("override_icon_url", model.DEFAULT_WEBHOOK_ICON) + } + } + + if _, err := CreatePost(c, post, false); err != nil { + return nil, model.NewAppError("CreateWebhookPost", "Error creating post", "err="+err.Message) + } + + return post, nil +} + +func handlePostEventsAndForget(c *Context, post *model.Post, triggerWebhooks bool) { go func() { - // Get a list of user names (to be used as keywords) and ids for the given team - uchan := Srv.Store.User().GetProfiles(teamId) - echan := Srv.Store.Channel().GetMembers(post.ChannelId) + tchan := Srv.Store.Team().Get(c.Session.TeamId) cchan := Srv.Store.Channel().Get(post.ChannelId) - tchan := Srv.Store.Team().Get(teamId) + uchan := Srv.Store.User().Get(post.UserId) + + var team *model.Team + if result := <-tchan; result.Err != nil { + l4g.Error("Encountered error getting team, team_id=%s, err=%v", c.Session.TeamId, result.Err) + return + } else { + team = result.Data.(*model.Team) + } var channel *model.Channel - var channelName string - var bodyText string - var subjectText string if result := <-cchan; result.Err != nil { - l4g.Error("Failed to retrieve channel channel_id=%v, err=%v", post.ChannelId, result.Err) + l4g.Error("Encountered error getting channel, channel_id=%s, err=%v", post.ChannelId, result.Err) return } else { channel = result.Data.(*model.Channel) - if channel.Type == model.CHANNEL_DIRECT { - bodyText = "You have one new message." - subjectText = "New Direct Message" - } else { - bodyText = "You have one new mention." - subjectText = "New Mention" - channelName = channel.DisplayName + } + + fireAndForgetNotifications(c, post, team, channel) + + var user *model.User + if result := <-uchan; result.Err != nil { + l4g.Error("Encountered error getting user, user_id=%s, err=%v", post.UserId, result.Err) + return + } else { + user = result.Data.(*model.User) + } + + if triggerWebhooks { + handleWebhookEventsAndForget(c, post, team, channel, user) + } + }() +} + +func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team, channel *model.Channel, user *model.User) { + go func() { + hchan := Srv.Store.Webhook().GetOutgoingByTeam(c.Session.TeamId) + + hooks := []*model.OutgoingWebhook{} + + if result := <-hchan; result.Err != nil { + l4g.Error("Encountered error getting webhooks by team, err=%v", result.Err) + return + } else { + hooks = result.Data.([]*model.OutgoingWebhook) + } + + if len(hooks) == 0 { + return + } + + firstWord := strings.Split(post.Message, " ")[0] + + relevantHooks := []*model.OutgoingWebhook{} + + for _, hook := range hooks { + if hook.ChannelId == post.ChannelId { + if len(hook.TriggerWords) == 0 || hook.HasTriggerWord(firstWord) { + relevantHooks = append(relevantHooks, hook) + } + } else if len(hook.ChannelId) == 0 && hook.HasTriggerWord(firstWord) { + relevantHooks = append(relevantHooks, hook) } } + for _, hook := range relevantHooks { + go func() { + p := url.Values{} + p.Set("token", hook.Token) + + p.Set("team_id", hook.TeamId) + p.Set("team_domain", team.Name) + + p.Set("channel_id", post.ChannelId) + p.Set("channel_name", channel.Name) + + p.Set("timestamp", strconv.FormatInt(post.CreateAt/1000, 10)) + + p.Set("user_id", post.UserId) + p.Set("user_name", user.Username) + + p.Set("text", post.Message) + p.Set("trigger_word", firstWord) + + client := &http.Client{} + + for _, url := range hook.CallbackURLs { + go func() { + req, _ := http.NewRequest("POST", url, strings.NewReader(p.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if resp, err := client.Do(req); err != nil { + l4g.Error("Event POST failed, err=%s", err.Error()) + } else { + respProps := model.MapFromJson(resp.Body) + + // copy the context and create a mock session for posting the message + mockSession := model.Session{UserId: hook.CreatorId, TeamId: hook.TeamId, IsOAuth: false} + newContext := &Context{mockSession, model.NewId(), "", c.Path, nil, c.teamURLValid, c.teamURL, c.siteURL} + + if text, ok := respProps["text"]; ok { + if _, err := CreateWebhookPost(newContext, post.ChannelId, text, respProps["username"], respProps["icon_url"]); err != nil { + l4g.Error("Failed to create response post, err=%v", err) + } + } + } + }() + } + + }() + } + + }() + +} + +func fireAndForgetNotifications(c *Context, post *model.Post, team *model.Team, channel *model.Channel) { + + 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 channelName string + var bodyText string + var subjectText string + if channel.Type == model.CHANNEL_DIRECT { + bodyText = "You have one new message." + subjectText = "New Direct Message" + } else { + bodyText = "You have one new mention." + subjectText = "New Mention" + channelName = channel.DisplayName + } + var mentionedUsers []string if result := <-uchan; result.Err != nil { - l4g.Error("Failed to retrieve user profiles team_id=%v, err=%v", teamId, result.Err) + l4g.Error("Failed to retrieve user profiles team_id=%v, err=%v", c.Session.TeamId, result.Err) return } else { profileMap := result.Data.(map[string]*model.User) @@ -296,23 +444,15 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) { mentionedUsers = append(mentionedUsers, k) } - var teamDisplayName string - var teamURL string - if result := <-tchan; result.Err != nil { - l4g.Error("Failed to retrieve team team_id=%v, err=%v", teamId, result.Err) - return - } else { - teamDisplayName = result.Data.(*model.Team).DisplayName - teamURL = siteURL + "/" + result.Data.(*model.Team).Name - } + teamURL := c.GetSiteURL() + "/" + team.Name // Build and send the emails location, _ := time.LoadLocation("UTC") tm := time.Unix(post.CreateAt/1000, 0).In(location) subjectPage := NewServerTemplatePage("post_subject") - subjectPage.Props["SiteURL"] = siteURL - subjectPage.Props["TeamDisplayName"] = teamDisplayName + subjectPage.Props["SiteURL"] = c.GetSiteURL() + subjectPage.Props["TeamDisplayName"] = team.DisplayName subjectPage.Props["SubjectText"] = subjectText subjectPage.Props["Month"] = tm.Month().String()[:3] subjectPage.Props["Day"] = fmt.Sprintf("%d", tm.Day()) @@ -330,9 +470,9 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) { } bodyPage := NewServerTemplatePage("post_body") - bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["SiteURL"] = c.GetSiteURL() bodyPage.Props["Nickname"] = profileMap[id].FirstName - bodyPage.Props["TeamDisplayName"] = teamDisplayName + bodyPage.Props["TeamDisplayName"] = team.DisplayName bodyPage.Props["ChannelName"] = channelName bodyPage.Props["BodyText"] = bodyText bodyPage.Props["SenderName"] = senderName @@ -399,7 +539,7 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) { } } - message := model.NewMessage(teamId, post.ChannelId, post.UserId, model.ACTION_POSTED) + message := model.NewMessage(c.Session.TeamId, post.ChannelId, post.UserId, model.ACTION_POSTED) message.Add("post", post.ToJson()) if len(post.Filenames) != 0 { diff --git a/api/webhook.go b/api/webhook.go index de4ba6691..34c308879 100644 --- a/api/webhook.go +++ b/api/webhook.go @@ -18,6 +18,11 @@ func InitWebhook(r *mux.Router) { sr.Handle("/incoming/create", ApiUserRequired(createIncomingHook)).Methods("POST") sr.Handle("/incoming/delete", ApiUserRequired(deleteIncomingHook)).Methods("POST") sr.Handle("/incoming/list", ApiUserRequired(getIncomingHooks)).Methods("GET") + + sr.Handle("/outgoing/create", ApiUserRequired(createOutgoingHook)).Methods("POST") + sr.Handle("/outgoing/regen_token", ApiUserRequired(regenOutgoingHookToken)).Methods("POST") + sr.Handle("/outgoing/delete", ApiUserRequired(deleteOutgoingHook)).Methods("POST") + sr.Handle("/outgoing/list", ApiUserRequired(getOutgoingHooks)).Methods("GET") } func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { @@ -50,9 +55,11 @@ func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { channel = result.Data.(*model.Channel) } - if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN { - c.LogAudit("fail - bad channel permissions") - return + if !c.HasPermissionsToChannel(pchan, "createIncomingHook") { + if channel.Type != model.CHANNEL_OPEN || channel.TeamId != c.Session.TeamId { + c.LogAudit("fail - bad channel permissions") + return + } } if result := <-Srv.Store.Webhook().SaveIncoming(hook); result.Err != nil { @@ -67,7 +74,7 @@ func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks { - c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "") + c.Err = model.NewAppError("deleteIncomingHook", "Incoming webhooks have been disabled by the system admin.", "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -87,7 +94,7 @@ func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { return } else { if c.Session.UserId != result.Data.(*model.IncomingWebhook).UserId && !c.IsTeamAdmin() { - c.LogAudit("fail - inappropriate conditions") + c.LogAudit("fail - inappropriate permissions") c.Err = model.NewAppError("deleteIncomingHook", "Inappropriate permissions to delete incoming webhook", "user_id="+c.Session.UserId) return } @@ -104,7 +111,7 @@ func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) { if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks { - c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "") + c.Err = model.NewAppError("getIncomingHooks", "Incoming webhooks have been disabled by the system admin.", "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -117,3 +124,153 @@ func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.IncomingWebhookListToJson(hooks))) } } + +func createOutgoingHook(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOutgoingWebhooks { + c.Err = model.NewAppError("createOutgoingHook", "Outgoing webhooks have been disabled by the system admin.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + c.LogAudit("attempt") + + hook := model.OutgoingWebhookFromJson(r.Body) + + if hook == nil { + c.SetInvalidParam("createOutgoingHook", "webhook") + return + } + + hook.CreatorId = c.Session.UserId + hook.TeamId = c.Session.TeamId + + if len(hook.ChannelId) != 0 { + cchan := Srv.Store.Channel().Get(hook.ChannelId) + pchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, hook.ChannelId, c.Session.UserId) + + var channel *model.Channel + if result := <-cchan; result.Err != nil { + c.Err = result.Err + return + } else { + channel = result.Data.(*model.Channel) + } + + if channel.Type != model.CHANNEL_OPEN { + c.LogAudit("fail - not open channel") + } + + if !c.HasPermissionsToChannel(pchan, "createOutgoingHook") { + if channel.Type != model.CHANNEL_OPEN || channel.TeamId != c.Session.TeamId { + c.LogAudit("fail - bad channel permissions") + return + } + } + } else if len(hook.TriggerWords) == 0 { + c.Err = model.NewAppError("createOutgoingHook", "Either trigger_words or channel_id must be set", "") + return + } + + if result := <-Srv.Store.Webhook().SaveOutgoing(hook); result.Err != nil { + c.Err = result.Err + return + } else { + c.LogAudit("success") + rhook := result.Data.(*model.OutgoingWebhook) + w.Write([]byte(rhook.ToJson())) + } +} + +func getOutgoingHooks(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOutgoingWebhooks { + c.Err = model.NewAppError("getOutgoingHooks", "Outgoing webhooks have been disabled by the system admin.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if result := <-Srv.Store.Webhook().GetOutgoingByCreator(c.Session.UserId); result.Err != nil { + c.Err = result.Err + return + } else { + hooks := result.Data.([]*model.OutgoingWebhook) + w.Write([]byte(model.OutgoingWebhookListToJson(hooks))) + } +} + +func deleteOutgoingHook(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks { + c.Err = model.NewAppError("deleteOutgoingHook", "Outgoing webhooks have been disabled by the system admin.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + c.LogAudit("attempt") + + props := model.MapFromJson(r.Body) + + id := props["id"] + if len(id) == 0 { + c.SetInvalidParam("deleteIncomingHook", "id") + return + } + + if result := <-Srv.Store.Webhook().GetOutgoing(id); result.Err != nil { + c.Err = result.Err + return + } else { + if c.Session.UserId != result.Data.(*model.OutgoingWebhook).CreatorId && !c.IsTeamAdmin() { + c.LogAudit("fail - inappropriate permissions") + c.Err = model.NewAppError("deleteOutgoingHook", "Inappropriate permissions to delete outcoming webhook", "user_id="+c.Session.UserId) + return + } + } + + if err := (<-Srv.Store.Webhook().DeleteOutgoing(id, model.GetMillis())).Err; err != nil { + c.Err = err + return + } + + c.LogAudit("success") + w.Write([]byte(model.MapToJson(props))) +} + +func regenOutgoingHookToken(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableIncomingWebhooks { + c.Err = model.NewAppError("regenOutgoingHookToken", "Outgoing webhooks have been disabled by the system admin.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + c.LogAudit("attempt") + + props := model.MapFromJson(r.Body) + + id := props["id"] + if len(id) == 0 { + c.SetInvalidParam("regenOutgoingHookToken", "id") + return + } + + var hook *model.OutgoingWebhook + if result := <-Srv.Store.Webhook().GetOutgoing(id); result.Err != nil { + c.Err = result.Err + return + } else { + hook = result.Data.(*model.OutgoingWebhook) + + if c.Session.UserId != hook.CreatorId && !c.IsTeamAdmin() { + c.LogAudit("fail - inappropriate permissions") + c.Err = model.NewAppError("regenOutgoingHookToken", "Inappropriate permissions to regenerate outcoming webhook token", "user_id="+c.Session.UserId) + return + } + } + + hook.Token = model.NewId() + + if result := <-Srv.Store.Webhook().UpdateOutgoing(hook); result.Err != nil { + c.Err = result.Err + return + } else { + w.Write([]byte(result.Data.(*model.OutgoingWebhook).ToJson())) + } +} diff --git a/api/webhook_test.go b/api/webhook_test.go index 16b9c9529..4c04a9922 100644 --- a/api/webhook_test.go +++ b/api/webhook_test.go @@ -152,6 +152,187 @@ func TestDeleteIncomingHook(t *testing.T) { } } +func TestCreateOutgoingHook(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) + + hook := &model.OutgoingWebhook{ChannelId: channel1.Id, CallbackURLs: []string{"http://nowhere.com"}} + + if utils.Cfg.ServiceSettings.EnableOutgoingWebhooks { + var rhook *model.OutgoingWebhook + if result, err := Client.CreateOutgoingWebhook(hook); err != nil { + t.Fatal(err) + } else { + rhook = result.Data.(*model.OutgoingWebhook) + } + + if hook.ChannelId != rhook.ChannelId { + t.Fatal("channel ids didn't match") + } + + if rhook.CreatorId != user.Id { + t.Fatal("user ids didn't match") + } + + if rhook.TeamId != team.Id { + t.Fatal("team ids didn't match") + } + + hook = &model.OutgoingWebhook{ChannelId: "junk", CallbackURLs: []string{"http://nowhere.com"}} + if _, err := Client.CreateOutgoingWebhook(hook); err == nil { + t.Fatal("should have failed - bad channel id") + } + + hook = &model.OutgoingWebhook{ChannelId: channel2.Id, CreatorId: "123", TeamId: "456", CallbackURLs: []string{"http://nowhere.com"}} + if result, err := Client.CreateOutgoingWebhook(hook); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.OutgoingWebhook).CreatorId != user.Id { + t.Fatal("bad user id wasn't overwritten") + } + if result.Data.(*model.OutgoingWebhook).TeamId != team.Id { + t.Fatal("bad team id wasn't overwritten") + } + } + } else { + if _, err := Client.CreateOutgoingWebhook(hook); err == nil { + t.Fatal("should have errored - webhooks turned off") + } + } +} + +func TestListOutgoingHooks(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + if utils.Cfg.ServiceSettings.EnableOutgoingWebhooks { + hook1 := &model.OutgoingWebhook{ChannelId: channel1.Id, CallbackURLs: []string{"http://nowhere.com"}} + hook1 = Client.Must(Client.CreateOutgoingWebhook(hook1)).Data.(*model.OutgoingWebhook) + + hook2 := &model.OutgoingWebhook{TriggerWords: []string{"trigger"}, CallbackURLs: []string{"http://nowhere.com"}} + hook2 = Client.Must(Client.CreateOutgoingWebhook(hook2)).Data.(*model.OutgoingWebhook) + + if result, err := Client.ListOutgoingWebhooks(); err != nil { + t.Fatal(err) + } else { + hooks := result.Data.([]*model.OutgoingWebhook) + + if len(hooks) != 2 { + t.Fatal("incorrect number of hooks") + } + } + } else { + if _, err := Client.ListOutgoingWebhooks(); err == nil { + t.Fatal("should have errored - webhooks turned off") + } + } +} + +func TestDeleteOutgoingHook(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + if utils.Cfg.ServiceSettings.EnableOutgoingWebhooks { + hook := &model.OutgoingWebhook{ChannelId: channel1.Id, CallbackURLs: []string{"http://nowhere.com"}} + hook = Client.Must(Client.CreateOutgoingWebhook(hook)).Data.(*model.OutgoingWebhook) + + data := make(map[string]string) + data["id"] = hook.Id + + if _, err := Client.DeleteOutgoingWebhook(data); err != nil { + t.Fatal(err) + } + + hooks := Client.Must(Client.ListOutgoingWebhooks()).Data.([]*model.OutgoingWebhook) + if len(hooks) != 0 { + t.Fatal("delete didn't work properly") + } + } else { + data := make(map[string]string) + data["id"] = "123" + + if _, err := Client.DeleteOutgoingWebhook(data); err == nil { + t.Fatal("should have errored - webhooks turned off") + } + } +} + +func TestRegenOutgoingHookToken(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + if utils.Cfg.ServiceSettings.EnableOutgoingWebhooks { + hook := &model.OutgoingWebhook{ChannelId: channel1.Id, CallbackURLs: []string{"http://nowhere.com"}} + hook = Client.Must(Client.CreateOutgoingWebhook(hook)).Data.(*model.OutgoingWebhook) + + data := make(map[string]string) + data["id"] = hook.Id + + if result, err := Client.RegenOutgoingWebhookToken(data); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.OutgoingWebhook).Token == hook.Token { + t.Fatal("regen didn't work properly") + } + } + + } else { + data := make(map[string]string) + data["id"] = "123" + + if _, err := Client.RegenOutgoingWebhookToken(data); err == nil { + t.Fatal("should have errored - webhooks turned off") + } + } +} + func TestZZWebSocketTearDown(t *testing.T) { // *IMPORTANT* - Kind of hacky // This should be the last function in any test file diff --git a/config/config.json b/config/config.json index 8ef151350..37109428d 100644 --- a/config/config.json +++ b/config/config.json @@ -6,6 +6,7 @@ "GoogleDeveloperKey": "", "EnableOAuthServiceProvider": false, "EnableIncomingWebhooks": true, + "EnableOutgoingWebhooks": true, "EnablePostUsernameOverride": false, "EnablePostIconOverride": false, "EnableTesting": false, diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json index 653b6ffd7..00729395e 100644 --- a/docker/dev/config_docker.json +++ b/docker/dev/config_docker.json @@ -6,6 +6,7 @@ "GoogleDeveloperKey": "", "EnableOAuthServiceProvider": false, "EnableIncomingWebhooks": true, + "EnableOutgoingWebhooks": true, "EnablePostUsernameOverride": false, "EnablePostIconOverride": false, "EnableTesting": false, diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json index 653b6ffd7..00729395e 100644 --- a/docker/local/config_docker.json +++ b/docker/local/config_docker.json @@ -6,6 +6,7 @@ "GoogleDeveloperKey": "", "EnableOAuthServiceProvider": false, "EnableIncomingWebhooks": true, + "EnableOutgoingWebhooks": true, "EnablePostUsernameOverride": false, "EnablePostIconOverride": false, "EnableTesting": false, diff --git a/model/client.go b/model/client.go index eea65c50e..9183dcacb 100644 --- a/model/client.go +++ b/model/client.go @@ -879,6 +879,42 @@ func (c *Client) GetPreferenceCategory(category string) (*Result, *AppError) { } } +func (c *Client) CreateOutgoingWebhook(hook *OutgoingWebhook) (*Result, *AppError) { + if r, err := c.DoApiPost("/hooks/outgoing/create", hook.ToJson()); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), OutgoingWebhookFromJson(r.Body)}, nil + } +} + +func (c *Client) DeleteOutgoingWebhook(data map[string]string) (*Result, *AppError) { + if r, err := c.DoApiPost("/hooks/outgoing/delete", MapToJson(data)); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil + } +} + +func (c *Client) ListOutgoingWebhooks() (*Result, *AppError) { + if r, err := c.DoApiGet("/hooks/outgoing/list", "", ""); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), OutgoingWebhookListFromJson(r.Body)}, nil + } +} + +func (c *Client) RegenOutgoingWebhookToken(data map[string]string) (*Result, *AppError) { + if r, err := c.DoApiPost("/hooks/outgoing/regen_token", MapToJson(data)); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), OutgoingWebhookFromJson(r.Body)}, nil + } +} + func (c *Client) MockSession(sessionToken string) { c.AuthToken = sessionToken c.AuthType = HEADER_BEARER diff --git a/model/config.go b/model/config.go index 8a11b7bb7..ef76877c2 100644 --- a/model/config.go +++ b/model/config.go @@ -29,6 +29,7 @@ type ServiceSettings struct { GoogleDeveloperKey string EnableOAuthServiceProvider bool EnableIncomingWebhooks bool + EnableOutgoingWebhooks bool EnablePostUsernameOverride bool EnablePostIconOverride bool EnableTesting bool diff --git a/model/webhook.go b/model/incoming_webhook.go index 9b9969b96..9b9969b96 100644 --- a/model/webhook.go +++ b/model/incoming_webhook.go diff --git a/model/webhook_test.go b/model/incoming_webhook_test.go index 5297d7d90..5297d7d90 100644 --- a/model/webhook_test.go +++ b/model/incoming_webhook_test.go diff --git a/model/outgoing_webhook.go b/model/outgoing_webhook.go new file mode 100644 index 000000000..8958dd5b0 --- /dev/null +++ b/model/outgoing_webhook.go @@ -0,0 +1,135 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "fmt" + "io" +) + +type OutgoingWebhook struct { + Id string `json:"id"` + Token string `json:"token"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + CreatorId string `json:"creator_id"` + ChannelId string `json:"channel_id"` + TeamId string `json:"team_id"` + TriggerWords StringArray `json:"trigger_words"` + CallbackURLs StringArray `json:"callback_urls"` +} + +func (o *OutgoingWebhook) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func OutgoingWebhookFromJson(data io.Reader) *OutgoingWebhook { + decoder := json.NewDecoder(data) + var o OutgoingWebhook + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +func OutgoingWebhookListToJson(l []*OutgoingWebhook) string { + b, err := json.Marshal(l) + if err != nil { + return "" + } else { + return string(b) + } +} + +func OutgoingWebhookListFromJson(data io.Reader) []*OutgoingWebhook { + decoder := json.NewDecoder(data) + var o []*OutgoingWebhook + err := decoder.Decode(&o) + if err == nil { + return o + } else { + return nil + } +} + +func (o *OutgoingWebhook) IsValid() *AppError { + + if len(o.Id) != 26 { + return NewAppError("OutgoingWebhook.IsValid", "Invalid Id", "") + } + + if len(o.Token) != 26 { + return NewAppError("OutgoingWebhook.IsValid", "Invalid token", "") + } + + if o.CreateAt == 0 { + return NewAppError("OutgoingWebhook.IsValid", "Create at must be a valid time", "id="+o.Id) + } + + if o.UpdateAt == 0 { + return NewAppError("OutgoingWebhook.IsValid", "Update at must be a valid time", "id="+o.Id) + } + + if len(o.CreatorId) != 26 { + return NewAppError("OutgoingWebhook.IsValid", "Invalid user id", "") + } + + if len(o.ChannelId) != 0 && len(o.ChannelId) != 26 { + return NewAppError("OutgoingWebhook.IsValid", "Invalid channel id", "") + } + + if len(o.TeamId) != 26 { + return NewAppError("OutgoingWebhook.IsValid", "Invalid team id", "") + } + + if len(fmt.Sprintf("%s", o.TriggerWords)) > 1024 { + return NewAppError("OutgoingWebhook.IsValid", "Invalid trigger words", "") + } + + if len(o.CallbackURLs) == 0 || len(fmt.Sprintf("%s", o.CallbackURLs)) > 1024 { + return NewAppError("OutgoingWebhook.IsValid", "Invalid callback urls", "") + } + + return nil +} + +func (o *OutgoingWebhook) PreSave() { + if o.Id == "" { + o.Id = NewId() + } + + if o.Token == "" { + o.Token = NewId() + } + + o.CreateAt = GetMillis() + o.UpdateAt = o.CreateAt +} + +func (o *OutgoingWebhook) PreUpdate() { + o.UpdateAt = GetMillis() +} + +func (o *OutgoingWebhook) HasTriggerWord(word string) bool { + if len(o.TriggerWords) == 0 || len(word) == 0 { + return false + } + + for _, trigger := range o.TriggerWords { + if trigger == word { + return true + } + } + + return false +} diff --git a/model/outgoing_webhook_test.go b/model/outgoing_webhook_test.go new file mode 100644 index 000000000..2ca48c291 --- /dev/null +++ b/model/outgoing_webhook_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestOutgoingWebhookJson(t *testing.T) { + o := OutgoingWebhook{Id: NewId()} + json := o.ToJson() + ro := OutgoingWebhookFromJson(strings.NewReader(json)) + + if o.Id != ro.Id { + t.Fatal("Ids do not match") + } +} + +func TestOutgoingWebhookIsValid(t *testing.T) { + o := OutgoingWebhook{} + + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.Id = NewId() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.CreateAt = GetMillis() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.UpdateAt = GetMillis() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.CreatorId = "123" + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.CreatorId = NewId() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.Token = "123" + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.Token = NewId() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.ChannelId = "123" + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.ChannelId = NewId() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.TeamId = "123" + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.TeamId = NewId() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.CallbackURLs = []string{"http://nowhere.com/"} + if err := o.IsValid(); err != nil { + t.Fatal(err) + } +} + +func TestOutgoingWebhookPreSave(t *testing.T) { + o := OutgoingWebhook{} + o.PreSave() +} + +func TestOutgoingWebhookPreUpdate(t *testing.T) { + o := OutgoingWebhook{} + o.PreUpdate() +} diff --git a/store/sql_store.go b/store/sql_store.go index 692ac2664..c5bf840a1 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -30,6 +30,11 @@ import ( "github.com/mattermost/platform/utils" ) +const ( + INDEX_TYPE_FULL_TEXT = "full_text" + INDEX_TYPE_DEFAULT = "default" +) + type SqlStore struct { master *gorp.DbMap replicas []*gorp.DbMap @@ -363,14 +368,14 @@ func (ss SqlStore) RemoveColumnIfExists(tableName string, columnName string) boo // } func (ss SqlStore) CreateIndexIfNotExists(indexName string, tableName string, columnName string) { - ss.createIndexIfNotExists(indexName, tableName, columnName, false) + ss.createIndexIfNotExists(indexName, tableName, columnName, INDEX_TYPE_DEFAULT) } func (ss SqlStore) CreateFullTextIndexIfNotExists(indexName string, tableName string, columnName string) { - ss.createIndexIfNotExists(indexName, tableName, columnName, true) + ss.createIndexIfNotExists(indexName, tableName, columnName, INDEX_TYPE_FULL_TEXT) } -func (ss SqlStore) createIndexIfNotExists(indexName string, tableName string, columnName string, fullText bool) { +func (ss SqlStore) createIndexIfNotExists(indexName string, tableName string, columnName string, indexType string) { if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { _, err := ss.GetMaster().SelectStr("SELECT $1::regclass", indexName) @@ -380,7 +385,7 @@ func (ss SqlStore) createIndexIfNotExists(indexName string, tableName string, co } query := "" - if fullText { + if indexType == INDEX_TYPE_FULL_TEXT { query = "CREATE INDEX " + indexName + " ON " + tableName + " USING gin(to_tsvector('english', " + columnName + "))" } else { query = "CREATE INDEX " + indexName + " ON " + tableName + " (" + columnName + ")" @@ -406,7 +411,7 @@ func (ss SqlStore) createIndexIfNotExists(indexName string, tableName string, co } fullTextIndex := "" - if fullText { + if indexType == INDEX_TYPE_FULL_TEXT { fullTextIndex = " FULLTEXT " } diff --git a/store/sql_webhook_store.go b/store/sql_webhook_store.go index 42a91a80e..1910984f0 100644 --- a/store/sql_webhook_store.go +++ b/store/sql_webhook_store.go @@ -20,6 +20,15 @@ func NewSqlWebhookStore(sqlStore *SqlStore) WebhookStore { table.ColMap("UserId").SetMaxSize(26) table.ColMap("ChannelId").SetMaxSize(26) table.ColMap("TeamId").SetMaxSize(26) + + tableo := db.AddTableWithName(model.OutgoingWebhook{}, "OutgoingWebhooks").SetKeys(false, "Id") + tableo.ColMap("Id").SetMaxSize(26) + tableo.ColMap("Token").SetMaxSize(26) + tableo.ColMap("CreatorId").SetMaxSize(26) + tableo.ColMap("ChannelId").SetMaxSize(26) + tableo.ColMap("TeamId").SetMaxSize(26) + tableo.ColMap("TriggerWords").SetMaxSize(1024) + tableo.ColMap("CallbackURLs").SetMaxSize(1024) } return s @@ -29,8 +38,9 @@ func (s SqlWebhookStore) UpgradeSchemaIfNeeded() { } func (s SqlWebhookStore) CreateIndexesIfNotExists() { - s.CreateIndexIfNotExists("idx_webhook_user_id", "IncomingWebhooks", "UserId") - s.CreateIndexIfNotExists("idx_webhook_team_id", "IncomingWebhooks", "TeamId") + s.CreateIndexIfNotExists("idx_incoming_webhook_user_id", "IncomingWebhooks", "UserId") + s.CreateIndexIfNotExists("idx_incoming_webhook_team_id", "IncomingWebhooks", "TeamId") + s.CreateIndexIfNotExists("idx_outgoing_webhook_team_id", "OutgoingWebhooks", "TeamId") } func (s SqlWebhookStore) SaveIncoming(webhook *model.IncomingWebhook) StoreChannel { @@ -126,3 +136,160 @@ func (s SqlWebhookStore) GetIncomingByUser(userId string) StoreChannel { return storeChannel } + +func (s SqlWebhookStore) SaveOutgoing(webhook *model.OutgoingWebhook) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if len(webhook.Id) > 0 { + result.Err = model.NewAppError("SqlWebhookStore.SaveOutgoing", + "You cannot overwrite an existing OutgoingWebhook", "id="+webhook.Id) + storeChannel <- result + close(storeChannel) + return + } + + webhook.PreSave() + if result.Err = webhook.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + if err := s.GetMaster().Insert(webhook); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.SaveOutgoing", "We couldn't save the OutgoingWebhook", "id="+webhook.Id+", "+err.Error()) + } else { + result.Data = webhook + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) GetOutgoing(id string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var webhook model.OutgoingWebhook + + if err := s.GetReplica().SelectOne(&webhook, "SELECT * FROM OutgoingWebhooks WHERE Id = :Id AND DeleteAt = 0", map[string]interface{}{"Id": id}); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.GetOutgoing", "We couldn't get the webhook", "id="+id+", err="+err.Error()) + } + + result.Data = &webhook + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) GetOutgoingByCreator(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var webhooks []*model.OutgoingWebhook + + if _, err := s.GetReplica().Select(&webhooks, "SELECT * FROM OutgoingWebhooks WHERE CreatorId = :UserId AND DeleteAt = 0", map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.GetOutgoingByCreator", "We couldn't get the webhooks", "userId="+userId+", err="+err.Error()) + } + + result.Data = webhooks + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) GetOutgoingByChannel(channelId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var webhooks []*model.OutgoingWebhook + + if _, err := s.GetReplica().Select(&webhooks, "SELECT * FROM OutgoingWebhooks WHERE ChannelId = :ChannelId AND DeleteAt = 0", map[string]interface{}{"ChannelId": channelId}); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.GetOutgoingByChannel", "We couldn't get the webhooks", "channelId="+channelId+", err="+err.Error()) + } + + result.Data = webhooks + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) GetOutgoingByTeam(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var webhooks []*model.OutgoingWebhook + + if _, err := s.GetReplica().Select(&webhooks, "SELECT * FROM OutgoingWebhooks WHERE TeamId = :TeamId AND DeleteAt = 0", map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.GetOutgoingByTeam", "We couldn't get the webhooks", "teamId="+teamId+", err="+err.Error()) + } + + result.Data = webhooks + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) DeleteOutgoing(webhookId string, time int64) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := s.GetMaster().Exec("Update OutgoingWebhooks SET DeleteAt = :DeleteAt, UpdateAt = :UpdateAt WHERE Id = :Id", map[string]interface{}{"DeleteAt": time, "UpdateAt": time, "Id": webhookId}) + if err != nil { + result.Err = model.NewAppError("SqlWebhookStore.DeleteOutgoing", "We couldn't delete the webhook", "id="+webhookId+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) UpdateOutgoing(hook *model.OutgoingWebhook) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + hook.UpdateAt = model.GetMillis() + + if _, err := s.GetMaster().Update(hook); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.UpdateOutgoing", "We couldn't update the webhook", "id="+hook.Id+", "+err.Error()) + } else { + result.Data = hook + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_webhook_store_test.go b/store/sql_webhook_store_test.go index 6f4ef4354..1fb990f3e 100644 --- a/store/sql_webhook_store_test.go +++ b/store/sql_webhook_store_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestIncomingWebhookStoreSaveIncoming(t *testing.T) { +func TestWebhookStoreSaveIncoming(t *testing.T) { Setup() o1 := model.IncomingWebhook{} @@ -25,7 +25,7 @@ func TestIncomingWebhookStoreSaveIncoming(t *testing.T) { } } -func TestIncomingWebhookStoreGetIncoming(t *testing.T) { +func TestWebhookStoreGetIncoming(t *testing.T) { Setup() o1 := &model.IncomingWebhook{} @@ -48,7 +48,34 @@ func TestIncomingWebhookStoreGetIncoming(t *testing.T) { } } -func TestIncomingWebhookStoreDelete(t *testing.T) { +func TestWebhookStoreGetIncomingByUser(t *testing.T) { + Setup() + + o1 := &model.IncomingWebhook{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.TeamId = model.NewId() + + o1 = (<-store.Webhook().SaveIncoming(o1)).Data.(*model.IncomingWebhook) + + if r1 := <-store.Webhook().GetIncomingByUser(o1.UserId); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.([]*model.IncomingWebhook)[0].CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if result := <-store.Webhook().GetIncomingByUser("123"); result.Err != nil { + t.Fatal(result.Err) + } else { + if len(result.Data.([]*model.IncomingWebhook)) != 0 { + t.Fatal("no webhooks should have returned") + } + } +} + +func TestWebhookStoreDeleteIncoming(t *testing.T) { Setup() o1 := &model.IncomingWebhook{} @@ -75,3 +102,176 @@ func TestIncomingWebhookStoreDelete(t *testing.T) { t.Fatal("Missing id should have failed") } } + +func TestWebhookStoreSaveOutgoing(t *testing.T) { + Setup() + + o1 := model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + if err := (<-store.Webhook().SaveOutgoing(&o1)).Err; err != nil { + t.Fatal("couldn't save item", err) + } + + if err := (<-store.Webhook().SaveOutgoing(&o1)).Err; err == nil { + t.Fatal("shouldn't be able to update from save") + } +} + +func TestWebhookStoreGetOutgoing(t *testing.T) { + Setup() + + o1 := &model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook) + + if r1 := <-store.Webhook().GetOutgoing(o1.Id); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(*model.OutgoingWebhook).CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if err := (<-store.Webhook().GetOutgoing("123")).Err; err == nil { + t.Fatal("Missing id should have failed") + } +} + +func TestWebhookStoreGetOutgoingByChannel(t *testing.T) { + Setup() + + o1 := &model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook) + + if r1 := <-store.Webhook().GetOutgoingByChannel(o1.ChannelId); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.([]*model.OutgoingWebhook)[0].CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if result := <-store.Webhook().GetOutgoingByChannel("123"); result.Err != nil { + t.Fatal(result.Err) + } else { + if len(result.Data.([]*model.OutgoingWebhook)) != 0 { + t.Fatal("no webhooks should have returned") + } + } +} + +func TestWebhookStoreGetOutgoingByCreator(t *testing.T) { + Setup() + + o1 := &model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook) + + if r1 := <-store.Webhook().GetOutgoingByCreator(o1.CreatorId); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.([]*model.OutgoingWebhook)[0].CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if result := <-store.Webhook().GetOutgoingByCreator("123"); result.Err != nil { + t.Fatal(result.Err) + } else { + if len(result.Data.([]*model.OutgoingWebhook)) != 0 { + t.Fatal("no webhooks should have returned") + } + } +} + +func TestWebhookStoreGetOutgoingByTeam(t *testing.T) { + Setup() + + o1 := &model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook) + + if r1 := <-store.Webhook().GetOutgoingByTeam(o1.TeamId); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.([]*model.OutgoingWebhook)[0].CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if result := <-store.Webhook().GetOutgoingByTeam("123"); result.Err != nil { + t.Fatal(result.Err) + } else { + if len(result.Data.([]*model.OutgoingWebhook)) != 0 { + t.Fatal("no webhooks should have returned") + } + } +} + +func TestWebhookStoreDeleteOutgoing(t *testing.T) { + Setup() + + o1 := &model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook) + + if r1 := <-store.Webhook().GetOutgoing(o1.Id); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(*model.OutgoingWebhook).CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if r2 := <-store.Webhook().DeleteOutgoing(o1.Id, model.GetMillis()); r2.Err != nil { + t.Fatal(r2.Err) + } + + if r3 := (<-store.Webhook().GetOutgoing(o1.Id)); r3.Err == nil { + t.Log(r3.Data) + t.Fatal("Missing id should have failed") + } +} + +func TestWebhookStoreUpdateOutgoing(t *testing.T) { + Setup() + + o1 := &model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook) + + o1.Token = model.NewId() + + if r2 := <-store.Webhook().UpdateOutgoing(o1); r2.Err != nil { + t.Fatal(r2.Err) + } +} diff --git a/store/store.go b/store/store.go index de335cc2b..70980a15c 100644 --- a/store/store.go +++ b/store/store.go @@ -150,6 +150,13 @@ type WebhookStore interface { GetIncoming(id string) StoreChannel GetIncomingByUser(userId string) StoreChannel DeleteIncoming(webhookId string, time int64) StoreChannel + SaveOutgoing(webhook *model.OutgoingWebhook) StoreChannel + GetOutgoing(id string) StoreChannel + GetOutgoingByCreator(userId string) StoreChannel + GetOutgoingByChannel(channelId string) StoreChannel + GetOutgoingByTeam(teamId string) StoreChannel + DeleteOutgoing(webhookId string, time int64) StoreChannel + UpdateOutgoing(hook *model.OutgoingWebhook) StoreChannel } type PreferenceStore interface { diff --git a/utils/config.go b/utils/config.go index 2c6f30bf0..e3349650b 100644 --- a/utils/config.go +++ b/utils/config.go @@ -188,6 +188,7 @@ func getClientProperties(c *model.Config) map[string]string { props["SegmentDeveloperKey"] = c.ServiceSettings.SegmentDeveloperKey props["GoogleDeveloperKey"] = c.ServiceSettings.GoogleDeveloperKey props["EnableIncomingWebhooks"] = strconv.FormatBool(c.ServiceSettings.EnableIncomingWebhooks) + props["EnableOutgoingWebhooks"] = strconv.FormatBool(c.ServiceSettings.EnableOutgoingWebhooks) props["EnablePostUsernameOverride"] = strconv.FormatBool(c.ServiceSettings.EnablePostUsernameOverride) props["EnablePostIconOverride"] = strconv.FormatBool(c.ServiceSettings.EnablePostIconOverride) diff --git a/web/react/components/admin_console/service_settings.jsx b/web/react/components/admin_console/service_settings.jsx index 4105ba6da..53c89a942 100644 --- a/web/react/components/admin_console/service_settings.jsx +++ b/web/react/components/admin_console/service_settings.jsx @@ -36,6 +36,7 @@ export default class ServiceSettings extends React.Component { config.ServiceSettings.SegmentDeveloperKey = ReactDOM.findDOMNode(this.refs.SegmentDeveloperKey).value.trim(); config.ServiceSettings.GoogleDeveloperKey = ReactDOM.findDOMNode(this.refs.GoogleDeveloperKey).value.trim(); config.ServiceSettings.EnableIncomingWebhooks = ReactDOM.findDOMNode(this.refs.EnableIncomingWebhooks).checked; + config.ServiceSettings.EnableOutgoingWebhooks = React.findDOMNode(this.refs.EnableOutgoingWebhooks).checked; config.ServiceSettings.EnablePostUsernameOverride = ReactDOM.findDOMNode(this.refs.EnablePostUsernameOverride).checked; config.ServiceSettings.EnablePostIconOverride = ReactDOM.findDOMNode(this.refs.EnablePostIconOverride).checked; config.ServiceSettings.EnableTesting = ReactDOM.findDOMNode(this.refs.EnableTesting).checked; @@ -207,7 +208,40 @@ export default class ServiceSettings extends React.Component { </div> </div> - <div className='form-group'> + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='EnableOutgoingWebhooks' + > + {'Enable Outgoing Webhooks: '} + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='EnableOutgoingWebhooks' + value='true' + ref='EnableOutgoingWebhooks' + defaultChecked={this.props.config.ServiceSettings.EnableOutgoingWebhooks} + onChange={this.handleChange} + /> + {'true'} + </label> + <label className='radio-inline'> + <input + type='radio' + name='EnableOutgoingWebhooks' + value='false' + defaultChecked={!this.props.config.ServiceSettings.EnableOutgoingWebhooks} + onChange={this.handleChange} + /> + {'false'} + </label> + <p className='help-text'>{'When true, outgoing webhooks will be allowed.'}</p> + </div> + </div> + + <div className='form-group'> <label className='control-label col-sm-4' htmlFor='EnablePostUsernameOverride' diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx new file mode 100644 index 000000000..e83ae3bd6 --- /dev/null +++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx @@ -0,0 +1,262 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../../utils/client.jsx'); +var Constants = require('../../utils/constants.jsx'); +var ChannelStore = require('../../stores/channel_store.jsx'); +var LoadingScreen = require('../loading_screen.jsx'); + +export default class ManageOutgoingHooks extends React.Component { + constructor() { + super(); + + this.getHooks = this.getHooks.bind(this); + this.addNewHook = this.addNewHook.bind(this); + this.updateChannelId = this.updateChannelId.bind(this); + this.updateTriggerWords = this.updateTriggerWords.bind(this); + this.updateCallbackURLs = this.updateCallbackURLs.bind(this); + + this.state = {hooks: [], channelId: '', triggerWords: '', callbackURLs: '', getHooksComplete: false}; + } + componentDidMount() { + this.getHooks(); + } + addNewHook(e) { + e.preventDefault(); + + if ((this.state.channelId === '' && this.state.triggerWords === '') || + this.state.callbackURLs === '') { + return; + } + + const hook = {}; + hook.channel_id = this.state.channelId; + if (this.state.triggerWords.length !== 0) { + hook.trigger_words = this.state.triggerWords.trim().split(','); + } + hook.callback_urls = this.state.callbackURLs.split('\n'); + + Client.addOutgoingHook( + hook, + (data) => { + let hooks = Object.assign([], this.state.hooks); + if (!hooks) { + hooks = []; + } + hooks.push(data); + this.setState({hooks, serverError: null, channelId: '', triggerWords: '', callbackURLs: ''}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + removeHook(id) { + const data = {}; + data.id = id; + + Client.deleteOutgoingHook( + data, + () => { + const hooks = this.state.hooks; + let index = -1; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].id === id) { + index = i; + break; + } + } + + if (index !== -1) { + hooks.splice(index, 1); + } + + this.setState({hooks}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + regenToken(id) { + const regenData = {}; + regenData.id = id; + + Client.regenOutgoingHookToken( + regenData, + (data) => { + const hooks = Object.assign([], this.state.hooks); + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].id === id) { + hooks[i] = data; + break; + } + } + + this.setState({hooks, serverError: null}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + getHooks() { + Client.listOutgoingHooks( + (data) => { + if (data) { + this.setState({hooks: data, getHooksComplete: true, serverError: null}); + } + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + updateChannelId(e) { + this.setState({channelId: e.target.value}); + } + updateTriggerWords(e) { + this.setState({triggerWords: e.target.value}); + } + updateCallbackURLs(e) { + this.setState({callbackURLs: e.target.value}); + } + render() { + let serverError; + if (this.state.serverError) { + serverError = <label className='has-error'>{this.state.serverError}</label>; + } + + const channels = ChannelStore.getAll(); + const options = [<option value=''>{'--- Select a channel ---'}</option>]; + channels.forEach((channel) => { + if (channel.type === Constants.OPEN_CHANNEL) { + options.push(<option value={channel.id}>{channel.name}</option>); + } + }); + + const hooks = []; + this.state.hooks.forEach((hook) => { + const c = ChannelStore.get(hook.channel_id); + let channelDiv; + if (c) { + channelDiv = ( + <div className='padding-top'> + <strong>{'Channel: '}</strong>{c.name} + </div> + ); + } + + let triggerDiv; + if (hook.trigger_words && hook.trigger_words.length !== 0) { + triggerDiv = ( + <div className='padding-top'> + <strong>{'Trigger Words: '}</strong>{hook.trigger_words.join(', ')} + </div> + ); + } + + hooks.push( + <div className='font--small'> + <div className='padding-top x2 divider-light'></div> + <div className='padding-top x2'> + <strong>{'URLs: '}</strong><span className='word-break--all'>{hook.callback_urls.join(', ')}</span> + </div> + {channelDiv} + {triggerDiv} + <div className='padding-top'> + <strong>{'Token: '}</strong>{hook.token} + </div> + <div className='padding-top'> + <a + className='text-danger' + href='#' + onClick={this.regenToken.bind(this, hook.id)} + > + {'Regen Token'} + </a> + <span>{' - '}</span> + <a + className='text-danger' + href='#' + onClick={this.removeHook.bind(this, hook.id)} + > + {'Remove'} + </a> + </div> + </div> + ); + }); + + let displayHooks; + if (!this.state.getHooksComplete) { + displayHooks = <LoadingScreen/>; + } else if (hooks.length > 0) { + displayHooks = hooks; + } else { + displayHooks = <label>{': None'}</label>; + } + + const existingHooks = ( + <div className='padding-top x2'> + <label className='control-label padding-top x2'>{'Existing outgoing webhooks'}</label> + {displayHooks} + </div> + ); + + const disableButton = (this.state.channelId === '' && this.state.triggerWords === '') || this.state.callbackURLs === ''; + + return ( + <div key='addOutgoingHook'> + <label className='control-label'>{'Add a new outgoing webhook'}</label> + <div className='padding-top'> + <strong>{'Channel:'}</strong> + <select + ref='channelName' + className='form-control' + value={this.state.channelId} + onChange={this.updateChannelId} + > + {options} + </select> + <span>{'Only public channels can be used'}</span> + <br/> + <br/> + <strong>{'Trigger Words:'}</strong> + <input + ref='triggerWords' + className='form-control' + value={this.state.triggerWords} + onChange={this.updateTriggerWords} + placeholder='Optional if channel selected' + /> + <span>{'Comma separated words to trigger on'}</span> + <br/> + <br/> + <strong>{'Callback URLs:'}</strong> + <textarea + ref='callbackURLs' + className='form-control no-resize' + value={this.state.callbackURLs} + resize={false} + rows={3} + onChange={this.updateCallbackURLs} + /> + <span>{'New line separated URLs that will receive the HTTP POST event'}</span> + {serverError} + <div className='padding-top'> + <a + className={'btn btn-sm btn-primary'} + href='#' + disabled={disableButton} + onClick={this.addNewHook} + > + {'Add'} + </a> + </div> + </div> + {existingHooks} + </div> + ); + } +} diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx index 3be062ad3..231580cc3 100644 --- a/web/react/components/user_settings/user_settings_integrations.jsx +++ b/web/react/components/user_settings/user_settings_integrations.jsx @@ -4,6 +4,7 @@ var SettingItemMin = require('../setting_item_min.jsx'); var SettingItemMax = require('../setting_item_max.jsx'); var ManageIncomingHooks = require('./manage_incoming_hooks.jsx'); +var ManageOutgoingHooks = require('./manage_outgoing_hooks.jsx'); export default class UserSettingsIntegrationsTab extends React.Component { constructor(props) { @@ -19,6 +20,8 @@ export default class UserSettingsIntegrationsTab extends React.Component { } handleClose() { this.updateSection(''); + $('.ps-container.modal-body').scrollTop(0); + $('.ps-container.modal-body').perfectScrollbar('update'); } componentDidMount() { $('#user_settings').on('hidden.bs.modal', this.handleClose); @@ -28,35 +31,67 @@ export default class UserSettingsIntegrationsTab extends React.Component { } render() { let incomingHooksSection; + let outgoingHooksSection; var inputs = []; - if (this.props.activeSection === 'incoming-hooks') { - inputs.push( - <ManageIncomingHooks /> - ); + if (global.window.config.EnableIncomingWebhooks === 'true') { + if (this.props.activeSection === 'incoming-hooks') { + inputs.push( + <ManageIncomingHooks /> + ); - incomingHooksSection = ( - <SettingItemMax - title='Incoming Webhooks' - width = 'full' - inputs={inputs} - updateSection={function clearSection(e) { - this.updateSection(''); - e.preventDefault(); - }.bind(this)} - /> - ); - } else { - incomingHooksSection = ( - <SettingItemMin - title='Incoming Webhooks' - width = 'full' - describe='Manage your incoming webhooks (Developer feature)' - updateSection={function updateNameSection() { - this.updateSection('incoming-hooks'); - }.bind(this)} - /> - ); + incomingHooksSection = ( + <SettingItemMax + title='Incoming Webhooks' + width = 'full' + inputs={inputs} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + incomingHooksSection = ( + <SettingItemMin + title='Incoming Webhooks' + width = 'full' + describe='Manage your incoming webhooks (Developer feature)' + updateSection={() => { + this.updateSection('incoming-hooks'); + }} + /> + ); + } + } + + if (global.window.config.EnableOutgoingWebhooks === 'true') { + if (this.props.activeSection === 'outgoing-hooks') { + inputs.push( + <ManageOutgoingHooks /> + ); + + outgoingHooksSection = ( + <SettingItemMax + title='Outgoing Webhooks' + inputs={inputs} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + outgoingHooksSection = ( + <SettingItemMin + title='Outgoing Webhooks' + describe='Manage your outgoing webhooks' + updateSection={() => { + this.updateSection('outgoing-hooks'); + }} + /> + ); + } } return ( @@ -82,6 +117,8 @@ export default class UserSettingsIntegrationsTab extends React.Component { <h3 className='tab-header'>{'Integration Settings'}</h3> <div className='divider-dark first'/> {incomingHooksSection} + <div className='divider-light'/> + {outgoingHooksSection} <div className='divider-dark'/> </div> </div> diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx index 692fb26ee..44cd423b5 100644 --- a/web/react/components/user_settings/user_settings_modal.jsx +++ b/web/react/components/user_settings/user_settings_modal.jsx @@ -38,7 +38,7 @@ export default class UserSettingsModal extends React.Component { if (global.window.config.EnableOAuthServiceProvider === 'true') { tabs.push({name: 'developer', uiName: 'Developer', icon: 'glyphicon glyphicon-th'}); } - if (global.window.config.EnableIncomingWebhooks === 'true') { + if (global.window.config.EnableIncomingWebhooks === 'true' || global.window.config.EnableOutgoingWebhooks === 'true') { tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'}); } tabs.push({name: 'display', uiName: 'Display', icon: 'glyphicon glyphicon-eye-open'}); diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index f6aee362c..f92633439 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -1182,3 +1182,61 @@ export function savePreferences(preferences, success, error) { } }); } + +export function addOutgoingHook(hook, success, error) { + $.ajax({ + url: '/api/v1/hooks/outgoing/create', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(hook), + success, + error: (xhr, status, err) => { + var e = handleError('addOutgoingHook', xhr, status, err); + error(e); + } + }); +} + +export function deleteOutgoingHook(data, success, error) { + $.ajax({ + url: '/api/v1/hooks/outgoing/delete', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(data), + success, + error: (xhr, status, err) => { + var e = handleError('deleteOutgoingHook', xhr, status, err); + error(e); + } + }); +} + +export function listOutgoingHooks(success, error) { + $.ajax({ + url: '/api/v1/hooks/outgoing/list', + dataType: 'json', + type: 'GET', + success, + error: (xhr, status, err) => { + var e = handleError('listOutgoingHooks', xhr, status, err); + error(e); + } + }); +} + +export function regenOutgoingHookToken(data, success, error) { + $.ajax({ + url: '/api/v1/hooks/outgoing/regen_token', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(data), + success, + error: (xhr, status, err) => { + var e = handleError('regenOutgoingHookToken', xhr, status, err); + error(e); + } + }); +} diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 5eb8378ca..1d856e067 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -126,6 +126,7 @@ module.exports = { MONTHS: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], MAX_DMS: 20, DM_CHANNEL: 'D', + OPEN_CHANNEL: 'O', MAX_POST_LEN: 4000, EMOJI_SIZE: 16, ONLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path class='online--icon' d='M6,5.487c1.371,0,2.482-1.116,2.482-2.493c0-1.378-1.111-2.495-2.482-2.495S3.518,1.616,3.518,2.994C3.518,4.371,4.629,5.487,6,5.487z M10.452,8.545c-0.101-0.829-0.36-1.968-0.726-2.541C9.475,5.606,8.5,5.5,8.5,5.5S8.43,7.521,6,7.521C3.507,7.521,3.5,5.5,3.5,5.5S2.527,5.606,2.273,6.004C1.908,6.577,1.648,7.716,1.547,8.545C1.521,8.688,1.49,9.082,1.498,9.142c0.161,1.295,2.238,2.322,4.375,2.358C5.916,11.501,5.958,11.501,6,11.501c0.043,0,0.084,0,0.127-0.001c2.076-0.026,4.214-1.063,4.375-2.358C10.509,9.082,10.471,8.696,10.452,8.545z'/></g></g></svg>", diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss index 3146c16d5..c4591d7b6 100644 --- a/web/sass-files/sass/partials/_settings.scss +++ b/web/sass-files/sass/partials/_settings.scss @@ -291,3 +291,7 @@ .color-btn { margin:4px; } + +.no-resize { + resize:none; +} diff --git a/web/web.go b/web/web.go index f10c4f2a1..a24f1589d 100644 --- a/web/web.go +++ b/web/web.go @@ -15,7 +15,6 @@ import ( "gopkg.in/fsnotify.v1" "html/template" "net/http" - "regexp" "strconv" "strings" ) @@ -931,9 +930,6 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { channelName := props["channel"] - overrideUsername := props["username"] - overrideIconUrl := props["icon_url"] - var hook *model.IncomingWebhook if result := <-hchan; result.Err != nil { c.Err = model.NewAppError("incomingWebhook", "Invalid webhook", "err="+result.Err.Message) @@ -962,12 +958,8 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { cchan = api.Srv.Store.Channel().Get(hook.ChannelId) } - // parse links into Markdown format - linkWithTextRegex := regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`) - text = linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})") - - linkRegex := regexp.MustCompile(`<\s*(\S*)\s*>`) - text = linkRegex.ReplaceAllString(text, "${1}") + overrideUsername := props["username"] + overrideIconUrl := props["icon_url"] if result := <-cchan; result.Err != nil { c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message) @@ -978,35 +970,16 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { pchan := api.Srv.Store.Channel().CheckPermissionsTo(hook.TeamId, channel.Id, hook.UserId) - post := &model.Post{UserId: hook.UserId, ChannelId: channel.Id, Message: text} - post.AddProp("from_webhook", "true") - - if utils.Cfg.ServiceSettings.EnablePostUsernameOverride { - if len(overrideUsername) != 0 { - post.AddProp("override_username", overrideUsername) - } else { - post.AddProp("override_username", model.DEFAULT_WEBHOOK_USERNAME) - } - } - - if utils.Cfg.ServiceSettings.EnablePostIconOverride { - if len(overrideIconUrl) != 0 { - post.AddProp("override_icon_url", overrideIconUrl) - } else { - post.AddProp("override_icon_url", model.DEFAULT_WEBHOOK_ICON) - } - } + // create a mock session + c.Session = model.Session{UserId: hook.UserId, TeamId: hook.TeamId, IsOAuth: false} if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN { c.Err = model.NewAppError("incomingWebhook", "Inappropriate channel permissions", "") return } - // create a mock session - c.Session = model.Session{UserId: hook.UserId, TeamId: hook.TeamId, IsOAuth: false} - - if _, err := api.CreatePost(c, post, false); err != nil { - c.Err = model.NewAppError("incomingWebhook", "Error creating post", "err="+err.Message) + if _, err := api.CreateWebhookPost(c, channel.Id, text, overrideUsername, overrideIconUrl); err != nil { + c.Err = err return } |