// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model import ( "crypto" "crypto/aes" "crypto/cipher" "crypto/ecdsa" "crypto/rand" "encoding/asn1" "encoding/base64" "encoding/json" "fmt" "io" "math/big" "net/http" "strconv" "strings" ) const ( POST_ACTION_TYPE_BUTTON = "button" POST_ACTION_TYPE_SELECT = "select" INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS = 3000 ) var PostActionRetainPropKeys = []string{"from_webhook", "override_username", "override_icon_url"} type DoPostActionRequest struct { SelectedOption string `json:"selected_option,omitempty"` Cookie string `json:"cookie,omitempty"` } type PostAction struct { // A unique Action ID. If not set, generated automatically. Id string `json:"id,omitempty"` // The type of the interactive element. Currently supported are // "select" and "button". Type string `json:"type,omitempty"` // The text on the button, or in the select placeholder. Name string `json:"name,omitempty"` // If the action is disabled. Disabled bool `json:"disabled,omitempty"` // Style defines a text and border style. // Supported values are "default", "primary", "success", "good", "warning", "danger" // and any hex color. Style string `json:"style,omitempty"` // DataSource indicates the data source for the select action. If left // empty, the select is populated from Options. Other supported values // are "users" and "channels". DataSource string `json:"data_source,omitempty"` // Options contains the values listed in a select dropdown on the post. Options []*PostActionOptions `json:"options,omitempty"` // DefaultOption contains the option, if any, that will appear as the // default selection in a select box. It has no effect when used with // other types of actions. DefaultOption string `json:"default_option,omitempty"` // Defines the interaction with the backend upon a user action. // Integration contains Context, which is private plugin data; // Integrations are stripped from Posts when they are sent to the // client, or are encrypted in a Cookie. Integration *PostActionIntegration `json:"integration,omitempty"` Cookie string `json:"cookie,omitempty" db:"-"` } func (p *PostAction) Equals(input *PostAction) bool { if p.Id != input.Id { return false } if p.Type != input.Type { return false } if p.Name != input.Name { return false } if p.DataSource != input.DataSource { return false } if p.DefaultOption != input.DefaultOption { return false } if p.Cookie != input.Cookie { return false } // Compare PostActionOptions if len(p.Options) != len(input.Options) { return false } for k := range p.Options { if p.Options[k].Text != input.Options[k].Text { return false } if p.Options[k].Value != input.Options[k].Value { return false } } // Compare PostActionIntegration if p.Integration.URL != input.Integration.URL { return false } if len(p.Integration.Context) != len(input.Integration.Context) { return false } for key, value := range p.Integration.Context { inputValue, ok := input.Integration.Context[key] if !ok { return false } if value != inputValue { return false } } return true } // PostActionCookie is set by the server, serialized and encrypted into // PostAction.Cookie. The clients should hold on to it, and include it with // subsequent DoPostAction requests. This allows the server to access the // action metadata even when it's not available in the database, for ephemeral // posts. type PostActionCookie struct { Type string `json:"type,omitempty"` PostId string `json:"post_id,omitempty"` RootPostId string `json:"root_post_id,omitempty"` ChannelId string `json:"channel_id,omitempty"` DataSource string `json:"data_source,omitempty"` Integration *PostActionIntegration `json:"integration,omitempty"` RetainProps map[string]interface{} `json:"retain_props,omitempty"` RemoveProps []string `json:"remove_props,omitempty"` } type PostActionOptions struct { Text string `json:"text"` Value string `json:"value"` } type PostActionIntegration struct { URL string `json:"url,omitempty"` Context map[string]interface{} `json:"context,omitempty"` } type PostActionIntegrationRequest struct { UserId string `json:"user_id"` UserName string `json:"user_name"` ChannelId string `json:"channel_id"` ChannelName string `json:"channel_name"` TeamId string `json:"team_id"` TeamName string `json:"team_domain"` PostId string `json:"post_id"` TriggerId string `json:"trigger_id"` Type string `json:"type"` DataSource string `json:"data_source"` Context map[string]interface{} `json:"context,omitempty"` } type PostActionIntegrationResponse struct { Update *Post `json:"update"` EphemeralText string `json:"ephemeral_text"` SkipSlackParsing bool `json:"skip_slack_parsing"` // Set to `true` to skip the Slack-compatibility handling of Text. } type PostActionAPIResponse struct { Status string `json:"status"` // needed to maintain backwards compatibility TriggerId string `json:"trigger_id"` } type Dialog struct { CallbackId string `json:"callback_id"` Title string `json:"title"` IntroductionText string `json:"introduction_text"` IconURL string `json:"icon_url"` Elements []DialogElement `json:"elements"` SubmitLabel string `json:"submit_label"` NotifyOnCancel bool `json:"notify_on_cancel"` State string `json:"state"` } type DialogElement struct { DisplayName string `json:"display_name"` Name string `json:"name"` Type string `json:"type"` SubType string `json:"subtype"` Default string `json:"default"` Placeholder string `json:"placeholder"` HelpText string `json:"help_text"` Optional bool `json:"optional"` MinLength int `json:"min_length"` MaxLength int `json:"max_length"` DataSource string `json:"data_source"` Options []*PostActionOptions `json:"options"` } type OpenDialogRequest struct { TriggerId string `json:"trigger_id"` URL string `json:"url"` Dialog Dialog `json:"dialog"` } type SubmitDialogRequest struct { Type string `json:"type"` URL string `json:"url,omitempty"` CallbackId string `json:"callback_id"` State string `json:"state"` UserId string `json:"user_id"` ChannelId string `json:"channel_id"` TeamId string `json:"team_id"` Submission map[string]interface{} `json:"submission"` Cancelled bool `json:"cancelled"` } type SubmitDialogResponse struct { Error string `json:"error,omitempty"` Errors map[string]string `json:"errors,omitempty"` } func GenerateTriggerId(userId string, s crypto.Signer) (string, string, *AppError) { clientTriggerId := NewId() triggerData := strings.Join([]string{clientTriggerId, userId, strconv.FormatInt(GetMillis(), 10)}, ":") + ":" h := crypto.SHA256 sum := h.New() sum.Write([]byte(triggerData)) signature, err := s.Sign(rand.Reader, sum.Sum(nil), h) if err != nil { return "", "", NewAppError("GenerateTriggerId", "interactive_message.generate_trigger_id.signing_failed", nil, err.Error(), http.StatusInternalServerError) } base64Sig := base64.StdEncoding.EncodeToString(signature) triggerId := base64.StdEncoding.EncodeToString([]byte(triggerData + base64Sig)) return clientTriggerId, triggerId, nil } func (r *PostActionIntegrationRequest) GenerateTriggerId(s crypto.Signer) (string, string, *AppError) { clientTriggerId, triggerId, err := GenerateTriggerId(r.UserId, s) if err != nil { return "", "", err } r.TriggerId = triggerId return clientTriggerId, triggerId, nil } func DecodeAndVerifyTriggerId(triggerId string, s *ecdsa.PrivateKey) (string, string, *AppError) { triggerIdBytes, err := base64.StdEncoding.DecodeString(triggerId) if err != nil { return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed", nil, err.Error(), http.StatusBadRequest) } split := strings.Split(string(triggerIdBytes), ":") if len(split) != 4 { return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.missing_data", nil, "", http.StatusBadRequest) } clientTriggerId := split[0] userId := split[1] timestampStr := split[2] timestamp, _ := strconv.ParseInt(timestampStr, 10, 64) now := GetMillis() if now-timestamp > INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS { return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.expired", map[string]interface{}{"Seconds": INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS / 1000}, "", http.StatusBadRequest) } signature, err := base64.StdEncoding.DecodeString(split[3]) if err != nil { return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed_signature", nil, err.Error(), http.StatusBadRequest) } var esig struct { R, S *big.Int } if _, err := asn1.Unmarshal(signature, &esig); err != nil { return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.signature_decode_failed", nil, err.Error(), http.StatusBadRequest) } triggerData := strings.Join([]string{clientTriggerId, userId, timestampStr}, ":") + ":" h := crypto.SHA256 sum := h.New() sum.Write([]byte(triggerData)) if !ecdsa.Verify(&s.PublicKey, sum.Sum(nil), esig.R, esig.S) { return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.verify_signature_failed", nil, "", http.StatusBadRequest) } return clientTriggerId, userId, nil } func (r *OpenDialogRequest) DecodeAndVerifyTriggerId(s *ecdsa.PrivateKey) (string, string, *AppError) { return DecodeAndVerifyTriggerId(r.TriggerId, s) } func (r *PostActionIntegrationRequest) ToJson() []byte { b, _ := json.Marshal(r) return b } func PostActionIntegrationRequestFromJson(data io.Reader) *PostActionIntegrationRequest { var o *PostActionIntegrationRequest err := json.NewDecoder(data).Decode(&o) if err != nil { return nil } return o } func (r *PostActionIntegrationResponse) ToJson() []byte { b, _ := json.Marshal(r) return b } func PostActionIntegrationResponseFromJson(data io.Reader) *PostActionIntegrationResponse { var o *PostActionIntegrationResponse err := json.NewDecoder(data).Decode(&o) if err != nil { return nil } return o } func SubmitDialogRequestFromJson(data io.Reader) *SubmitDialogRequest { var o *SubmitDialogRequest err := json.NewDecoder(data).Decode(&o) if err != nil { return nil } return o } func (r *SubmitDialogRequest) ToJson() []byte { b, _ := json.Marshal(r) return b } func SubmitDialogResponseFromJson(data io.Reader) *SubmitDialogResponse { var o *SubmitDialogResponse err := json.NewDecoder(data).Decode(&o) if err != nil { return nil } return o } func (r *SubmitDialogResponse) ToJson() []byte { b, _ := json.Marshal(r) return b } func (o *Post) StripActionIntegrations() { attachments := o.Attachments() if o.GetProp("attachments") != nil { o.AddProp("attachments", attachments) } for _, attachment := range attachments { for _, action := range attachment.Actions { action.Integration = nil } } } func (o *Post) GetAction(id string) *PostAction { for _, attachment := range o.Attachments() { for _, action := range attachment.Actions { if action.Id == id { return action } } } return nil } func (o *Post) GenerateActionIds() { if o.GetProp("attachments") != nil { o.AddProp("attachments", o.Attachments()) } if attachments, ok := o.GetProp("attachments").([]*SlackAttachment); ok { for _, attachment := range attachments { for _, action := range attachment.Actions { if action.Id == "" { action.Id = NewId() } } } } } func AddPostActionCookies(o *Post, secret []byte) *Post { p := o.Clone() // retainedProps carry over their value from the old post, including no value retainProps := map[string]interface{}{} removeProps := []string{} for _, key := range PostActionRetainPropKeys { value, ok := p.GetProps()[key] if ok { retainProps[key] = value } else { removeProps = append(removeProps, key) } } attachments := p.Attachments() for _, attachment := range attachments { for _, action := range attachment.Actions { c := &PostActionCookie{ Type: action.Type, ChannelId: p.ChannelId, DataSource: action.DataSource, Integration: action.Integration, RetainProps: retainProps, RemoveProps: removeProps, } c.PostId = p.Id if p.RootId == "" { c.RootPostId = p.Id } else { c.RootPostId = p.RootId } b, _ := json.Marshal(c) action.Cookie, _ = encryptPostActionCookie(string(b), secret) } } return p } func encryptPostActionCookie(plain string, secret []byte) (string, error) { if len(secret) == 0 { return plain, nil } block, err := aes.NewCipher(secret) if err != nil { return "", err } aesgcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, aesgcm.NonceSize()) _, err = io.ReadFull(rand.Reader, nonce) if err != nil { return "", err } sealed := aesgcm.Seal(nil, nonce, []byte(plain), nil) combined := append(nonce, sealed...) encoded := make([]byte, base64.StdEncoding.EncodedLen(len(combined))) base64.StdEncoding.Encode(encoded, combined) return string(encoded), nil } func DecryptPostActionCookie(encoded string, secret []byte) (string, error) { if len(secret) == 0 { return encoded, nil } block, err := aes.NewCipher(secret) if err != nil { return "", err } aesgcm, err := cipher.NewGCM(block) if err != nil { return "", err } decoded := make([]byte, base64.StdEncoding.DecodedLen(len(encoded))) n, err := base64.StdEncoding.Decode(decoded, []byte(encoded)) if err != nil { return "", err } decoded = decoded[:n] nonceSize := aesgcm.NonceSize() if len(decoded) < nonceSize { return "", fmt.Errorf("cookie too short") } nonce, decoded := decoded[:nonceSize], decoded[nonceSize:] plain, err := aesgcm.Open(nil, nonce, decoded, nil) if err != nil { return "", err } return string(plain), nil } func DoPostActionRequestFromJson(data io.Reader) *DoPostActionRequest { var o *DoPostActionRequest json.NewDecoder(data).Decode(&o) return o }