github.com/haalcala/mattermost-server-change-repo/v5@v5.33.2/app/integration_action.go (about) 1 // Copyright (c) 2015-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 "context" 23 "encoding/json" 24 "errors" 25 "fmt" 26 "io/ioutil" 27 "net/http" 28 "net/url" 29 "path" 30 "path/filepath" 31 "strings" 32 33 "github.com/gorilla/mux" 34 35 "github.com/mattermost/mattermost-server/v5/mlog" 36 "github.com/mattermost/mattermost-server/v5/model" 37 "github.com/mattermost/mattermost-server/v5/store" 38 "github.com/mattermost/mattermost-server/v5/utils" 39 ) 40 41 func (a *App) DoPostAction(postId, actionId, userID, selectedOption string) (string, *model.AppError) { 42 return a.DoPostActionWithCookie(postId, actionId, userID, selectedOption, nil) 43 } 44 45 func (a *App) DoPostActionWithCookie(postId, actionId, userID, selectedOption string, cookie *model.PostActionCookie) (string, *model.AppError) { 46 47 // PostAction may result in the original post being updated. For the 48 // updated post, we need to unconditionally preserve the original 49 // IsPinned and HasReaction attributes, and preserve its entire 50 // original Props set unless the plugin returns a replacement value. 51 // originalXxx variables are used to preserve these values. 52 var originalProps map[string]interface{} 53 originalIsPinned := false 54 originalHasReactions := false 55 56 // If the updated post does contain a replacement Props set, we still 57 // need to preserve some original values, as listed in 58 // model.PostActionRetainPropKeys. remove and retain track these. 59 remove := []string{} 60 retain := map[string]interface{}{} 61 62 datasource := "" 63 upstreamURL := "" 64 rootPostId := "" 65 upstreamRequest := &model.PostActionIntegrationRequest{ 66 UserId: userID, 67 PostId: postId, 68 } 69 70 // See if the post exists in the DB, if so ignore the cookie. 71 // Start all queries here for parallel execution 72 pchan := make(chan store.StoreResult, 1) 73 go func() { 74 post, err := a.Srv().Store.Post().GetSingle(postId) 75 pchan <- store.StoreResult{Data: post, NErr: err} 76 close(pchan) 77 }() 78 79 cchan := make(chan store.StoreResult, 1) 80 go func() { 81 channel, err := a.Srv().Store.Channel().GetForPost(postId) 82 cchan <- store.StoreResult{Data: channel, NErr: err} 83 close(cchan) 84 }() 85 86 userChan := make(chan store.StoreResult, 1) 87 go func() { 88 user, err := a.Srv().Store.User().Get(context.Background(), upstreamRequest.UserId) 89 userChan <- store.StoreResult{Data: user, NErr: err} 90 close(userChan) 91 }() 92 93 result := <-pchan 94 if result.NErr != nil { 95 if cookie == nil { 96 var nfErr *store.ErrNotFound 97 switch { 98 case errors.As(result.NErr, &nfErr): 99 return "", model.NewAppError("DoPostActionWithCookie", "app.post.get.app_error", nil, nfErr.Error(), http.StatusNotFound) 100 default: 101 return "", model.NewAppError("DoPostActionWithCookie", "app.post.get.app_error", nil, result.NErr.Error(), http.StatusInternalServerError) 102 } 103 } 104 if cookie.Integration == nil { 105 return "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "no Integration in action cookie", http.StatusBadRequest) 106 } 107 108 if postId != cookie.PostId { 109 return "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "postId doesn't match", http.StatusBadRequest) 110 } 111 112 channel, err := a.Srv().Store.Channel().Get(cookie.ChannelId, true) 113 if err != nil { 114 var nfErr *store.ErrNotFound 115 switch { 116 case errors.As(err, &nfErr): 117 return "", model.NewAppError("DoPostActionWithCookie", "app.channel.get.existing.app_error", nil, nfErr.Error(), http.StatusNotFound) 118 default: 119 return "", model.NewAppError("DoPostActionWithCookie", "app.channel.get.find.app_error", nil, err.Error(), http.StatusInternalServerError) 120 } 121 } 122 123 upstreamRequest.ChannelId = cookie.ChannelId 124 upstreamRequest.ChannelName = channel.Name 125 upstreamRequest.TeamId = channel.TeamId 126 upstreamRequest.Type = cookie.Type 127 upstreamRequest.Context = cookie.Integration.Context 128 datasource = cookie.DataSource 129 130 retain = cookie.RetainProps 131 remove = cookie.RemoveProps 132 rootPostId = cookie.RootPostId 133 upstreamURL = cookie.Integration.URL 134 } else { 135 post := result.Data.(*model.Post) 136 result = <-cchan 137 if result.NErr != nil { 138 return "", model.NewAppError("DoPostActionWithCookie", "app.channel.get_for_post.app_error", nil, result.NErr.Error(), http.StatusInternalServerError) 139 } 140 channel := result.Data.(*model.Channel) 141 142 action := post.GetAction(actionId) 143 if action == nil || action.Integration == nil { 144 return "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound) 145 } 146 147 upstreamRequest.ChannelId = post.ChannelId 148 upstreamRequest.ChannelName = channel.Name 149 upstreamRequest.TeamId = channel.TeamId 150 upstreamRequest.Type = action.Type 151 upstreamRequest.Context = action.Integration.Context 152 datasource = action.DataSource 153 154 // Save the original values that may need to be preserved (including selected 155 // Props, i.e. override_username, override_icon_url) 156 for _, key := range model.PostActionRetainPropKeys { 157 value, ok := post.GetProps()[key] 158 if ok { 159 retain[key] = value 160 } else { 161 remove = append(remove, key) 162 } 163 } 164 originalProps = post.GetProps() 165 originalIsPinned = post.IsPinned 166 originalHasReactions = post.HasReactions 167 168 if post.RootId == "" { 169 rootPostId = post.Id 170 } else { 171 rootPostId = post.RootId 172 } 173 174 upstreamURL = action.Integration.URL 175 } 176 177 teamChan := make(chan store.StoreResult, 1) 178 179 go func() { 180 defer close(teamChan) 181 182 // Direct and group channels won't have teams. 183 if upstreamRequest.TeamId == "" { 184 return 185 } 186 187 team, err := a.Srv().Store.Team().Get(upstreamRequest.TeamId) 188 teamChan <- store.StoreResult{Data: team, NErr: err} 189 }() 190 191 ur := <-userChan 192 if ur.NErr != nil { 193 var nfErr *store.ErrNotFound 194 switch { 195 case errors.As(ur.NErr, &nfErr): 196 return "", model.NewAppError("DoPostActionWithCookie", MissingAccountError, nil, nfErr.Error(), http.StatusNotFound) 197 default: 198 return "", model.NewAppError("DoPostActionWithCookie", "app.user.get.app_error", nil, ur.NErr.Error(), http.StatusInternalServerError) 199 } 200 } 201 user := ur.Data.(*model.User) 202 upstreamRequest.UserName = user.Username 203 204 tr, ok := <-teamChan 205 if ok { 206 if tr.NErr != nil { 207 var nfErr *store.ErrNotFound 208 switch { 209 case errors.As(tr.NErr, &nfErr): 210 return "", model.NewAppError("DoPostActionWithCookie", "app.team.get.find.app_error", nil, nfErr.Error(), http.StatusNotFound) 211 default: 212 return "", model.NewAppError("DoPostActionWithCookie", "app.team.get.finding.app_error", nil, tr.NErr.Error(), http.StatusInternalServerError) 213 } 214 } 215 216 team := tr.Data.(*model.Team) 217 upstreamRequest.TeamName = team.Name 218 } 219 220 if upstreamRequest.Type == model.POST_ACTION_TYPE_SELECT { 221 if selectedOption != "" { 222 if upstreamRequest.Context == nil { 223 upstreamRequest.Context = map[string]interface{}{} 224 } 225 upstreamRequest.DataSource = datasource 226 upstreamRequest.Context["selected_option"] = selectedOption 227 } 228 } 229 230 clientTriggerId, _, appErr := upstreamRequest.GenerateTriggerId(a.AsymmetricSigningKey()) 231 if appErr != nil { 232 return "", appErr 233 } 234 235 var resp *http.Response 236 if strings.HasPrefix(upstreamURL, "/warn_metrics/") { 237 appErr = a.doLocalWarnMetricsRequest(upstreamURL, upstreamRequest) 238 if appErr != nil { 239 return "", appErr 240 } 241 return "", nil 242 } 243 resp, appErr = a.DoActionRequest(upstreamURL, upstreamRequest.ToJson()) 244 if appErr != nil { 245 return "", appErr 246 } 247 defer resp.Body.Close() 248 249 var response model.PostActionIntegrationResponse 250 if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 251 return "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) 252 } 253 254 if response.Update != nil { 255 response.Update.Id = postId 256 257 // Restore the post attributes and Props that need to be preserved 258 if response.Update.GetProps() == nil { 259 response.Update.SetProps(originalProps) 260 } else { 261 for key, value := range retain { 262 response.Update.AddProp(key, value) 263 } 264 for _, key := range remove { 265 response.Update.DelProp(key) 266 } 267 } 268 response.Update.IsPinned = originalIsPinned 269 response.Update.HasReactions = originalHasReactions 270 271 if _, appErr = a.UpdatePost(response.Update, false); appErr != nil { 272 return "", appErr 273 } 274 } 275 276 if response.EphemeralText != "" { 277 ephemeralPost := &model.Post{ 278 Message: response.EphemeralText, 279 ChannelId: upstreamRequest.ChannelId, 280 RootId: rootPostId, 281 UserId: userID, 282 } 283 284 if !response.SkipSlackParsing { 285 ephemeralPost.Message = model.ParseSlackLinksToMarkdown(response.EphemeralText) 286 } 287 288 for key, value := range retain { 289 ephemeralPost.AddProp(key, value) 290 } 291 a.SendEphemeralPost(userID, ephemeralPost) 292 } 293 294 return clientTriggerId, nil 295 } 296 297 // Perform an HTTP POST request to an integration's action endpoint. 298 // Caller must consume and close returned http.Response as necessary. 299 // For internal requests, requests are routed directly to a plugin ServerHTTP hook 300 func (a *App) DoActionRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { 301 inURL, err := url.Parse(rawURL) 302 if err != nil { 303 return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, err.Error(), http.StatusBadRequest) 304 } 305 306 rawURLPath := path.Clean(rawURL) 307 if strings.HasPrefix(rawURLPath, "/plugins/") || strings.HasPrefix(rawURLPath, "plugins/") { 308 return a.DoLocalRequest(rawURLPath, body) 309 } 310 311 req, err := http.NewRequest("POST", rawURL, bytes.NewReader(body)) 312 if err != nil { 313 return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, err.Error(), http.StatusBadRequest) 314 } 315 req.Header.Set("Content-Type", "application/json") 316 req.Header.Set("Accept", "application/json") 317 318 // Allow access to plugin routes for action buttons 319 var httpClient *http.Client 320 subpath, _ := utils.GetSubpathFromConfig(a.Config()) 321 siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL) 322 if (inURL.Hostname() == "localhost" || inURL.Hostname() == "127.0.0.1" || inURL.Hostname() == siteURL.Hostname()) && strings.HasPrefix(inURL.Path, path.Join(subpath, "plugins")) { 323 req.Header.Set(model.HEADER_AUTH, "Bearer "+a.Session().Token) 324 httpClient = a.HTTPService().MakeClient(true) 325 } else { 326 httpClient = a.HTTPService().MakeClient(false) 327 } 328 329 resp, httpErr := httpClient.Do(req) 330 if httpErr != nil { 331 return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+httpErr.Error(), http.StatusBadRequest) 332 } 333 334 if resp.StatusCode != http.StatusOK { 335 return resp, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), http.StatusBadRequest) 336 } 337 338 return resp, nil 339 } 340 341 type LocalResponseWriter struct { 342 data []byte 343 headers http.Header 344 status int 345 } 346 347 func (w *LocalResponseWriter) Header() http.Header { 348 if w.headers == nil { 349 w.headers = make(http.Header) 350 } 351 return w.headers 352 } 353 354 func (w *LocalResponseWriter) Write(bytes []byte) (int, error) { 355 w.data = make([]byte, len(bytes)) 356 copy(w.data, bytes) 357 return len(w.data), nil 358 } 359 360 func (w *LocalResponseWriter) WriteHeader(statusCode int) { 361 w.status = statusCode 362 } 363 364 func (a *App) doPluginRequest(method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { 365 rawURL = strings.TrimPrefix(rawURL, "/") 366 inURL, err := url.Parse(rawURL) 367 if err != nil { 368 return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) 369 } 370 result := strings.Split(inURL.Path, "/") 371 if len(result) < 2 { 372 return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=Unable to find pluginId", http.StatusBadRequest) 373 } 374 if result[0] != "plugins" { 375 return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=plugins not in path", http.StatusBadRequest) 376 } 377 pluginId := result[1] 378 379 path := strings.TrimPrefix(inURL.Path, "plugins/"+pluginId) 380 381 base, err := url.Parse(path) 382 if err != nil { 383 return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) 384 } 385 386 // merge the rawQuery params (if any) with the function's provided values 387 rawValues := inURL.Query() 388 if len(rawValues) != 0 { 389 if values == nil { 390 values = make(url.Values) 391 } 392 for k, vs := range rawValues { 393 for _, v := range vs { 394 values.Add(k, v) 395 } 396 } 397 } 398 if values != nil { 399 base.RawQuery = values.Encode() 400 } 401 402 w := &LocalResponseWriter{} 403 r, err := http.NewRequest(method, base.String(), bytes.NewReader(body)) 404 if err != nil { 405 return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) 406 } 407 r.Header.Set("Mattermost-User-Id", a.Session().UserId) 408 r.Header.Set(model.HEADER_AUTH, "Bearer "+a.Session().Token) 409 params := make(map[string]string) 410 params["plugin_id"] = pluginId 411 r = mux.SetURLVars(r, params) 412 413 a.ServePluginRequest(w, r) 414 415 resp := &http.Response{ 416 StatusCode: w.status, 417 Proto: "HTTP/1.1", 418 ProtoMajor: 1, 419 ProtoMinor: 1, 420 Header: w.headers, 421 Body: ioutil.NopCloser(bytes.NewReader(w.data)), 422 } 423 if resp.StatusCode == 0 { 424 resp.StatusCode = http.StatusOK 425 } 426 427 return resp, nil 428 } 429 430 func (a *App) doLocalWarnMetricsRequest(rawURL string, upstreamRequest *model.PostActionIntegrationRequest) *model.AppError { 431 _, err := url.Parse(rawURL) 432 if err != nil { 433 return model.NewAppError("doLocalWarnMetricsRequest", "api.post.do_action.action_integration.app_error", nil, err.Error(), http.StatusBadRequest) 434 } 435 436 warnMetricId := filepath.Base(rawURL) 437 if warnMetricId == "" { 438 return model.NewAppError("doLocalWarnMetricsRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest) 439 } 440 441 license := a.Srv().License() 442 if license != nil { 443 mlog.Debug("License is present, skip this call") 444 return nil 445 } 446 447 user, appErr := a.GetUser(a.Session().UserId) 448 if appErr != nil { 449 return appErr 450 } 451 452 botPost := &model.Post{ 453 UserId: upstreamRequest.Context["bot_user_id"].(string), 454 ChannelId: upstreamRequest.ChannelId, 455 HasReactions: true, 456 } 457 458 isE0Edition := (model.BuildEnterpriseReady == "true") // license == nil was already validated upstream 459 _, warnMetricDisplayTexts := a.getWarnMetricStatusAndDisplayTextsForId(warnMetricId, utils.T, isE0Edition) 460 botPost.Message = ":white_check_mark: " + warnMetricDisplayTexts.BotSuccessMessage 461 462 if isE0Edition { 463 if appErr = a.RequestLicenseAndAckWarnMetric(warnMetricId, true); appErr != nil { 464 botPost.Message = ":warning: " + utils.T("api.server.warn_metric.bot_response.start_trial_failure.message") 465 } 466 } else { 467 forceAck := upstreamRequest.Context["force_ack"].(bool) 468 if appErr = a.NotifyAndSetWarnMetricAck(warnMetricId, user, forceAck, true); appErr != nil { 469 if forceAck { 470 return appErr 471 } 472 mailtoLinkText := a.buildWarnMetricMailtoLink(warnMetricId, user) 473 botPost.Message = ":warning: " + utils.T("api.server.warn_metric.bot_response.notification_failure.message") 474 actions := []*model.PostAction{} 475 actions = append(actions, 476 &model.PostAction{ 477 Id: "emailUs", 478 Name: utils.T("api.server.warn_metric.email_us"), 479 Type: model.POST_ACTION_TYPE_BUTTON, 480 Options: []*model.PostActionOptions{ 481 { 482 Text: "WarnMetricMailtoUrl", 483 Value: mailtoLinkText, 484 }, 485 { 486 Text: "TrackEventId", 487 Value: warnMetricId, 488 }, 489 }, 490 Integration: &model.PostActionIntegration{ 491 Context: model.StringInterface{ 492 "bot_user_id": botPost.UserId, 493 "force_ack": true, 494 }, 495 URL: fmt.Sprintf("/warn_metrics/ack/%s", model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500), 496 }, 497 }, 498 ) 499 attachements := []*model.SlackAttachment{{ 500 AuthorName: "", 501 Title: "", 502 Actions: actions, 503 Text: utils.T("api.server.warn_metric.bot_response.notification_failure.body"), 504 }} 505 model.ParseSlackAttachment(botPost, attachements) 506 } 507 } 508 509 if _, err := a.CreatePostAsUser(botPost, a.Session().Id, true); err != nil { 510 return err 511 } 512 513 return nil 514 } 515 516 type MailToLinkContent struct { 517 MetricId string `json:"metric_id"` 518 MailRecipient string `json:"mail_recipient"` 519 MailCC string `json:"mail_cc"` 520 MailSubject string `json:"mail_subject"` 521 MailBody string `json:"mail_body"` 522 } 523 524 func (mlc *MailToLinkContent) ToJson() string { 525 b, _ := json.Marshal(mlc) 526 return string(b) 527 } 528 529 func (a *App) buildWarnMetricMailtoLink(warnMetricId string, user *model.User) string { 530 T := utils.GetUserTranslations(user.Locale) 531 _, warnMetricDisplayTexts := a.getWarnMetricStatusAndDisplayTextsForId(warnMetricId, T, false) 532 533 mailBody := warnMetricDisplayTexts.EmailBody 534 mailBody += T("api.server.warn_metric.bot_response.mailto_contact_header", map[string]interface{}{"Contact": user.GetFullName()}) 535 mailBody += "\r\n" 536 mailBody += T("api.server.warn_metric.bot_response.mailto_email_header", map[string]interface{}{"Email": user.Email}) 537 mailBody += "\r\n" 538 539 registeredUsersCount, err := a.Srv().Store.User().Count(model.UserCountOptions{}) 540 if err != nil { 541 mlog.Warn("Error retrieving the number of registered users", mlog.Err(err)) 542 } else { 543 mailBody += utils.T("api.server.warn_metric.bot_response.mailto_registered_users_header", map[string]interface{}{"NoRegisteredUsers": registeredUsersCount}) 544 mailBody += "\r\n" 545 } 546 547 mailBody += T("api.server.warn_metric.bot_response.mailto_site_url_header", map[string]interface{}{"SiteUrl": a.GetSiteURL()}) 548 mailBody += "\r\n" 549 550 mailBody += T("api.server.warn_metric.bot_response.mailto_diagnostic_id_header", map[string]interface{}{"DiagnosticId": a.TelemetryId()}) 551 mailBody += "\r\n" 552 553 mailBody += T("api.server.warn_metric.bot_response.mailto_footer") 554 555 mailToLinkContent := &MailToLinkContent{ 556 MetricId: warnMetricId, 557 MailRecipient: model.MM_SUPPORT_ADVISOR_ADDRESS, 558 MailCC: user.Email, 559 MailSubject: T("api.server.warn_metric.bot_response.mailto_subject"), 560 MailBody: mailBody, 561 } 562 563 return mailToLinkContent.ToJson() 564 } 565 566 func (a *App) DoLocalRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { 567 return a.doPluginRequest("POST", rawURL, nil, body) 568 } 569 570 func (a *App) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError { 571 clientTriggerId, userID, err := request.DecodeAndVerifyTriggerId(a.AsymmetricSigningKey()) 572 if err != nil { 573 return err 574 } 575 576 request.TriggerId = clientTriggerId 577 578 jsonRequest, _ := json.Marshal(request) 579 580 message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_OPEN_DIALOG, "", "", userID, nil) 581 message.Add("dialog", string(jsonRequest)) 582 a.Publish(message) 583 584 return nil 585 } 586 587 func (a *App) SubmitInteractiveDialog(request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) { 588 url := request.URL 589 request.URL = "" 590 request.Type = "dialog_submission" 591 592 b, jsonErr := json.Marshal(request) 593 if jsonErr != nil { 594 return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.json_error", nil, jsonErr.Error(), http.StatusBadRequest) 595 } 596 597 resp, err := a.DoActionRequest(url, b) 598 if err != nil { 599 return nil, err 600 } 601 602 defer resp.Body.Close() 603 604 var response model.SubmitDialogResponse 605 if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 606 // Don't fail, an empty response is acceptable 607 return &response, nil 608 } 609 610 return &response, nil 611 }