Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
299 lines
8.0 KiB
Go
299 lines
8.0 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.
|
|
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/url"
|
|
)
|
|
|
|
var (
|
|
// ErrMmBlocksActionNotFound is returned when the action id is missing or not an executable mm_blocks action.
|
|
ErrMmBlocksActionNotFound = errors.New("mm_blocks action not found")
|
|
)
|
|
|
|
// MmBlocksActionResolved is the outcome of resolving an mm_blocks action for execution.
|
|
type MmBlocksActionResolved struct {
|
|
OpenURLGoto string
|
|
ExternalURL string
|
|
Context map[string]any
|
|
}
|
|
|
|
const (
|
|
MmBlocksActionTypeExternal = "external"
|
|
MmBlocksActionTypeOpenURL = "openURL"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
spec := &MmBlocksActionSpec{Type: typ}
|
|
switch typ {
|
|
case MmBlocksActionTypeExternal:
|
|
spec.URL, _ = entryMap["url"].(string)
|
|
spec.Context = contextMapFromProp(entryMap["context"])
|
|
spec.Query = stringMapFromPropValue(entryMap["query"])
|
|
return spec
|
|
case MmBlocksActionTypeOpenURL:
|
|
spec.URL, _ = entryMap["url"].(string)
|
|
spec.Query = stringMapFromPropValue(entryMap["query"])
|
|
return spec
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ActionSpec returns the server-side spec for one action id from the cookie actions map.
|
|
func (m *MmBlocksActionCookie) ActionSpec(actionID string) *MmBlocksActionSpec {
|
|
if m == nil || actionID == "" || m.Actions == nil {
|
|
return nil
|
|
}
|
|
entry, ok := m.Actions[actionID]
|
|
if !ok || entry == nil {
|
|
return nil
|
|
}
|
|
entryMap, ok := coerceToStringAnyMap(entry)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return mmBlocksEntryMapToSpec(entryMap)
|
|
}
|
|
|
|
// ResolveMmBlocksAction resolves spec for execution: openURL returns OpenURLGoto; external returns ExternalURL and Context.
|
|
func ResolveMmBlocksAction(spec *MmBlocksActionSpec, actionID string, clientQuery map[string]string) (*MmBlocksActionResolved, error) {
|
|
if spec == nil {
|
|
return nil, fmt.Errorf("mm_blocks action_id=%s: %w", actionID, ErrMmBlocksActionNotFound)
|
|
}
|
|
switch spec.Type {
|
|
case MmBlocksActionTypeOpenURL:
|
|
if spec.URL == "" {
|
|
return nil, fmt.Errorf("mm_blocks action_id=%s: %w", actionID, ErrMmBlocksActionNotFound)
|
|
}
|
|
gotoURL, err := MergeQueryIntoURL(spec.URL, spec.Query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &MmBlocksActionResolved{OpenURLGoto: gotoURL}, nil
|
|
|
|
case MmBlocksActionTypeExternal:
|
|
if spec.URL == "" {
|
|
return nil, fmt.Errorf("mm_blocks action_id=%s: %w", actionID, ErrMmBlocksActionNotFound)
|
|
}
|
|
upstreamURL, err := MergeQueryIntoURL(spec.URL, spec.Query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
upstreamURL, err = MergeQueryIntoURL(upstreamURL, clientQuery)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &MmBlocksActionResolved{ExternalURL: upstreamURL, Context: spec.Context}, nil
|
|
|
|
default:
|
|
return nil, fmt.Errorf("mm_blocks action_id=%s: %w", actionID, ErrMmBlocksActionNotFound)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ParseDecryptedActionCookiePayload unmarshals decrypted cookie JSON from the client.
|
|
// Exactly one of legacy or mmBlocks is non-nil when err is nil (legacy attachment cookie vs mm_blocks cookie).
|
|
func ParseDecryptedActionCookiePayload(decrypted string) (legacy *PostActionCookie, mmBlocks *MmBlocksActionCookie, err error) {
|
|
var probe struct {
|
|
Kind string `json:"kind"`
|
|
}
|
|
if err := json.Unmarshal([]byte(decrypted), &probe); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if probe.Kind == MmBlocksActionCookieKind {
|
|
var mm MmBlocksActionCookie
|
|
if err := json.Unmarshal([]byte(decrypted), &mm); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return nil, &mm, nil
|
|
}
|
|
var c PostActionCookie
|
|
if err := json.Unmarshal([]byte(decrypted), &c); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &c, nil, nil
|
|
}
|
|
|
|
// AddMmBlocksActionCookies encrypts the full mm_blocks_actions map into one cookie string stored in PostPropsMmBlocksActions.
|
|
func AddMmBlocksActionCookies(p *Post, secret []byte) {
|
|
raw := p.GetProp(PostPropsMmBlocksActions)
|
|
if raw == nil {
|
|
return
|
|
}
|
|
actionsTop, ok := coerceToStringAnyMap(raw)
|
|
if !ok || len(actionsTop) == 0 {
|
|
return
|
|
}
|
|
|
|
retainProps := map[string]any{}
|
|
removeProps := []string{}
|
|
for _, key := range PostActionRetainPropKeys {
|
|
value, ok := p.GetProps()[key]
|
|
if ok {
|
|
retainProps[key] = value
|
|
} else {
|
|
removeProps = append(removeProps, key)
|
|
}
|
|
}
|
|
|
|
actionsForEnc := make(map[string]map[string]any, len(actionsTop))
|
|
for actionID, val := range actionsTop {
|
|
entryMap, ok := coerceToStringAnyMap(val)
|
|
if !ok {
|
|
continue
|
|
}
|
|
actionsForEnc[actionID] = entryMap
|
|
}
|
|
|
|
rootPostID := p.Id
|
|
if p.RootId != "" {
|
|
rootPostID = p.RootId
|
|
}
|
|
mmCookie := MmBlocksActionCookie{
|
|
Kind: MmBlocksActionCookieKind,
|
|
PostId: p.Id,
|
|
RootPostId: rootPostID,
|
|
ChannelId: p.ChannelId,
|
|
RetainProps: retainProps,
|
|
RemoveProps: removeProps,
|
|
Actions: actionsForEnc,
|
|
}
|
|
b, err := json.Marshal(mmCookie)
|
|
if err != nil {
|
|
return
|
|
}
|
|
enc, err := EncryptPostActionCookie(string(b), secret)
|
|
if err != nil {
|
|
return
|
|
}
|
|
p.AddProp(PostPropsMmBlocksActions, enc)
|
|
}
|
|
|
|
// StripMmBlocksActionSecrets removes server-only fields from props.mm_blocks_actions for wire serialization.
|
|
func (o *Post) StripMmBlocksActionSecrets() {
|
|
raw := o.GetProp(PostPropsMmBlocksActions)
|
|
if raw == nil {
|
|
return
|
|
}
|
|
if _, ok := raw.(string); ok {
|
|
// Already replaced with a single opaque encrypted cookie by AddMmBlocksActionCookies.
|
|
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
|
|
}
|