diff options
Diffstat (limited to 'model')
-rw-r--r-- | model/client.go | 128 | ||||
-rw-r--r-- | model/command.go | 5 | ||||
-rw-r--r-- | model/config.go | 10 | ||||
-rw-r--r-- | model/file.go | 4 | ||||
-rw-r--r-- | model/incoming_webhook.go | 134 | ||||
-rw-r--r-- | model/incoming_webhook_test.go | 131 | ||||
-rw-r--r-- | model/user.go | 1 | ||||
-rw-r--r-- | model/version.go | 1 |
8 files changed, 390 insertions, 24 deletions
diff --git a/model/client.go b/model/client.go index 152aaa706..eabedefa5 100644 --- a/model/client.go +++ b/model/client.go @@ -28,6 +28,8 @@ const ( HEADER_AUTH = "Authorization" HEADER_REQUESTED_WITH = "X-Requested-With" HEADER_REQUESTED_WITH_XML = "XMLHttpRequest" + STATUS = "status" + STATUS_OK = "OK" API_URL_SUFFIX_V1 = "/api/v1" API_URL_SUFFIX_V3 = "/api/v3" @@ -41,18 +43,21 @@ type Result struct { } type Client struct { - Url string // The location of the server like "http://localhost:8065" - ApiUrl string // The api location of the server like "http://localhost:8065/api/v3" - HttpClient *http.Client // The http client - AuthToken string - AuthType string - TeamId string + Url string // The location of the server like "http://localhost:8065" + ApiUrl string // The api location of the server like "http://localhost:8065/api/v3" + HttpClient *http.Client // The http client + AuthToken string + AuthType string + TeamId string + RequestId string + Etag string + ServerVersion string } // NewClient constructs a new client with convienence methods for talking to // the server. func NewClient(url string) *Client { - return &Client{url, url + API_URL_SUFFIX, &http.Client{}, "", "", ""} + return &Client{url, url + API_URL_SUFFIX, &http.Client{}, "", "", "", "", "", ""} } func (c *Client) SetOAuthToken(token string) { @@ -94,6 +99,10 @@ func (c *Client) GetChannelNameRoute(channelName string) string { return fmt.Sprintf("/teams/%v/channels/name/%v", c.GetTeamId(), channelName) } +func (c *Client) GetGeneralRoute() string { + return "/general" +} + func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppError) { rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data)) rq.Header.Set("Content-Type", contentType) @@ -155,6 +164,7 @@ func getCookie(name string, resp *http.Response) *http.Cookie { return nil } +// Must is a convenience function used for testing. func (c *Client) Must(result *Result, err *AppError) *Result { if err != nil { l4g.Close() @@ -165,6 +175,76 @@ func (c *Client) Must(result *Result, err *AppError) *Result { return result } +// CheckStatusOK is a convenience function for checking the return of Web Service +// call that return the a map of status=OK. +func (c *Client) CheckStatusOK(r *http.Response) bool { + m := MapFromJson(r.Body) + if m != nil && m[STATUS] == STATUS_OK { + return true + } + + return false +} + +func (c *Client) fillInExtraProperties(r *http.Response) { + c.RequestId = r.Header.Get(HEADER_REQUEST_ID) + c.Etag = r.Header.Get(HEADER_ETAG_SERVER) + c.ServerVersion = r.Header.Get(HEADER_VERSION_ID) +} + +func (c *Client) clearExtraProperties() { + c.RequestId = "" + c.Etag = "" + c.ServerVersion = "" +} + +// General Routes Section + +// GetClientProperties returns properties needed by the client to show/hide +// certian features. It returns a map of strings. +func (c *Client) GetClientProperties() (map[string]string, *AppError) { + c.clearExtraProperties() + if r, err := c.DoApiGet(c.GetGeneralRoute()+"/client_props", "", ""); err != nil { + return nil, err + } else { + c.fillInExtraProperties(r) + return MapFromJson(r.Body), nil + } +} + +// LogClient is a convenience Web Service call so clients can log messages into +// the server-side logs. For example we typically log javascript error messages +// into the server-side. It returns true if the logging was successful. +func (c *Client) LogClient(message string) (bool, *AppError) { + c.clearExtraProperties() + m := make(map[string]string) + m["level"] = "ERROR" + m["message"] = message + + if r, err := c.DoApiPost(c.GetGeneralRoute()+"/log_client", MapToJson(m)); err != nil { + return false, err + } else { + c.fillInExtraProperties(r) + return c.CheckStatusOK(r), nil + } +} + +// GetPing returns a map of strings with server time, server version, and node Id. +// Systems that want to check on health status of the server should check the +// url /api/v3/ping for a 200 status response. +func (c *Client) GetPing() (map[string]string, *AppError) { + c.clearExtraProperties() + if r, err := c.DoApiGet(c.GetGeneralRoute()+"/ping", "", ""); err != nil { + return nil, err + } else { + c.fillInExtraProperties(r) + return MapFromJson(r.Body), nil + + } +} + +// Team Routes Section + func (c *Client) SignupTeam(email string, displayName string) (*Result, *AppError) { m := make(map[string]string) m["email"] = email @@ -596,21 +676,26 @@ func (c *Client) GetAllAudits() (*Result, *AppError) { } } -func (c *Client) GetClientProperties() (*Result, *AppError) { - if r, err := c.DoApiGet("/admin/client_props", "", ""); err != nil { +func (c *Client) GetConfig() (*Result, *AppError) { + if r, err := c.DoApiGet("/admin/config", "", ""); err != nil { return nil, err } else { return &Result{r.Header.Get(HEADER_REQUEST_ID), - r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil + r.Header.Get(HEADER_ETAG_SERVER), ConfigFromJson(r.Body)}, nil } } -func (c *Client) GetConfig() (*Result, *AppError) { - if r, err := c.DoApiGet("/admin/config", "", ""); err != nil { - return nil, err +// ReloadConfig will reload the config.json file from disk. Properties +// requiring a server restart will still need a server restart. You must +// have the system admin role to call this method. It will return status=OK +// if it's successfully reloaded the config file, otherwise check the returned error. +func (c *Client) ReloadConfig() (bool, *AppError) { + c.clearExtraProperties() + if r, err := c.DoApiGet("/admin/reload_config", "", ""); err != nil { + return false, err } else { - return &Result{r.Header.Get(HEADER_REQUEST_ID), - r.Header.Get(HEADER_ETAG_SERVER), ConfigFromJson(r.Body)}, nil + c.fillInExtraProperties(r) + return c.CheckStatusOK(r), nil } } @@ -623,6 +708,19 @@ func (c *Client) SaveConfig(config *Config) (*Result, *AppError) { } } +// RecycleDatabaseConnection will attempt to recycle the database connections. +// You must have the system admin role to call this method. It will return status=OK +// if it's successfully recycled the connections, otherwise check the returned error. +func (c *Client) RecycleDatabaseConnection() (bool, *AppError) { + c.clearExtraProperties() + if r, err := c.DoApiGet("/admin/recycle_db_conn", "", ""); err != nil { + return false, err + } else { + c.fillInExtraProperties(r) + return c.CheckStatusOK(r), nil + } +} + func (c *Client) TestEmail(config *Config) (*Result, *AppError) { if r, err := c.DoApiPost("/admin/test_email", config.ToJson()); err != nil { return nil, err diff --git a/model/command.go b/model/command.go index 4d5f7ace9..decb647b7 100644 --- a/model/command.go +++ b/model/command.go @@ -6,11 +6,14 @@ package model import ( "encoding/json" "io" + "strings" ) const ( COMMAND_METHOD_POST = "P" COMMAND_METHOD_GET = "G" + MIN_TRIGGER_LENGTH = 1 + MAX_TRIGGER_LENGTH = 128 ) type Command struct { @@ -99,7 +102,7 @@ func (o *Command) IsValid() *AppError { return NewLocAppError("Command.IsValid", "model.command.is_valid.team_id.app_error", nil, "") } - if len(o.Trigger) == 0 || len(o.Trigger) > 128 { + if len(o.Trigger) < MIN_TRIGGER_LENGTH || len(o.Trigger) > MAX_TRIGGER_LENGTH || strings.Index(o.Trigger, "/") == 0 || strings.Contains(o.Trigger, " ") { return NewLocAppError("Command.IsValid", "model.command.is_valid.trigger.app_error", nil, "") } diff --git a/model/config.go b/model/config.go index 9a7e3b7c5..674a352f0 100644 --- a/model/config.go +++ b/model/config.go @@ -92,6 +92,7 @@ type LogSettings struct { } type FileSettings struct { + MaxFileSize *int64 DriverName string Directory string EnablePublicLink bool @@ -256,6 +257,11 @@ func (o *Config) SetDefaults() { o.SqlSettings.AtRestEncryptKey = NewRandomString(32) } + if o.FileSettings.MaxFileSize == nil { + o.FileSettings.MaxFileSize = new(int64) + *o.FileSettings.MaxFileSize = 52428800 // 50 MB + } + if len(o.FileSettings.PublicLinkSalt) == 0 { o.FileSettings.PublicLinkSalt = NewRandomString(32) } @@ -547,6 +553,10 @@ func (o *Config) IsValid() *AppError { return NewLocAppError("Config.IsValid", "model.config.is_valid.sql_max_conn.app_error", nil, "") } + if *o.FileSettings.MaxFileSize <= 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.max_file_size.app_error", nil, "") + } + if !(o.FileSettings.DriverName == IMAGE_DRIVER_LOCAL || o.FileSettings.DriverName == IMAGE_DRIVER_S3) { return NewLocAppError("Config.IsValid", "model.config.is_valid.file_driver.app_error", nil, "") } diff --git a/model/file.go b/model/file.go index b7806b3b4..fa98a3b3a 100644 --- a/model/file.go +++ b/model/file.go @@ -8,10 +8,6 @@ import ( "io" ) -const ( - MAX_FILE_SIZE = 50000000 // 50 MB -) - var ( IMAGE_EXTENSIONS = [5]string{".jpg", ".jpeg", ".gif", ".bmp", ".png"} IMAGE_MIME_TYPES = map[string]string{".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".bmp": "image/bmp", ".png": "image/png", ".tiff": "image/tiff"} diff --git a/model/incoming_webhook.go b/model/incoming_webhook.go index 0763b443e..fabf799cd 100644 --- a/model/incoming_webhook.go +++ b/model/incoming_webhook.go @@ -4,8 +4,11 @@ package model import ( + "bytes" "encoding/json" "io" + "regexp" + "strings" ) const ( @@ -125,13 +128,136 @@ func (o *IncomingWebhook) PreUpdate() { o.UpdateAt = GetMillis() } -func IncomingWebhookRequestFromJson(data io.Reader) *IncomingWebhookRequest { - decoder := json.NewDecoder(data) +// escapeControlCharsFromPayload escapes control chars (\n, \t) from a byte slice. +// Context: +// JSON strings are not supposed to contain control characters such as \n, \t, +// ... but some incoming webhooks might still send invalid JSON and we want to +// try to handle that. An example invalid JSON string from an incoming webhook +// might look like this (strings for both "text" and "fallback" attributes are +// invalid JSON strings because they contain unescaped newlines and tabs): +// `{ +// "text": "this is a test +// that contains a newline and tabs", +// "attachments": [ +// { +// "fallback": "Required plain-text summary of the attachment +// that contains a newline and tabs", +// "color": "#36a64f", +// ... +// "text": "Optional text that appears within the attachment +// that contains a newline and tabs", +// ... +// "thumb_url": "http://example.com/path/to/thumb.png" +// } +// ] +// }` +// This function will search for `"key": "value"` pairs, and escape \n, \t +// from the value. +func escapeControlCharsFromPayload(by []byte) []byte { + // we'll search for `"text": "..."` or `"fallback": "..."`, ... + keys := "text|fallback|pretext|author_name|title|value" + + // the regexp reads like this: + // (?s): this flag let . match \n (default is false) + // "(keys)": we search for the keys defined above + // \s*:\s*: followed by 0..n spaces/tabs, a colon then 0..n spaces/tabs + // ": a double-quote + // (\\"|[^"])*: any number of times the `\"` string or any char but a double-quote + // ": a double-quote + r := `(?s)"(` + keys + `)"\s*:\s*"(\\"|[^"])*"` + re := regexp.MustCompile(r) + + // the function that will escape \n and \t on the regexp matches + repl := func(b []byte) []byte { + if bytes.Contains(b, []byte("\n")) { + b = bytes.Replace(b, []byte("\n"), []byte("\\n"), -1) + } + if bytes.Contains(b, []byte("\t")) { + b = bytes.Replace(b, []byte("\t"), []byte("\\t"), -1) + } + + return b + } + + return re.ReplaceAllFunc(by, repl) +} + +func decodeIncomingWebhookRequest(by []byte) (*IncomingWebhookRequest, error) { + decoder := json.NewDecoder(bytes.NewReader(by)) var o IncomingWebhookRequest err := decoder.Decode(&o) if err == nil { - return &o + return &o, nil } else { - return nil + return nil, err + } +} + +// To mention @channel via a webhook in Slack, the message should contain +// <!channel>, as explained at the bottom of this article: +// https://get.slack.help/hc/en-us/articles/202009646-Making-announcements +func expandAnnouncement(text string) string { + c1 := "<!channel>" + c2 := "@channel" + if strings.Contains(text, c1) { + return strings.Replace(text, c1, c2, -1) + } + return text +} + +// Expand announcements in incoming webhooks from Slack. Those announcements +// can be found in the text attribute, or in the pretext, text, title and value +// attributes of the attachment structure. The Slack attachment structure is +// documented here: https://api.slack.com/docs/attachments +func expandAnnouncements(i *IncomingWebhookRequest) { + i.Text = expandAnnouncement(i.Text) + + if i.Attachments != nil { + attachments := i.Attachments.([]interface{}) + for _, attachment := range attachments { + a := attachment.(map[string]interface{}) + + if a["pretext"] != nil { + a["pretext"] = expandAnnouncement(a["pretext"].(string)) + } + + if a["text"] != nil { + a["text"] = expandAnnouncement(a["text"].(string)) + } + + if a["title"] != nil { + a["title"] = expandAnnouncement(a["title"].(string)) + } + + if a["fields"] != nil { + fields := a["fields"].([]interface{}) + for _, field := range fields { + f := field.(map[string]interface{}) + if f["value"] != nil { + f["value"] = expandAnnouncement(f["value"].(string)) + } + } + } + } } } + +func IncomingWebhookRequestFromJson(data io.Reader) *IncomingWebhookRequest { + buf := new(bytes.Buffer) + buf.ReadFrom(data) + by := buf.Bytes() + + // Try to decode the JSON data. Only if it fails, try to escape control + // characters from the strings contained in the JSON data. + o, err := decodeIncomingWebhookRequest(by) + if err != nil { + o, err = decodeIncomingWebhookRequest(escapeControlCharsFromPayload(by)) + if err != nil { + return nil + } + } + + expandAnnouncements(o) + + return o +} diff --git a/model/incoming_webhook_test.go b/model/incoming_webhook_test.go index 3f1754244..8246b6c0a 100644 --- a/model/incoming_webhook_test.go +++ b/model/incoming_webhook_test.go @@ -100,3 +100,134 @@ func TestIncomingWebhookPreUpdate(t *testing.T) { o := IncomingWebhook{} o.PreUpdate() } + +func TestIncomingWebhookRequestFromJson_Announcements(t *testing.T) { + text := "This message will send a notification to all team members in the channel where you post the message, because it contains: <!channel>" + expected := "This message will send a notification to all team members in the channel where you post the message, because it contains: @channel" + + // simple payload + payload := `{"text": "` + text + `"}` + data := strings.NewReader(payload) + iwr := IncomingWebhookRequestFromJson(data) + + if iwr == nil { + t.Fatal("IncomingWebhookRequest should not be nil") + } + if iwr.Text != expected { + t.Fatalf("Sample text should be: %s, got: %s", expected, iwr.Text) + } + + // payload with attachment (pretext, title, text, value) + payload = `{ + "attachments": [ + { + "pretext": "` + text + `", + "title": "` + text + `", + "text": "` + text + `", + "fields": [ + { + "title": "A title", + "value": "` + text + `", + "short": false + } + ] + } + ] + }` + + data = strings.NewReader(payload) + iwr = IncomingWebhookRequestFromJson(data) + + if iwr == nil { + t.Fatal("IncomingWebhookRequest should not be nil") + } + + attachments := iwr.Attachments.([]interface{}) + attachment := attachments[0].(map[string]interface{}) + if attachment["pretext"] != expected { + t.Fatalf("Sample attachment pretext should be: %s, got: %s", expected, attachment["pretext"]) + } + if attachment["text"] != expected { + t.Fatalf("Sample attachment text should be: %s, got: %s", expected, attachment["text"]) + } + if attachment["title"] != expected { + t.Fatalf("Sample attachment title should be: %s, got: %s", expected, attachment["title"]) + } + fields := attachment["fields"].([]interface{}) + field := fields[0].(map[string]interface{}) + if field["value"] != expected { + t.Fatalf("Sample attachment field value should be: %s, got: %s", expected, field["value"]) + } +} + +func TestIncomingWebhookRequestFromJson(t *testing.T) { + texts := []string{ + `this is a test`, + `this is a test + that contains a newline and tabs`, + `this is a test \"foo + that contains a newline and tabs`, + `this is a test \"foo\" + that contains a newline and tabs`, + `this is a test \"foo\" + \" that contains a newline and tabs`, + `this is a test \"foo\" + + \" that contains a newline and tabs + `, + } + + for i, text := range texts { + // build a sample payload with the text + payload := `{ + "text": "` + text + `", + "attachments": [ + { + "fallback": "` + text + `", + + "color": "#36a64f", + + "pretext": "` + text + `", + + "author_name": "` + text + `", + "author_link": "http://flickr.com/bobby/", + "author_icon": "http://flickr.com/icons/bobby.jpg", + + "title": "` + text + `", + "title_link": "https://api.slack.com/", + + "text": "` + text + `", + + "fields": [ + { + "title": "` + text + `", + "value": "` + text + `", + "short": false + } + ], + + "image_url": "http://my-website.com/path/to/image.jpg", + "thumb_url": "http://example.com/path/to/thumb.png" + } + ] + }` + + // try to create an IncomingWebhookRequest from the payload + data := strings.NewReader(payload) + iwr := IncomingWebhookRequestFromJson(data) + + // After it has been decoded, the JSON string won't contain the escape char anymore + expected := strings.Replace(text, `\"`, `"`, -1) + if iwr == nil { + t.Fatal("IncomingWebhookRequest should not be nil") + } + if iwr.Text != expected { + t.Fatalf("Sample %d text should be: %s, got: %s", i, expected, iwr.Text) + } + attachments := iwr.Attachments.([]interface{}) + attachment := attachments[0].(map[string]interface{}) + if attachment["text"] != expected { + t.Fatalf("Sample %d attachment text should be: %s, got: %s", i, expected, attachment["text"]) + } + } +} diff --git a/model/user.go b/model/user.go index 15c281401..7dee67381 100644 --- a/model/user.go +++ b/model/user.go @@ -191,6 +191,7 @@ func (u *User) PreUpdate() { func (u *User) SetDefaultNotifications() { u.NotifyProps = make(map[string]string) u.NotifyProps["email"] = "true" + u.NotifyProps["push"] = USER_NOTIFY_MENTION u.NotifyProps["desktop"] = USER_NOTIFY_ALL u.NotifyProps["desktop_sound"] = "true" u.NotifyProps["mention_keys"] = u.Username + ",@" + u.Username diff --git a/model/version.go b/model/version.go index 4a47f06ef..dde9eccd7 100644 --- a/model/version.go +++ b/model/version.go @@ -33,6 +33,7 @@ var CurrentVersion string = versions[0] var BuildNumber string var BuildDate string var BuildHash string +var BuildHashEnterprise string var BuildEnterpriseReady string var versionsWithoutHotFixes []string |