mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* Add inline action buttons for bot-posted markdown
Bots, webhooks, and plugins can now embed clickable action buttons
inside markdown (including table cells) using mmaction://actionId
links, with row-specific parameters forwarded to the integration on
click. This enables use cases like a per-row "Mx Plan" button in a
fleet-status table that opens a dialog scoped to the clicked row.
Design
- New post prop inline_actions maps actionId (alphanumeric) to a
PostActionIntegration {URL, Context}, capped at 50 entries.
- Markdown link with scheme mmaction:// emits a placeholder span that
messageHtmlToComponent converts to the InlineActionButton component.
- Click POSTs inline_context (parsed from the URL query string) to the
existing /posts/{id}/actions/{action_id} endpoint; the server merges
it into the integration request as context.inline_params while
preserving the post-level context.
- Only bot, webhook, and plugin posts render the button; non-integration
posts have inline_actions stripped on create, update, and ephemeral
broadcast. Hardened-mode also covers the new prop.
- Reuses the existing PostAction dialog pipeline: plugin handlers reply
with a trigger_id and call /actions/dialogs/open as before.
Security
- InlineContext capped at 50 entries / 128-char keys / 2 KB values.
- Integration Context cloned per click so per-click inline_params and
selected_option cannot leak into the cached post for other clickers.
- Plugin response updates cannot add inline_actions to a post that did
not already have them; invalid entries are dropped with a warn log.
- Label content and data attributes are escaped; labels are flattened
to plain text (tags stripped, entities decoded, then escaped).
- Malformed JSON request bodies now return 400 instead of falling
through with an empty inline_context.
Tests
- Model: validators, normalization, GetInlineAction, strip, fallback.
- App: create strip, update guard (4 subtests including
AllowInlineActionsUpdate bypass), ephemeral strip, inline_params
merge, context-map isolation, plugin-response guards, from_bot and
from_plugin retention across plugin updates.
- API: inline_context validation (size bounds + error id),
omitempty backward compat, malformed JSON 400.
- Webapp: renderer scheme handling, allow/deny flags, size caps,
HTML escape, tag strip, entity decode, attribute-injection defense;
component click dispatch, double-click race guard, unmount safety,
error-result recovery, aria state.
* lint fix
* i18n-extract
* Review fixes for inline action buttons
- renderer: preserve actionId case; reject opaque mmaction: URI
- app: require bot AND integration session to preserve inline_actions
- app: restore original inline_actions when plugin response is invalid
- i18n: rename key to ...app_error to match convention
* Tighten UpdatePost inline_actions guard; fix test seeds
- app: UpdatePost now requires AllowInlineActionsUpdate to modify
inline_actions. Integration session alone is insufficient — a
PAT-wielding user could otherwise inject inline_actions on any
post they could edit.
- tests: seed bot posts with inline_actions via an integration
session (intSeedCtx) so they survive the create-time strip.
- renderer: lint fix (blank line before comment block).
* Reject malformed inline-action authorities at render time
- renderer: enforce ^[A-Za-z0-9]+$ on actionId, mirroring the server
regex. Authorities like mmaction://plan:443 or mmaction://user@plan
now fall through to plain text instead of rendering a dead button.
- post: clarify in the strip comment that webhooks and plugins bypass
CreatePostAsUser entirely (they call CreatePost / CreatePostMissingChannel
directly), so the strip block does not apply to them.
* Tighten inline-action renderer tests
- Replace oversized-params test with boundary pair (at-cap and over-cap)
to lock in the > vs >= behavior of the size-limit check.
- Add a "surrounding text survives" assertion for the tag-strip path so
a future swap from regex strip to a DOM sanitizer won't silently
drop legitimate content along with tags.
* Inline action buttons via mmaction:// markdown links
Adds inline action buttons rendered from mmaction:// links in markdown,
with the click pipeline reusing the existing post-action infrastructure.
Aligned with the broader mm_blocks_actions framework (Daniel's PR).
* fix lint, DoS hardening, fix and rename test
* Address review feedback
* lint fix
* Reject percent-encoded path traversal in validateIntegrationURL (e.g. %2e%2e%2f) by parsing the URL and checking the decoded path.
---------
Co-authored-by: Mattermost Build <[email protected]>
Co-authored-by: Hanzo Dev <[email protected]>
155 lines
4.1 KiB
Go
155 lines
4.1 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
// Server-side definitions for the post.props.mm_blocks_actions registry that
|
|
// underpins the markdown-actions feature. Mirrors the canonical model
|
|
// landing in the broader mm_blocks framework PR; cookie transport
|
|
// (MmBlocksActionCookie, AddMmBlocksActionCookies, ParseDecryptedActionCookiePayload)
|
|
// is intentionally omitted here and will be filled in by that PR. Until then,
|
|
// mm_blocks_actions is resolved on click via DB lookup
|
|
// (GetMmBlocksActionSpec) and stripped from ephemeral broadcasts so dead
|
|
// buttons don't render.
|
|
|
|
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"net/url"
|
|
)
|
|
|
|
const (
|
|
MmBlocksActionTypeExternal = "external"
|
|
)
|
|
|
|
// MmBlocksActionSpec is the server-side definition for one entry in props.mm_blocks_actions.
|
|
type MmBlocksActionSpec struct {
|
|
Type string
|
|
URL string
|
|
Query map[string]string
|
|
Context map[string]any
|
|
}
|
|
|
|
// GetMmBlocksActionSpec returns the action definition for actionID from props.mm_blocks_actions, if present.
|
|
func (o *Post) GetMmBlocksActionSpec(actionID string) *MmBlocksActionSpec {
|
|
raw := o.GetProp(PostPropsMmBlocksActions)
|
|
if raw == nil || actionID == "" {
|
|
return nil
|
|
}
|
|
actionsTop, ok := coerceToStringAnyMap(raw)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
entry, ok := actionsTop[actionID]
|
|
if !ok || entry == nil {
|
|
return nil
|
|
}
|
|
entryMap, ok := coerceToStringAnyMap(entry)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return mmBlocksEntryMapToSpec(entryMap)
|
|
}
|
|
|
|
// mmBlocksEntryMapToSpec maps one props.mm_blocks_actions[actionID] object to MmBlocksActionSpec.
|
|
func mmBlocksEntryMapToSpec(entryMap map[string]any) *MmBlocksActionSpec {
|
|
typ, _ := entryMap["type"].(string)
|
|
if typ == "" {
|
|
return nil
|
|
}
|
|
if typ != MmBlocksActionTypeExternal {
|
|
return nil
|
|
}
|
|
spec := &MmBlocksActionSpec{Type: typ}
|
|
spec.URL, _ = entryMap["url"].(string)
|
|
spec.Context = contextMapFromProp(entryMap["context"])
|
|
spec.Query = stringMapFromPropValue(entryMap["query"])
|
|
return spec
|
|
}
|
|
|
|
// MmBlocksContextMap parses a context JSON string or treats a non-JSON string as a single context value.
|
|
func MmBlocksContextMap(contextString string) map[string]any {
|
|
if contextString == "" {
|
|
return nil
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(contextString), &m); err == nil && m != nil {
|
|
return m
|
|
}
|
|
return map[string]any{"context": contextString}
|
|
}
|
|
|
|
// MergeQueryIntoURL merges q into rawURL's query string; existing keys are overwritten by q.
|
|
func MergeQueryIntoURL(rawURL string, q map[string]string) (string, error) {
|
|
if len(q) == 0 {
|
|
return rawURL, nil
|
|
}
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse url: %w", err)
|
|
}
|
|
values := u.Query()
|
|
for k, v := range q {
|
|
values.Set(k, v)
|
|
}
|
|
u.RawQuery = values.Encode()
|
|
return u.String(), nil
|
|
}
|
|
|
|
// StripMmBlocksActionSecrets removes server-only fields from
|
|
// props.mm_blocks_actions for wire serialization. The current
|
|
// implementation deletes the prop wholesale; the cookie-transport PR will
|
|
// extend this to preserve encrypted-string cookie payloads in place.
|
|
func (o *Post) StripMmBlocksActionSecrets() {
|
|
if o.GetProp(PostPropsMmBlocksActions) == nil {
|
|
return
|
|
}
|
|
o.DelProp(PostPropsMmBlocksActions)
|
|
}
|
|
|
|
// contextMapFromProp normalizes props.mm_blocks_actions[*].context to map[string]any (JSON object or string).
|
|
func contextMapFromProp(v any) map[string]any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return MmBlocksContextMap(s)
|
|
}
|
|
if m, ok := coerceToStringAnyMap(v); ok {
|
|
// Clone so callers cannot mutate the live post.Props map. A
|
|
// nested mutation through the returned map would otherwise race
|
|
// with concurrent post.Props readers.
|
|
return maps.Clone(m)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stringMapFromPropValue(v any) map[string]string {
|
|
m, ok := coerceToStringAnyMap(v)
|
|
if !ok || len(m) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(m))
|
|
for k, val := range m {
|
|
if s, ok := val.(string); ok {
|
|
out[k] = s
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func coerceToStringAnyMap(v any) (map[string]any, bool) {
|
|
if v == nil {
|
|
return nil, false
|
|
}
|
|
m, ok := v.(map[string]any)
|
|
if ok {
|
|
return m, true
|
|
}
|
|
return nil, false
|
|
}
|