Files
team/server/channels/app/integration_action.go
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

653 lines
24 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Integration Action Flow
//
// 1. An integration creates an interactive message button or menu.
// 2. A user clicks on a button or selects an option from the menu.
// 3. The client sends a request to server to complete the post action, calling DoPostActionWithCookie below.
// 4. DoPostActionWithCookie will send an HTTP POST request to the integration containing contextual data, including
// an encoded and signed trigger ID. Slash commands also include trigger IDs in their payloads.
// 5. The integration performs any actions it needs to and optionally makes a request back to the MM server
// using the trigger ID to open an interactive dialog.
// 6. If that optional request is made, OpenInteractiveDialog sends a WebSocket event to all connected clients
// for the relevant user, telling them to display the dialog.
// 7. The user fills in the dialog and submits it, where SubmitInteractiveDialog will submit it back to the
// integration for handling.
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/mlog"
"github.com/hanzoteam/server/server/public/shared/request"
"github.com/hanzoteam/server/server/v8/channels/utils"
)
func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID, selectedOption string, legacyCookie *model.PostActionCookie, mmBlocksCookie *model.MmBlocksActionCookie, clientQuery map[string]string, integrationFormat string) (string, string, *model.AppError) {
// Bound the per-click query at the App boundary so any caller — REST
// handler, plugin, future internal trigger — gets the same enforcement.
if err := model.ValidateActionQuery(clientQuery); err != nil {
return "", "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.query.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
setup, gotoURL, appErr := a.resolvePostActionSetup(rctx, postID, actionId, userID, legacyCookie, mmBlocksCookie, clientQuery, integrationFormat)
if appErr != nil {
return "", "", appErr
}
if gotoURL != "" {
return "", gotoURL, nil
}
upstreamRequest := setup.upstreamRequest
if selectedOption != "" {
if upstreamRequest.Context == nil {
upstreamRequest.Context = map[string]any{}
}
upstreamRequest.Context["selected_option"] = selectedOption
upstreamRequest.DataSource = setup.datasource
}
clientTriggerId, _, appErr := upstreamRequest.GenerateTriggerId(a.AsymmetricSigningKey())
if appErr != nil {
return "", "", appErr
}
requestJSON, err := json.Marshal(upstreamRequest)
if err != nil {
return "", "", model.NewAppError("DoPostActionWithCookie", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Log request, regardless of whether destination is internal or external
rctx.Logger().Info("DoPostActionWithCookie POST request, through DoActionRequest",
mlog.String("url", setup.upstreamURL),
mlog.String("user_id", upstreamRequest.UserId),
mlog.String("post_id", upstreamRequest.PostId),
mlog.String("channel_id", upstreamRequest.ChannelId),
mlog.String("team_id", upstreamRequest.TeamId),
)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
resp, appErr := a.DoActionRequest(rctx.WithContext(ctx), setup.upstreamURL, requestJSON)
if appErr != nil {
return "", "", appErr
}
defer resp.Body.Close()
var response model.PostActionIntegrationResponse
limitedReader := io.LimitReader(resp.Body, MaxIntegrationResponseSize)
respBytes, err := io.ReadAll(limitedReader)
if err != nil {
return "", "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
if len(respBytes) > 0 {
if err = json.Unmarshal(respBytes, &response); err != nil {
return "", "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
}
if response.Update != nil {
if appErr = a.applyPostActionUpdate(rctx, setup, postID, userID, response.Update); appErr != nil {
return "", "", appErr
}
}
if response.EphemeralText != "" {
ephemeralPost := &model.Post{
Message: response.EphemeralText,
ChannelId: upstreamRequest.ChannelId,
RootId: setup.rootPostId,
UserId: userID,
}
if !response.SkipSlackParsing {
ephemeralPost.Message = model.ParseSlackLinksToMarkdown(response.EphemeralText)
}
for key, value := range setup.retain {
ephemeralPost.AddProp(key, value)
}
a.SendEphemeralPost(rctx, userID, ephemeralPost)
}
return clientTriggerId, response.GotoLocation, nil
}
// DoActionRequest performs an HTTP POST request to an integration's action endpoint.
// Caller must consume and close returned http.Response as necessary.
// For internal requests, requests are routed directly to a plugin ServerHTTP hook
func (a *App) DoActionRequest(rctx request.CTX, rawURL string, body []byte) (*http.Response, *model.AppError) {
inURL, err := url.Parse(rawURL)
if err != nil {
return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
rawURLPath := path.Clean(rawURL)
if strings.HasPrefix(rawURLPath, "/plugins/") || strings.HasPrefix(rawURLPath, "plugins/") {
return a.DoLocalRequest(rctx, rawURLPath, body)
}
req, err := http.NewRequestWithContext(rctx.Context(), "POST", rawURL, bytes.NewReader(body))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
rctx.Logger().Info("Outgoing Integration Action request timed out. Consider increasing ServiceSettings.OutgoingIntegrationRequestsTimeout.")
}
return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
httpClient := a.getPostActionClient(rctx, inURL, req)
resp, httpErr := httpClient.Do(req)
if httpErr != nil {
return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(httpErr)
}
if resp.StatusCode != http.StatusOK {
// Preserve 429 and 503 because they carry retry semantics (RFC 6585,
// RFC 7231) that downstream HTTP clients legitimately need. Map other
// 5xx responses to 502 Bad Gateway since the failure is upstream of
// MM. Sanitize all other non-200 responses to 400 so plugin-specific
// 4xx codes do not leak misleading semantics to MM API clients.
status := http.StatusBadRequest
switch {
case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode == http.StatusServiceUnavailable:
status = resp.StatusCode
case resp.StatusCode >= 500:
status = http.StatusBadGateway
}
return resp, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), status)
}
return resp, nil
}
func (a *App) getPostActionClient(rctx request.CTX, inURL *url.URL, req *http.Request) *http.Client {
// Allow access to plugin routes for action buttons
var httpClient *http.Client
subpath, _ := utils.GetSubpathFromConfig(a.Config())
siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL)
if inURL.Hostname() == siteURL.Hostname() && strings.HasPrefix(path.Clean(inURL.Path), path.Join(subpath, "plugins")) {
req.Header.Set(model.HeaderAuth, "Bearer "+rctx.Session().Token)
httpClient = a.HTTPService().MakeClient(true)
} else {
httpClient = a.HTTPService().MakeClient(false)
}
return httpClient
}
type LocalResponseWriter struct {
data []byte
headers http.Header
status int
}
func (w *LocalResponseWriter) Header() http.Header {
if w.headers == nil {
w.headers = make(http.Header)
}
return w.headers
}
func (w *LocalResponseWriter) Write(bytes []byte) (int, error) {
w.data = make([]byte, len(bytes))
copy(w.data, bytes)
return len(w.data), nil
}
func (w *LocalResponseWriter) WriteHeader(statusCode int) {
w.status = statusCode
}
func (a *App) doPluginRequest(rctx request.CTX, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) {
return a.ch.doPluginRequest(rctx, method, rawURL, values, body)
}
func (ch *Channels) doPluginRequest(rctx request.CTX, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) {
rawURL = strings.TrimPrefix(rawURL, "/")
inURL, err := url.Parse(rawURL)
if err != nil {
return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
result := strings.Split(path.Clean(inURL.Path), "/")
if len(result) < 2 {
return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=Unable to find pluginId", http.StatusBadRequest)
}
if result[0] != "plugins" {
return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=plugins not in path", http.StatusBadRequest)
}
pluginID := result[1]
path := strings.TrimPrefix(inURL.Path, "plugins/"+pluginID)
base, err := url.Parse(path)
if err != nil {
return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
// merge the rawQuery params (if any) with the function's provided values
rawValues := inURL.Query()
if len(rawValues) != 0 {
if values == nil {
values = make(url.Values)
}
for k, vs := range rawValues {
for _, v := range vs {
values.Add(k, v)
}
}
}
if values != nil {
base.RawQuery = values.Encode()
}
w := &LocalResponseWriter{}
r, err := http.NewRequest(method, base.String(), bytes.NewReader(body))
if err != nil {
return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
r.Header.Set("Mattermost-User-Id", rctx.Session().UserId)
r.Header.Set(model.HeaderAuth, "Bearer "+rctx.Session().Token)
params := make(map[string]string)
params["plugin_id"] = pluginID
r = mux.SetURLVars(r, params)
ch.ServePluginRequest(w, r)
resp := &http.Response{
StatusCode: w.status,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: w.headers,
Body: io.NopCloser(bytes.NewReader(w.data)),
}
if resp.StatusCode == 0 {
resp.StatusCode = http.StatusOK
}
return resp, nil
}
type MailToLinkContent struct {
MetricId string `json:"metric_id"`
MailRecipient string `json:"mail_recipient"`
MailCC string `json:"mail_cc"`
MailSubject string `json:"mail_subject"`
MailBody string `json:"mail_body"`
}
func (mlc *MailToLinkContent) ToJSON() string {
b, _ := json.Marshal(mlc)
return string(b)
}
func (a *App) DoLocalRequest(rctx request.CTX, rawURL string, body []byte) (*http.Response, *model.AppError) {
return a.doPluginRequest(rctx, "POST", rawURL, nil, body)
}
func (a *App) OpenInteractiveDialog(rctx request.CTX, request model.OpenDialogRequest) *model.AppError {
timeout := time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout) * time.Second
clientTriggerId, userID, appErr := request.DecodeAndVerifyTriggerId(a.AsymmetricSigningKey(), timeout)
if appErr != nil {
return appErr
}
request.TriggerId = clientTriggerId
if dialogErr := request.IsValid(); dialogErr != nil {
rctx.Logger().Warn("Interactive dialog is invalid", mlog.Err(dialogErr))
}
jsonRequest, err := json.Marshal(request)
if err != nil {
a.ch.srv.Log().Warn("Error encoding request", mlog.Err(err))
}
message := model.NewWebSocketEvent(model.WebsocketEventOpenDialog, "", "", userID, nil, "")
message.Add("dialog", string(jsonRequest))
a.Publish(message)
return nil
}
func (a *App) SubmitInteractiveDialog(rctx request.CTX, request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) {
url := request.URL
request.URL = ""
// Validate submitted file IDs exist and belong to the submitting user.
// Dedup without sorting so the order the user selected the files is preserved
// when the request is forwarded to the integration.
request.FileIds = model.RemoveDuplicateStringsNonSort(request.FileIds)
if len(request.FileIds) > model.MaxDialogFileIds {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.too_many_file_ids",
map[string]any{"Max": model.MaxDialogFileIds}, "", http.StatusBadRequest)
}
declaredFileIDs := make(map[string]bool, len(request.FileIds))
for _, fileID := range request.FileIds {
declaredFileIDs[fileID] = true
}
// Validate the declared file IDs with a single batched lookup (one DB roundtrip
// instead of one per file). This is the primary ownership check, so a store
// error fails closed.
if len(request.FileIds) > 0 {
declaredFiles, nErr := a.Srv().Store().FileInfo().GetByIds(request.FileIds, false, false, false)
if nErr != nil {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.get_file_info_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
foundFileIDs := make(map[string]bool, len(declaredFiles))
for _, fileInfo := range declaredFiles {
foundFileIDs[fileInfo.Id] = true
if fileInfo.CreatorId != request.UserId {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.file_not_owned", map[string]any{"FileId": fileInfo.Id}, "", http.StatusForbidden)
}
}
// Any declared ID not returned doesn't exist (or was deleted) — reject it.
for _, fileID := range request.FileIds {
if !foundFileIDs[fileID] {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.invalid_file_id", map[string]any{"FileId": fileID}, "", http.StatusBadRequest)
}
}
}
// Defense in depth: integrations read raw field values from request.Submission
// (e.g. submission["my_file"]), not request.FileIds. Because the submit request
// does not carry the dialog's element definitions, the server cannot tell which
// submission fields are file pickers, so a malicious client could smuggle another
// user's file ID through a submission value while sending a benign FileIds list.
// Collect ID-shaped tokens from submission values (recursing into arrays/objects),
// resolve them in a single batch, and enforce the same ownership check on any that
// are real files. Tokens that don't resolve to a file (e.g. user/channel select
// IDs, or ID-shaped free text) are ordinary values and ignored.
//
// The scan is bounded in both breadth (ID-shaped tokens collected) and depth
// (recursion into nested arrays/objects). Hitting either bound must not silently
// skip remaining values — breadth overflow fails closed so a padded submission
// cannot smuggle an unchecked file ID past the cap.
const maxSubmissionScanDepth = 100
candidateFileIDs := make([]string, 0)
seenCandidate := make(map[string]bool)
scanLimitExceeded := false
var collectIDs func(v any, depth int)
collectIDs = func(v any, depth int) {
if depth > maxSubmissionScanDepth || scanLimitExceeded {
return
}
switch typed := v.(type) {
case string:
for tok := range strings.SplitSeq(typed, ",") {
tok = strings.TrimSpace(tok)
if tok == "" || declaredFileIDs[tok] || seenCandidate[tok] || !model.IsValidId(tok) {
continue
}
if len(candidateFileIDs) >= model.MaxDialogSubmissionIDShapedTokenScan {
scanLimitExceeded = true
return
}
seenCandidate[tok] = true
candidateFileIDs = append(candidateFileIDs, tok)
}
case []any:
for _, e := range typed {
if scanLimitExceeded {
return
}
collectIDs(e, depth+1)
}
case []string:
for _, e := range typed {
if scanLimitExceeded {
return
}
collectIDs(e, depth+1)
}
case map[string]any:
for _, e := range typed {
if scanLimitExceeded {
return
}
collectIDs(e, depth+1)
}
}
}
for _, raw := range request.Submission {
if scanLimitExceeded {
break
}
collectIDs(raw, 0)
}
if scanLimitExceeded {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.too_many_submission_ids",
map[string]any{"Max": model.MaxDialogSubmissionIDShapedTokenScan}, "", http.StatusBadRequest)
}
if len(candidateFileIDs) > 0 {
// allowFromCache=false: ownership must be decided from current DB state, not a
// possibly stale per-file cache entry.
submissionFiles, nErr := a.Srv().Store().FileInfo().GetByIds(candidateFileIDs, false, false, false)
if nErr != nil {
// Defense-in-depth scan: a transient store error must not block an otherwise
// valid submission whose tokens may just be coincidental ID-shaped text. The
// primary FileIds ownership check above already ran fail-closed.
rctx.Logger().Warn("Could not resolve submission file IDs for ownership check", mlog.Err(nErr))
} else {
for _, fileInfo := range submissionFiles {
if fileInfo.CreatorId != request.UserId {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.file_not_owned", map[string]any{"FileId": fileInfo.Id}, "", http.StatusForbidden)
}
}
// Only tokens that are real files count toward the limit; combine with the
// declared FileIds so the per-submission ceiling is MaxDialogFileIds total.
if len(declaredFileIDs)+len(submissionFiles) > model.MaxDialogFileIds {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.too_many_file_ids",
map[string]any{"Max": model.MaxDialogFileIds}, "", http.StatusBadRequest)
}
}
}
// Preserve Type field for field refresh functionality, otherwise default to dialog_submission
if request.Type != "refresh" {
request.Type = "dialog_submission"
}
b, err := json.Marshal(request)
if err != nil {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.json_error", nil, "", http.StatusBadRequest).Wrap(err)
}
// Log request, regardless of whether destination is internal or external
rctx.Logger().Info("SubmitInteractiveDialog POST request, through DoActionRequest",
mlog.String("url", url),
mlog.String("user_id", request.UserId),
mlog.String("channel_id", request.ChannelId),
mlog.String("team_id", request.TeamId),
mlog.Bool("cancelled", request.Cancelled),
)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
resp, appErr := a.DoActionRequest(rctx.WithContext(ctx), url, b)
if appErr != nil {
return nil, appErr
}
defer resp.Body.Close()
// Limit response size to prevent OOM attacks
limitedReader := io.LimitReader(resp.Body, MaxDialogResponseSize)
body, err := io.ReadAll(limitedReader)
if err != nil {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.read_body_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
var response model.SubmitDialogResponse
if len(body) == 0 {
// Don't fail, an empty response is acceptable
return &response, nil
}
err = json.Unmarshal(body, &response)
if err != nil {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.decode_json_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Validate the response
if err := response.IsValid(); err != nil {
if strings.Contains(err.Error(), "invalid form") {
rctx.Logger().Info("Interactive dialog is invalid", mlog.Err(err))
} else {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.invalid_response", nil, err.Error(), http.StatusBadRequest)
}
}
return &response, nil
}
func (a *App) ExecuteDialogAction(rctx request.CTX, userID string, req model.ExecuteDialogActionRequest) (string, *model.AppError) {
if !model.IsValidLookupURL(req.URL) {
return "", model.NewAppError("ExecuteDialogAction", "api.post.do_action.action_integration.app_error", nil, "invalid URL", http.StatusBadRequest)
}
if err := model.ValidateActionQuery(req.Context); err != nil {
return "", model.NewAppError("ExecuteDialogAction", "api.post.do_action.action_integration.app_error", nil, err.Error(), http.StatusBadRequest)
}
ctx := make(map[string]any, len(req.Context))
for k, v := range req.Context {
ctx[k] = v
}
upstreamRequest := &model.PostActionIntegrationRequest{
Type: "dialog_action",
UserId: userID,
ChannelId: req.ChannelId,
TeamId: req.TeamId,
Context: ctx,
}
user, userErr := a.Srv().Store().User().Get(rctx, userID)
if userErr != nil {
return "", model.NewAppError("ExecuteDialogAction", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(userErr)
}
upstreamRequest.UserName = user.Username
channel, channelErr := a.GetChannel(rctx, req.ChannelId)
if channelErr != nil {
return "", channelErr
}
upstreamRequest.ChannelName = channel.Name
if req.TeamId != "" {
team, teamErr := a.Srv().Store().Team().Get(req.TeamId)
if teamErr != nil {
return "", model.NewAppError("ExecuteDialogAction", "app.team.get.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(teamErr)
}
upstreamRequest.TeamName = team.Name
}
clientTriggerId, _, appErr := upstreamRequest.GenerateTriggerId(a.AsymmetricSigningKey())
if appErr != nil {
return "", appErr
}
requestJSON, err := json.Marshal(upstreamRequest)
if err != nil {
return "", model.NewAppError("ExecuteDialogAction", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
rctx.Logger().Info("ExecuteDialogAction POST request, through DoActionRequest",
mlog.String("url", req.URL),
mlog.String("user_id", userID),
mlog.String("channel_id", req.ChannelId),
mlog.String("team_id", req.TeamId),
)
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
resp, appErr := a.DoActionRequest(rctx.WithContext(timeoutCtx), req.URL, requestJSON)
if appErr != nil {
// DoActionRequest can return a non-nil response together with an error
// (e.g. a non-200 status). Drain and close it so the HTTP connection
// isn't leaked on the error path.
if resp != nil && resp.Body != nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, MaxDialogResponseSize))
_ = resp.Body.Close()
}
return "", appErr
}
defer resp.Body.Close()
// Drain response body to allow HTTP connection reuse
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, MaxDialogResponseSize))
return clientTriggerId, nil
}
func (a *App) LookupInteractiveDialog(rctx request.CTX, request model.SubmitDialogRequest) (*model.LookupDialogResponse, *model.AppError) {
url := request.URL
request.URL = ""
request.Type = "dialog_lookup"
b, err := json.Marshal(request)
if err != nil {
return nil, model.NewAppError("LookupInteractiveDialog", "app.lookup_interactive_dialog.json_error", nil, "", http.StatusBadRequest).Wrap(err)
}
// Log request, regardless of whether destination is internal or external
rctx.Logger().Info("LookupInteractiveDialog POST request, through DoActionRequest",
mlog.String("url", url),
mlog.String("user_id", request.UserId),
mlog.String("channel_id", request.ChannelId),
mlog.String("team_id", request.TeamId),
)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
resp, appErr := a.DoActionRequest(rctx.WithContext(ctx), url, b)
if appErr != nil {
return nil, appErr
}
defer resp.Body.Close()
// Limit response size to prevent OOM attacks
limitedReader := io.LimitReader(resp.Body, MaxDialogResponseSize)
body, err := io.ReadAll(limitedReader)
if err != nil {
return nil, model.NewAppError("LookupInteractiveDialog", "app.lookup_interactive_dialog.read_body_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
var response model.LookupDialogResponse
if len(body) == 0 {
// Return empty response if no data
return &response, nil
}
err = json.Unmarshal(body, &response)
if err != nil {
return nil, model.NewAppError("LookupInteractiveDialog", "app.lookup_interactive_dialog.decode_json_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &response, nil
}