Files
team/server/channels/app/integration_action_setup.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

503 lines
18 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"maps"
"net/http"
"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/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(rctx, 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(rctx request.CTX, userID string) <-chan store.StoreResult[*model.User] {
userChan := make(chan store.StoreResult[*model.User], 1)
go func() {
user, err := a.Srv().Store().User().Get(rctx, 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
}