github.com/nhannv/mattermost-server@v5.11.1+incompatible/app/integration_action.go (about)

     1  // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
     2  // See License.txt for license information.
     3  
     4  // Integration Action Flow
     5  //
     6  // 1. An integration creates an interactive message button or menu.
     7  // 2. A user clicks on a button or selects an option from the menu.
     8  // 3. The client sends a request to server to complete the post action, calling DoPostAction below.
     9  // 4. DoPostAction will send an HTTP POST request to the integration containing contextual data, including
    10  // an encoded and signed trigger ID. Slash commands also include trigger IDs in their payloads.
    11  // 5. The integration performs any actions it needs to and optionally makes a request back to the MM server
    12  // using the trigger ID to open an interactive dialog.
    13  // 6. If that optional request is made, OpenInteractiveDialog sends a WebSocket event to all connected clients
    14  // for the relevant user, telling them to display the dialog.
    15  // 7. The user fills in the dialog and submits it, where SubmitInteractiveDialog will submit it back to the
    16  // integration for handling.
    17  
    18  package app
    19  
    20  import (
    21  	"bytes"
    22  	"encoding/json"
    23  	"fmt"
    24  	"net/http"
    25  	"net/url"
    26  	"path"
    27  	"strings"
    28  
    29  	"github.com/mattermost/mattermost-server/model"
    30  	"github.com/mattermost/mattermost-server/utils"
    31  )
    32  
    33  func (a *App) DoPostAction(postId, actionId, userId, selectedOption string) (string, *model.AppError) {
    34  	return a.DoPostActionWithCookie(postId, actionId, userId, selectedOption, nil)
    35  }
    36  
    37  func (a *App) DoPostActionWithCookie(postId, actionId, userId, selectedOption string, cookie *model.PostActionCookie) (string, *model.AppError) {
    38  
    39  	// PostAction may result in the original post being updated. For the
    40  	// updated post, we need to unconditionally preserve the original
    41  	// IsPinned and HasReaction attributes, and preserve its entire
    42  	// original Props set unless the plugin returns a replacement value.
    43  	// originalXxx variables are used to preserve these values.
    44  	var originalProps map[string]interface{}
    45  	originalIsPinned := false
    46  	originalHasReactions := false
    47  
    48  	// If the updated post does contain a replacement Props set, we still
    49  	// need to preserve some original values, as listed in
    50  	// model.PostActionRetainPropKeys. remove and retain track these.
    51  	remove := []string{}
    52  	retain := map[string]interface{}{}
    53  
    54  	datasource := ""
    55  	upstreamURL := ""
    56  	rootPostId := ""
    57  	upstreamRequest := &model.PostActionIntegrationRequest{
    58  		UserId: userId,
    59  		PostId: postId,
    60  	}
    61  
    62  	// See if the post exists in the DB, if so ignore the cookie.
    63  	// Start all queries here for parallel execution
    64  	pchan := a.Srv.Store.Post().GetSingle(postId)
    65  	cchan := a.Srv.Store.Channel().GetForPost(postId)
    66  	result := <-pchan
    67  	if result.Err != nil {
    68  		if cookie == nil {
    69  			return "", result.Err
    70  		}
    71  		if cookie.Integration == nil {
    72  			return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "no Integration in action cookie", http.StatusBadRequest)
    73  		}
    74  
    75  		if postId != cookie.PostId {
    76  			return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "postId doesn't match", http.StatusBadRequest)
    77  		}
    78  
    79  		upstreamRequest.ChannelId = cookie.ChannelId
    80  		upstreamRequest.Type = cookie.Type
    81  		upstreamRequest.Context = cookie.Integration.Context
    82  		datasource = cookie.DataSource
    83  
    84  		retain = cookie.RetainProps
    85  		remove = cookie.RemoveProps
    86  		rootPostId = cookie.RootPostId
    87  		upstreamURL = cookie.Integration.URL
    88  	} else {
    89  		// Get action metadata from the database
    90  		post := result.Data.(*model.Post)
    91  
    92  		result = <-cchan
    93  		if result.Err != nil {
    94  			return "", result.Err
    95  		}
    96  		channel := result.Data.(*model.Channel)
    97  
    98  		action := post.GetAction(actionId)
    99  		if action == nil || action.Integration == nil {
   100  			return "", model.NewAppError("DoPostAction", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound)
   101  		}
   102  
   103  		upstreamRequest.ChannelId = post.ChannelId
   104  		upstreamRequest.TeamId = channel.TeamId
   105  		upstreamRequest.Type = action.Type
   106  		upstreamRequest.Context = action.Integration.Context
   107  		datasource = action.DataSource
   108  
   109  		// Save the original values that may need to be preserved (including selected
   110  		// Props, i.e. override_username, override_icon_url)
   111  		for _, key := range model.PostActionRetainPropKeys {
   112  			value, ok := post.Props[key]
   113  			if ok {
   114  				retain[key] = value
   115  			} else {
   116  				remove = append(remove, key)
   117  			}
   118  		}
   119  		originalProps = post.Props
   120  		originalIsPinned = post.IsPinned
   121  		originalHasReactions = post.HasReactions
   122  
   123  		if post.RootId == "" {
   124  			rootPostId = post.Id
   125  		} else {
   126  			rootPostId = post.RootId
   127  		}
   128  
   129  		upstreamURL = action.Integration.URL
   130  	}
   131  
   132  	if upstreamRequest.Type == model.POST_ACTION_TYPE_SELECT {
   133  		if selectedOption != "" {
   134  			if upstreamRequest.Context == nil {
   135  				upstreamRequest.Context = map[string]interface{}{}
   136  			}
   137  			upstreamRequest.DataSource = datasource
   138  			upstreamRequest.Context["selected_option"] = selectedOption
   139  		}
   140  	}
   141  
   142  	clientTriggerId, _, appErr := upstreamRequest.GenerateTriggerId(a.AsymmetricSigningKey())
   143  	if appErr != nil {
   144  		return "", appErr
   145  	}
   146  
   147  	resp, appErr := a.DoActionRequest(upstreamURL, upstreamRequest.ToJson())
   148  	if appErr != nil {
   149  		return "", appErr
   150  	}
   151  	defer resp.Body.Close()
   152  
   153  	var response model.PostActionIntegrationResponse
   154  	if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
   155  		return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest)
   156  	}
   157  
   158  	if response.Update != nil {
   159  		response.Update.Id = postId
   160  
   161  		// Restore the post attributes and Props that need to be preserved
   162  		if response.Update.Props == nil {
   163  			response.Update.Props = originalProps
   164  		} else {
   165  			for key, value := range retain {
   166  				response.Update.AddProp(key, value)
   167  			}
   168  			for _, key := range remove {
   169  				delete(response.Update.Props, key)
   170  			}
   171  		}
   172  		response.Update.IsPinned = originalIsPinned
   173  		response.Update.HasReactions = originalHasReactions
   174  
   175  		if _, appErr = a.UpdatePost(response.Update, false); appErr != nil {
   176  			return "", appErr
   177  		}
   178  	}
   179  
   180  	if response.EphemeralText != "" {
   181  		ephemeralPost := &model.Post{
   182  			Message:   model.ParseSlackLinksToMarkdown(response.EphemeralText),
   183  			ChannelId: upstreamRequest.ChannelId,
   184  			RootId:    rootPostId,
   185  			UserId:    userId,
   186  		}
   187  		for key, value := range retain {
   188  			ephemeralPost.AddProp(key, value)
   189  		}
   190  		a.SendEphemeralPost(userId, ephemeralPost)
   191  	}
   192  
   193  	return clientTriggerId, nil
   194  }
   195  
   196  // Perform an HTTP POST request to an integration's action endpoint.
   197  // Caller must consume and close returned http.Response as necessary.
   198  func (a *App) DoActionRequest(rawURL string, body []byte) (*http.Response, *model.AppError) {
   199  	req, err := http.NewRequest("POST", rawURL, bytes.NewReader(body))
   200  	if err != nil {
   201  		return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, err.Error(), http.StatusBadRequest)
   202  	}
   203  	req.Header.Set("Content-Type", "application/json")
   204  	req.Header.Set("Accept", "application/json")
   205  
   206  	// Allow access to plugin routes for action buttons
   207  	var httpClient *http.Client
   208  	url, _ := url.Parse(rawURL)
   209  	siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL)
   210  	subpath, _ := utils.GetSubpathFromConfig(a.Config())
   211  	if (url.Hostname() == "localhost" || url.Hostname() == "127.0.0.1" || url.Hostname() == siteURL.Hostname()) && strings.HasPrefix(url.Path, path.Join(subpath, "plugins")) {
   212  		req.Header.Set(model.HEADER_AUTH, "Bearer "+a.Session.Token)
   213  		httpClient = a.HTTPService.MakeClient(true)
   214  	} else {
   215  		httpClient = a.HTTPService.MakeClient(false)
   216  	}
   217  
   218  	resp, httpErr := httpClient.Do(req)
   219  	if httpErr != nil {
   220  		return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+httpErr.Error(), http.StatusBadRequest)
   221  	}
   222  
   223  	if resp.StatusCode != http.StatusOK {
   224  		return resp, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), http.StatusBadRequest)
   225  	}
   226  
   227  	return resp, nil
   228  }
   229  
   230  func (a *App) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError {
   231  	clientTriggerId, userId, err := request.DecodeAndVerifyTriggerId(a.AsymmetricSigningKey())
   232  	if err != nil {
   233  		return err
   234  	}
   235  
   236  	request.TriggerId = clientTriggerId
   237  
   238  	jsonRequest, _ := json.Marshal(request)
   239  
   240  	message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_OPEN_DIALOG, "", "", userId, nil)
   241  	message.Add("dialog", string(jsonRequest))
   242  	a.Publish(message)
   243  
   244  	return nil
   245  }
   246  
   247  func (a *App) SubmitInteractiveDialog(request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) {
   248  	url := request.URL
   249  	request.URL = ""
   250  	request.Type = "dialog_submission"
   251  
   252  	b, jsonErr := json.Marshal(request)
   253  	if jsonErr != nil {
   254  		return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.json_error", nil, jsonErr.Error(), http.StatusBadRequest)
   255  	}
   256  
   257  	resp, err := a.DoActionRequest(url, b)
   258  	if err != nil {
   259  		return nil, err
   260  	}
   261  
   262  	defer resp.Body.Close()
   263  
   264  	var response model.SubmitDialogResponse
   265  	if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
   266  		// Don't fail, an empty response is acceptable
   267  		return &response, nil
   268  	}
   269  
   270  	return &response, nil
   271  }