Files
team/server/channels/app/integration_action_setup.go
2bb8d01100 Mattermost Blocks (#36338)
* Mattermost Blocks

* Rethink some block props and styles

* Remove obsolete comment

* Style fixes and add examples

* Add mention, search, and image metadata support

* Several UI improvements

* Validate blocks by translating them

* Add initial e2e tests

* Fix lint

* Fix tests and lint

* Fix lint

* Improve e2e and simplify doPostAction

* server side cleanup

* Fix typo

* Fix style

* Fix some tests

* Fix more tests

* Fix cypress tests

* Fix playwhright tests

* Fix flaky system console test

* Frontend cleanup

* Add blocks editor

* Fix CI

* Limit all blocks width

* Fix test

* Enable webhook service on playwhright CI

* fix flaky test

* Fix style

* Cleanups

* Address coderabbit comments

* Address coderabbit comments

* Address CI errors

* Fix tests

* Address changes in valid actions

* UX fixes

* Fix test

* Fix danger buttons and add allow emojis in buttons

* style tweaks

use semantic color tokens for buttons and accents, update max-heights for scollables,  adjustments to containers, removed btn-sm (using default button size for mm blocks with height and padding adjustments for use within blocks. removed hover selector from utils to prevent hover state issue with primary button, refined semantic-color-warning.

* Address feedback

* Add interactive content to post previews

* Add collapse transitions

* Address autocomplete position

* Handle columns within column sets

* Fix css lint

* Fix tests

* Use feature flag

* Address feedback

* Fix lint and add missing tests

* Fix e2e tests

* Fix e2e test

* Allow interactive posts without a message

* Fix lint

* Address feedback

* Fix ci

* Fix lint

* Add limits to block translations

* Fix lint

* Address feedback

* Address feedback

* Update snapshot

* Fix collapsible not defaulting to the right value

* Add total text limit

* Sync translations with mobile

* Fix snapshot

---------

Co-authored-by: Matthew Birtch <[email protected]>
Co-authored-by: Mattermost Build <[email protected]>
2026-06-29 20:33:46 +02:00

504 lines
18 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"errors"
"fmt"
"maps"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// maxMmBlocksActionsCloneDepth caps recursion in cloneMmBlocksActionsProp.
// ValidateMmBlocksActions bounds top-level entry count and key length but
// does not bound nesting depth inside spec.Context — a bot/plugin could
// otherwise stash a pathologically nested object that drives stack
// exhaustion on the restore path. 64 is well past any plausible legitimate
// nesting; deeper input is treated as malicious and truncated.
const maxMmBlocksActionsCloneDepth = 64
// cloneMmBlocksActionsProp deep-clones the post.props.mm_blocks_actions value.
// Each per-action entry can carry nested context / query maps (and arrays
// inside those), so the clone walks the structure recursively — a shallow
// clone at any level would leave nested objects aliased back to the live
// post's props, defeating the restore-after-invalid-response guarantee.
func cloneMmBlocksActionsProp(v any) any {
return cloneMmBlocksActionsPropAt(v, 0)
}
func cloneMmBlocksActionsPropAt(v any, depth int) any {
if depth > maxMmBlocksActionsCloneDepth {
// Defense-in-depth: drop the subtree rather than risk stack
// exhaustion. The restore path that calls this helper is on a
// rare branch (plugin response is invalid), and pathological
// nesting at this depth is not a legitimate use case.
return nil
}
switch typed := v.(type) {
case map[string]any:
out := make(map[string]any, len(typed))
for k, child := range typed {
out[k] = cloneMmBlocksActionsPropAt(child, depth+1)
}
return out
case []any:
out := make([]any, len(typed))
for i, child := range typed {
out[i] = cloneMmBlocksActionsPropAt(child, depth+1)
}
return out
default:
// Scalars (string/number/bool/nil) are immutable — safe to share.
return v
}
}
// postActionSetup holds resolved state for one interactive post action before the upstream HTTP call.
type postActionSetup struct {
upstreamURL string
upstreamRequest *model.PostActionIntegrationRequest
datasource string
retain map[string]any
remove []string
originalProps map[string]any
originalIsPinned bool
originalHasReactions bool
rootPostId string
ephemeralInteractive bool
ephemeralChannelID string
ephemeralRootPostID string
ephemeralSetUserID bool
}
func (a *App) resolvePostActionSetup(
rctx request.CTX,
postID, actionID, userID string,
legacyCookie *model.PostActionCookie,
mmBlocksCookie *model.MmBlocksActionCookie,
clientQuery map[string]string,
integrationFormat string,
) (*postActionSetup, string, *model.AppError) {
upstreamRequest := &model.PostActionIntegrationRequest{
UserId: userID,
PostId: postID,
}
userChan := a.startUpstreamUserFetch(userID)
postChan := a.startUpstreamPostFetch(rctx, postID)
channelChan := a.startUpstreamChannelFetch(postID)
postResult := <-postChan
if postResult.NErr != nil {
setup, gotoURL, appErr := a.resolvePostActionSetupFromCookies(postID, actionID, legacyCookie, mmBlocksCookie, clientQuery, upstreamRequest, postResult)
return a.finishPostActionSetup(setup, gotoURL, appErr, userChan)
}
post := postResult.Data
chResult := <-channelChan
if chResult.NErr != nil {
return nil, "", model.NewAppError("DoPostActionWithCookie", "app.channel.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(chResult.NErr)
}
setup, gotoURL, appErr := a.resolvePostActionSetupFromPost(post, chResult.Data, actionID, clientQuery, integrationFormat, upstreamRequest)
return a.finishPostActionSetup(setup, gotoURL, appErr, userChan)
}
func (a *App) startUpstreamPostFetch(rctx request.CTX, postID string) <-chan store.StoreResult[*model.Post] {
postChan := make(chan store.StoreResult[*model.Post], 1)
go func() {
post, err := a.Srv().Store().Post().GetSingle(rctx, postID, false)
postChan <- store.StoreResult[*model.Post]{Data: post, NErr: err}
close(postChan)
}()
return postChan
}
func (a *App) startUpstreamChannelFetch(postID string) <-chan store.StoreResult[*model.Channel] {
channelChan := make(chan store.StoreResult[*model.Channel], 1)
go func() {
channel, err := a.Srv().Store().Channel().GetForPost(postID)
channelChan <- store.StoreResult[*model.Channel]{Data: channel, NErr: err}
close(channelChan)
}()
return channelChan
}
func (a *App) startUpstreamUserFetch(userID string) <-chan store.StoreResult[*model.User] {
userChan := make(chan store.StoreResult[*model.User], 1)
go func() {
user, err := a.Srv().Store().User().Get(context.Background(), userID)
userChan <- store.StoreResult[*model.User]{Data: user, NErr: err}
close(userChan)
}()
return userChan
}
func (a *App) startUpstreamTeamFetch(teamID string) <-chan store.StoreResult[*model.Team] {
if teamID == "" {
return nil
}
teamChan := make(chan store.StoreResult[*model.Team], 1)
go func() {
defer close(teamChan)
team, err := a.Srv().Store().Team().Get(teamID)
teamChan <- store.StoreResult[*model.Team]{Data: team, NErr: err}
}()
return teamChan
}
func (a *App) populateUpstreamRequestIdentity(
upstreamRequest *model.PostActionIntegrationRequest,
userChan <-chan store.StoreResult[*model.User],
) *model.AppError {
teamChan := a.startUpstreamTeamFetch(upstreamRequest.TeamId)
ur := <-userChan
if ur.NErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(ur.NErr, &nfErr):
return model.NewAppError("DoPostActionWithCookie", MissingAccountError, nil, "", http.StatusNotFound).Wrap(ur.NErr)
default:
return model.NewAppError("DoPostActionWithCookie", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(ur.NErr)
}
}
upstreamRequest.UserName = ur.Data.Username
if teamChan != nil {
if tr, ok := <-teamChan; ok {
if tr.NErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(tr.NErr, &nfErr):
return model.NewAppError("DoPostActionWithCookie", "app.team.get.find.app_error", nil, "", http.StatusNotFound).Wrap(tr.NErr)
default:
return model.NewAppError("DoPostActionWithCookie", "app.team.get.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(tr.NErr)
}
}
upstreamRequest.TeamName = tr.Data.Name
}
}
return nil
}
func (a *App) finishPostActionSetup(
setup *postActionSetup,
gotoURL string,
appErr *model.AppError,
userChan <-chan store.StoreResult[*model.User],
) (*postActionSetup, string, *model.AppError) {
if appErr != nil {
return nil, "", appErr
}
if gotoURL != "" {
return nil, gotoURL, nil
}
if appErr := a.populateUpstreamRequestIdentity(setup.upstreamRequest, userChan); appErr != nil {
return nil, "", appErr
}
return setup, "", nil
}
func (a *App) resolvePostActionSetupFromCookies(
postID, actionID string,
legacyCookie *model.PostActionCookie,
mmBlocksCookie *model.MmBlocksActionCookie,
clientQuery map[string]string,
upstreamRequest *model.PostActionIntegrationRequest,
postResult store.StoreResult[*model.Post],
) (*postActionSetup, string, *model.AppError) {
if legacyCookie == nil && mmBlocksCookie == nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(postResult.NErr, &nfErr):
return nil, "", model.NewAppError("DoPostActionWithCookie", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(postResult.NErr)
default:
return nil, "", model.NewAppError("DoPostActionWithCookie", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(postResult.NErr)
}
}
if legacyCookie != nil && mmBlocksCookie != nil {
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "multiple action cookies set", http.StatusBadRequest)
}
var nfPost *store.ErrNotFound
ephemeral := errors.As(postResult.NErr, &nfPost)
if legacyCookie != nil {
return a.setupFromLegacyCookie(postID, legacyCookie, upstreamRequest, ephemeral)
}
return a.setupFromMmBlocksCookie(postID, actionID, mmBlocksCookie, clientQuery, upstreamRequest, ephemeral)
}
func (a *App) setupFromLegacyCookie(
postID string,
cookie *model.PostActionCookie,
upstreamRequest *model.PostActionIntegrationRequest,
ephemeral bool,
) (*postActionSetup, string, *model.AppError) {
if cookie.Integration == nil {
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "no Integration in action cookie", http.StatusBadRequest)
}
if postID != cookie.PostId {
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "postId doesn't match", http.StatusBadRequest)
}
channel, appErr := a.channelForPostAction(cookie.ChannelId)
if appErr != nil {
return nil, "", appErr
}
fillUpstreamChannel(upstreamRequest, channel, cookie.ChannelId)
upstreamRequest.Type = cookie.Type
// Clone the Context map — later code may add selected_option to it, and
// we must not mutate the shared source.
upstreamRequest.Context = maps.Clone(cookie.Integration.Context)
setup := &postActionSetup{
upstreamURL: cookie.Integration.URL,
upstreamRequest: upstreamRequest,
datasource: cookie.DataSource,
retain: cookie.RetainProps,
remove: cookie.RemoveProps,
rootPostId: cookie.RootPostId,
ephemeralInteractive: ephemeral,
ephemeralChannelID: cookie.ChannelId,
ephemeralRootPostID: cookie.RootPostId,
ephemeralSetUserID: true,
}
return setup, "", nil
}
func (a *App) setupFromMmBlocksCookie(
postID, actionID string,
cookie *model.MmBlocksActionCookie,
clientQuery map[string]string,
upstreamRequest *model.PostActionIntegrationRequest,
ephemeral bool,
) (*postActionSetup, string, *model.AppError) {
if cookie.Kind != model.MmBlocksActionCookieKind {
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "invalid mm_blocks action cookie", http.StatusBadRequest)
}
if postID != cookie.PostId {
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "postId doesn't match", http.StatusBadRequest)
}
channel, appErr := a.channelForPostAction(cookie.ChannelId)
if appErr != nil {
return nil, "", appErr
}
mmSpec := cookie.ActionSpec(actionID)
resolved, err := model.ResolveMmBlocksAction(mmSpec, actionID, clientQuery)
if appErr := mmBlocksResolveAppError(actionID, err); appErr != nil {
return nil, "", appErr
}
if resolved.OpenURLGoto != "" {
return nil, resolved.OpenURLGoto, nil
}
fillUpstreamChannel(upstreamRequest, channel, cookie.ChannelId)
upstreamRequest.Type = model.PostActionTypeButton
upstreamRequest.Context = resolved.Context
setup := &postActionSetup{
upstreamURL: resolved.ExternalURL,
upstreamRequest: upstreamRequest,
retain: cookie.RetainProps,
remove: cookie.RemoveProps,
rootPostId: cookie.RootPostId,
ephemeralInteractive: ephemeral,
ephemeralChannelID: cookie.ChannelId,
ephemeralRootPostID: cookie.RootPostId,
}
return setup, "", nil
}
func (a *App) resolvePostActionSetupFromPost(
post *model.Post,
channel *model.Channel,
actionID string,
clientQuery map[string]string,
integrationFormat string,
upstreamRequest *model.PostActionIntegrationRequest,
) (*postActionSetup, string, *model.AppError) {
fillUpstreamChannel(upstreamRequest, channel, post.ChannelId)
switch model.NormalizePostActionIntegrationFormat(integrationFormat) {
case model.PostActionIntegrationFormatMmBlock,
model.PostActionIntegrationFormatBlock,
model.PostActionIntegrationFormatCard:
if !a.Config().FeatureFlags.MmBlocksEnabled {
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "mm_blocks are not enabled", http.StatusBadRequest)
}
mmSpec := post.GetMmBlocksActionSpec(actionID)
resolved, err := model.ResolveMmBlocksAction(mmSpec, actionID, clientQuery)
if appErr := mmBlocksResolveAppError(actionID, err); appErr != nil {
return nil, "", appErr
}
if resolved.OpenURLGoto != "" {
return nil, resolved.OpenURLGoto, nil
}
preserve := post.PostActionPreserveState()
upstreamRequest.Type = model.PostActionTypeButton
upstreamRequest.Context = resolved.Context
return &postActionSetup{
upstreamURL: resolved.ExternalURL,
upstreamRequest: upstreamRequest,
retain: preserve.Retain,
remove: preserve.Remove,
originalProps: preserve.OriginalProps,
originalIsPinned: preserve.OriginalIsPinned,
originalHasReactions: preserve.OriginalHasReactions,
rootPostId: preserve.RootPostId,
}, "", nil
case model.PostActionIntegrationFormatAttachment:
action := post.GetAction(actionID)
if action == nil || action.Integration == nil {
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound)
}
preserve := post.PostActionPreserveState()
upstreamRequest.Type = action.Type
// Clone the Context map — the action pointer returned from
// post.GetAction may alias post.props state. Mutating it directly
// would leak per-click values (selected_option) into the post's
// cached integration for subsequent clickers.
upstreamRequest.Context = maps.Clone(action.Integration.Context)
return &postActionSetup{
upstreamURL: action.Integration.URL,
upstreamRequest: upstreamRequest,
datasource: action.DataSource,
retain: preserve.Retain,
remove: preserve.Remove,
originalProps: preserve.OriginalProps,
originalIsPinned: preserve.OriginalIsPinned,
originalHasReactions: preserve.OriginalHasReactions,
rootPostId: preserve.RootPostId,
}, "", nil
}
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("integration_context=%s", integrationFormat), http.StatusBadRequest)
}
func fillUpstreamChannel(req *model.PostActionIntegrationRequest, channel *model.Channel, channelID string) {
req.ChannelId = channelID
req.ChannelName = channel.Name
req.TeamId = channel.TeamId
}
func (a *App) channelForPostAction(channelID string) (*model.Channel, *model.AppError) {
channel, err := a.Srv().Store().Channel().Get(channelID, true)
if err != nil {
errCtx := map[string]any{"channel_id": channelID}
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("DoPostActionWithCookie", "app.channel.get.existing.app_error", errCtx, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("DoPostActionWithCookie", "app.channel.get.find.app_error", errCtx, "", http.StatusInternalServerError).Wrap(err)
}
}
return channel, nil
}
func mmBlocksResolveAppError(actionID string, err error) *model.AppError {
if err == nil {
return nil
}
switch {
case errors.Is(err, model.ErrMmBlocksActionNotFound):
return model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("mm_blocks action_id=%s", actionID), http.StatusNotFound)
default:
return model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
}
func (a *App) applyPostActionUpdate(
rctx request.CTX,
setup *postActionSetup,
postID, userID string,
update *model.Post,
) *model.AppError {
update.Id = postID
if update.GetProps() == nil {
switch {
case setup.originalProps != nil:
update.SetProps(setup.originalProps)
case setup.ephemeralInteractive && len(setup.retain) > 0:
props := make(model.StringInterface)
maps.Copy(props, setup.retain)
update.SetProps(props)
default:
update.SetProps(setup.originalProps)
}
} else {
for key, value := range setup.retain {
update.AddProp(key, value)
}
for _, key := range setup.remove {
update.DelProp(key)
}
}
update.IsPinned = setup.originalIsPinned
update.HasReactions = setup.originalHasReactions
// Validate mm_blocks_actions on update responses. Since
// AllowMmBlocksActionsUpdate bypasses the non-integration guard in
// UpdatePost, and mm_blocks_actions are not in PostActionRetainPropKeys,
// a bad response would otherwise permanently replace the post's valid
// mm_blocks_actions. Keep the original value (if any) and log a warning
// so integration authors can diagnose.
if update.GetProp(model.PostPropsMmBlocksActions) != nil {
originalMmBlocks := any(nil)
if setup.originalProps != nil {
originalMmBlocks = setup.originalProps[model.PostPropsMmBlocksActions]
}
if originalMmBlocks == nil {
rctx.Logger().Info("Dropping mm_blocks_actions from plugin update response: original post had none",
mlog.String("post_id", postID),
mlog.String("url", setup.upstreamURL),
)
update.DelProp(model.PostPropsMmBlocksActions)
} else if err := model.ValidateMmBlocksActions(update); err != nil {
rctx.Logger().Info("Restoring original mm_blocks_actions: plugin update response was invalid",
mlog.String("post_id", postID),
mlog.String("url", setup.upstreamURL),
mlog.Err(err),
)
update.AddProp(model.PostPropsMmBlocksActions, cloneMmBlocksActionsProp(originalMmBlocks))
}
}
if setup.ephemeralInteractive {
update.ChannelId = setup.ephemeralChannelID
if update.RootId == "" && setup.ephemeralRootPostID != "" && setup.ephemeralRootPostID != postID {
update.RootId = setup.ephemeralRootPostID
}
if setup.ephemeralSetUserID && update.UserId == "" {
update.UserId = userID
}
a.UpdateEphemeralPost(rctx, userID, update)
return nil
}
if _, _, appErr := a.UpdatePost(rctx, update, &model.UpdatePostOptions{SafeUpdate: false, AllowMmBlocksActionsUpdate: true}); appErr != nil {
return appErr
}
return nil
}