Full Mattermost server source with integrated Community Enterprise features. Includes vendor directory for offline/air-gapped builds. Structure: - enterprise-impl/: Enterprise feature implementations - enterprise-community/: Init files that register implementations - enterprise/: Bridge imports (community_imports.go) - vendor/: All dependencies for offline builds Build (online): go build ./cmd/mattermost Build (offline/air-gapped): go build -mod=vendor ./cmd/mattermost 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
959 lines
31 KiB
Go
959 lines
31 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package model
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/ecdsa"
|
|
"crypto/rand"
|
|
"encoding/asn1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
PostActionTypeButton = "button"
|
|
PostActionTypeSelect = "select"
|
|
DialogTitleMaxLength = 24
|
|
DialogElementDisplayNameMaxLength = 24
|
|
DialogElementNameMaxLength = 300
|
|
DialogElementHelpTextMaxLength = 150
|
|
DialogElementTextMaxLength = 150
|
|
DialogElementTextareaMaxLength = 3000
|
|
DialogElementSelectMaxLength = 3000
|
|
DialogElementBoolMaxLength = 150
|
|
DefaultTimeIntervalMinutes = 60 // Default time interval for DateTime fields
|
|
|
|
// Go date/time format constants
|
|
ISODateFormat = "2006-01-02" // YYYY-MM-DD
|
|
ISODateTimeFormat = "2006-01-02T15:04:05Z" // RFC3339 UTC
|
|
ISODateTimeWithTimezoneFormat = "2006-01-02T15:04:05-07:00" // RFC3339 with timezone
|
|
ISODateTimeNoTimezoneFormat = "2006-01-02T15:04:05" // ISO datetime without timezone
|
|
ISODateTimeNoSecondsFormat = "2006-01-02T15:04" // ISO datetime without seconds
|
|
)
|
|
|
|
// Common datetime formats used by both date and datetime validation
|
|
var commonDateTimeFormats = []string{
|
|
ISODateTimeFormat, // RFC3339 UTC
|
|
ISODateTimeWithTimezoneFormat, // RFC3339 with timezone
|
|
ISODateTimeNoTimezoneFormat, // ISO datetime without timezone
|
|
ISODateTimeNoSecondsFormat, // ISO datetime without seconds
|
|
}
|
|
|
|
var PostActionRetainPropKeys = []string{PostPropsFromWebhook, PostPropsOverrideUsername, PostPropsOverrideIconURL}
|
|
|
|
type DoPostActionRequest struct {
|
|
SelectedOption string `json:"selected_option,omitempty"`
|
|
Cookie string `json:"cookie,omitempty"`
|
|
}
|
|
|
|
const (
|
|
PostActionDataSourceUsers = "users"
|
|
PostActionDataSourceChannels = "channels"
|
|
)
|
|
|
|
type PostAction struct {
|
|
// A unique Action ID. If not set, generated automatically.
|
|
Id string `json:"id,omitempty"`
|
|
|
|
// The type of the interactive element. Currently supported are
|
|
// "select" and "button".
|
|
Type string `json:"type,omitempty"`
|
|
|
|
// The text on the button, or in the select placeholder.
|
|
Name string `json:"name,omitempty"`
|
|
|
|
// If the action is disabled.
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
|
|
// Style defines a text and border style.
|
|
// Supported values are "default", "primary", "success", "good", "warning", "danger"
|
|
// and any hex color.
|
|
Style string `json:"style,omitempty"`
|
|
|
|
// DataSource indicates the data source for the select action. If left
|
|
// empty, the select is populated from Options. Other supported values
|
|
// are "users" and "channels".
|
|
DataSource string `json:"data_source,omitempty"`
|
|
|
|
// Options contains the values listed in a select dropdown on the post.
|
|
Options []*PostActionOptions `json:"options,omitempty"`
|
|
|
|
// DefaultOption contains the option, if any, that will appear as the
|
|
// default selection in a select box. It has no effect when used with
|
|
// other types of actions.
|
|
DefaultOption string `json:"default_option,omitempty"`
|
|
|
|
// Defines the interaction with the backend upon a user action.
|
|
// Integration contains Context, which is private plugin data;
|
|
// Integrations are stripped from Posts when they are sent to the
|
|
// client, or are encrypted in a Cookie.
|
|
Integration *PostActionIntegration `json:"integration,omitempty"`
|
|
Cookie string `json:"cookie,omitempty" db:"-"`
|
|
}
|
|
|
|
// IsValid validates the action and returns an error if it is invalid.
|
|
func (p *PostAction) IsValid() error {
|
|
var multiErr *multierror.Error
|
|
|
|
if p.Name == "" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have a name"))
|
|
}
|
|
|
|
if p.Style != "" {
|
|
validStyles := []string{"default", "primary", "success", "good", "warning", "danger"}
|
|
// If not a predefined style, check if it's a hex color
|
|
if !slices.Contains(validStyles, p.Style) && !hexColorRegex.MatchString(p.Style) {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid style '%s' - must be one of [default, primary, success, good, warning, danger] or a hex color", p.Style))
|
|
}
|
|
}
|
|
|
|
switch p.Type {
|
|
case PostActionTypeButton:
|
|
if len(p.Options) > 0 {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("button action must not have options"))
|
|
}
|
|
if p.DataSource != "" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("button action must not have a data source"))
|
|
}
|
|
case PostActionTypeSelect:
|
|
if p.DataSource != "" {
|
|
validSources := []string{PostActionDataSourceUsers, PostActionDataSourceChannels}
|
|
if !slices.Contains(validSources, p.DataSource) {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid data_source '%s' for select action", p.DataSource))
|
|
}
|
|
|
|
if len(p.Options) > 0 {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("select action cannot have both DataSource and Options set"))
|
|
}
|
|
} else {
|
|
if len(p.Options) == 0 {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("select action must have either DataSource or Options set"))
|
|
} else {
|
|
for i, opt := range p.Options {
|
|
if opt == nil {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("select action contains nil option"))
|
|
continue
|
|
}
|
|
if err := opt.IsValid(); err != nil {
|
|
multiErr = multierror.Append(multiErr, multierror.Prefix(err, fmt.Sprintf("option at index %d is invalid:", i)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid action type: must be '%s' or '%s'", PostActionTypeButton, PostActionTypeSelect))
|
|
}
|
|
|
|
if p.Integration == nil {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have integration settings"))
|
|
} else {
|
|
if p.Integration.URL == "" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have an integration URL"))
|
|
}
|
|
if !(strings.HasPrefix(p.Integration.URL, "/plugins/") || strings.HasPrefix(p.Integration.URL, "plugins/") || IsValidHTTPURL(p.Integration.URL)) {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have an valid integration URL"))
|
|
}
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
func (p *PostAction) Equals(input *PostAction) bool {
|
|
if p.Id != input.Id {
|
|
return false
|
|
}
|
|
|
|
if p.Type != input.Type {
|
|
return false
|
|
}
|
|
|
|
if p.Name != input.Name {
|
|
return false
|
|
}
|
|
|
|
if p.DataSource != input.DataSource {
|
|
return false
|
|
}
|
|
|
|
if p.DefaultOption != input.DefaultOption {
|
|
return false
|
|
}
|
|
|
|
if p.Cookie != input.Cookie {
|
|
return false
|
|
}
|
|
|
|
// Compare PostActionOptions
|
|
if len(p.Options) != len(input.Options) {
|
|
return false
|
|
}
|
|
|
|
for k := range p.Options {
|
|
if p.Options[k].Text != input.Options[k].Text {
|
|
return false
|
|
}
|
|
|
|
if p.Options[k].Value != input.Options[k].Value {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Compare PostActionIntegration
|
|
|
|
// If input is nil, then return true if original is also nil.
|
|
// Else return false.
|
|
if input.Integration == nil {
|
|
return p.Integration == nil
|
|
}
|
|
|
|
// At this point, input is not nil, so return false if original is.
|
|
if p.Integration == nil {
|
|
return false
|
|
}
|
|
|
|
// Both are unequal and not nil.
|
|
if p.Integration.URL != input.Integration.URL {
|
|
return false
|
|
}
|
|
|
|
if len(p.Integration.Context) != len(input.Integration.Context) {
|
|
return false
|
|
}
|
|
|
|
for key, value := range p.Integration.Context {
|
|
inputValue, ok := input.Integration.Context[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
switch inputValue.(type) {
|
|
case string, bool, int, float64:
|
|
if value != inputValue {
|
|
return false
|
|
}
|
|
default:
|
|
if !reflect.DeepEqual(value, inputValue) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// PostActionCookie is set by the server, serialized and encrypted into
|
|
// PostAction.Cookie. The clients should hold on to it, and include it with
|
|
// subsequent DoPostAction requests. This allows the server to access the
|
|
// action metadata even when it's not available in the database, for ephemeral
|
|
// posts.
|
|
type PostActionCookie struct {
|
|
Type string `json:"type,omitempty"`
|
|
PostId string `json:"post_id,omitempty"`
|
|
RootPostId string `json:"root_post_id,omitempty"`
|
|
ChannelId string `json:"channel_id,omitempty"`
|
|
DataSource string `json:"data_source,omitempty"`
|
|
Integration *PostActionIntegration `json:"integration,omitempty"`
|
|
RetainProps map[string]any `json:"retain_props,omitempty"`
|
|
RemoveProps []string `json:"remove_props,omitempty"`
|
|
}
|
|
|
|
type PostActionOptions struct {
|
|
Text string `json:"text"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
func (o *PostActionOptions) IsValid() error {
|
|
var multiErr *multierror.Error
|
|
|
|
if o.Text == "" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("text is required"))
|
|
}
|
|
if o.Value == "" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("value is required"))
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
type PostActionIntegration struct {
|
|
// URL is the endpoint that the action will be sent to.
|
|
// It can be a relative path to a plugin.
|
|
URL string `json:"url,omitempty"`
|
|
Context map[string]any `json:"context,omitempty"`
|
|
}
|
|
|
|
type PostActionIntegrationRequest struct {
|
|
UserId string `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
ChannelId string `json:"channel_id"`
|
|
ChannelName string `json:"channel_name"`
|
|
TeamId string `json:"team_id"`
|
|
TeamName string `json:"team_domain"`
|
|
PostId string `json:"post_id"`
|
|
TriggerId string `json:"trigger_id"`
|
|
Type string `json:"type"`
|
|
DataSource string `json:"data_source"`
|
|
Context map[string]any `json:"context,omitempty"`
|
|
}
|
|
|
|
type PostActionIntegrationResponse struct {
|
|
Update *Post `json:"update"`
|
|
EphemeralText string `json:"ephemeral_text"`
|
|
SkipSlackParsing bool `json:"skip_slack_parsing"` // Set to `true` to skip the Slack-compatibility handling of Text.
|
|
}
|
|
|
|
type PostActionAPIResponse struct {
|
|
Status string `json:"status"` // needed to maintain backwards compatibility
|
|
TriggerId string `json:"trigger_id"`
|
|
}
|
|
|
|
type Dialog struct {
|
|
CallbackId string `json:"callback_id"`
|
|
Title string `json:"title"`
|
|
IntroductionText string `json:"introduction_text"`
|
|
IconURL string `json:"icon_url"`
|
|
Elements []DialogElement `json:"elements"`
|
|
SubmitLabel string `json:"submit_label"`
|
|
NotifyOnCancel bool `json:"notify_on_cancel"`
|
|
State string `json:"state"`
|
|
SourceURL string `json:"source_url,omitempty"`
|
|
}
|
|
|
|
type DialogElement struct {
|
|
DisplayName string `json:"display_name"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
SubType string `json:"subtype"`
|
|
Default string `json:"default"`
|
|
Placeholder string `json:"placeholder"`
|
|
HelpText string `json:"help_text"`
|
|
Optional bool `json:"optional"`
|
|
MinLength int `json:"min_length"`
|
|
MaxLength int `json:"max_length"`
|
|
DataSource string `json:"data_source"`
|
|
DataSourceURL string `json:"data_source_url,omitempty"`
|
|
Options []*PostActionOptions `json:"options"`
|
|
MultiSelect bool `json:"multiselect"`
|
|
Refresh bool `json:"refresh,omitempty"`
|
|
// Date/datetime field specific properties
|
|
MinDate string `json:"min_date,omitempty"`
|
|
MaxDate string `json:"max_date,omitempty"`
|
|
TimeInterval int `json:"time_interval,omitempty"`
|
|
}
|
|
|
|
type OpenDialogRequest struct {
|
|
TriggerId string `json:"trigger_id"`
|
|
URL string `json:"url"`
|
|
Dialog Dialog `json:"dialog"`
|
|
}
|
|
|
|
type SubmitDialogRequest struct {
|
|
Type string `json:"type"`
|
|
URL string `json:"url,omitempty"`
|
|
CallbackId string `json:"callback_id"`
|
|
State string `json:"state"`
|
|
UserId string `json:"user_id"`
|
|
ChannelId string `json:"channel_id"`
|
|
TeamId string `json:"team_id"`
|
|
Submission map[string]any `json:"submission"`
|
|
Cancelled bool `json:"cancelled"`
|
|
}
|
|
|
|
type SubmitDialogResponseType string
|
|
|
|
const (
|
|
SubmitDialogResponseTypeEmpty SubmitDialogResponseType = ""
|
|
SubmitDialogResponseTypeOK SubmitDialogResponseType = "ok"
|
|
SubmitDialogResponseTypeForm SubmitDialogResponseType = "form"
|
|
SubmitDialogResponseTypeNavigate SubmitDialogResponseType = "navigate"
|
|
)
|
|
|
|
type SubmitDialogResponse struct {
|
|
Error string `json:"error,omitempty"`
|
|
Errors map[string]string `json:"errors,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Form *Dialog `json:"form,omitempty"`
|
|
}
|
|
|
|
func (r *SubmitDialogResponse) IsValid() error {
|
|
// If Error or Errors are set, this is valid and everything else is ignored
|
|
if r.Error != "" || len(r.Errors) > 0 {
|
|
return nil
|
|
}
|
|
|
|
// Validate Type field and handle Form field appropriately for each type
|
|
switch SubmitDialogResponseType(r.Type) {
|
|
case SubmitDialogResponseTypeEmpty, SubmitDialogResponseTypeOK, SubmitDialogResponseTypeNavigate:
|
|
// Completion types - Form field should be nil
|
|
if r.Form != nil {
|
|
return errors.Errorf("form field must be nil for type %q", r.Type)
|
|
}
|
|
case SubmitDialogResponseTypeForm:
|
|
// Continuation type - Form field is required and must be valid
|
|
if r.Form == nil {
|
|
return errors.New("form field is required for form type")
|
|
}
|
|
if err := r.Form.IsValid(); err != nil {
|
|
return errors.Wrap(err, "invalid form")
|
|
}
|
|
default:
|
|
return errors.Errorf("invalid type %q, must be one of: empty, ok, form, navigate", r.Type)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DialogSelectOption represents an option in a select dropdown for dialogs
|
|
type DialogSelectOption struct {
|
|
Text string `json:"text"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// LookupDialogResponse represents the response for a lookup dialog request.
|
|
type LookupDialogResponse struct {
|
|
Items []DialogSelectOption `json:"items"`
|
|
}
|
|
|
|
// signForGenerateTriggerId wraps the signing operation with panic recovery
|
|
// to handle invalid signers that may cause panics in the crypto package
|
|
func signForGenerateTriggerId(s crypto.Signer, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
signature = nil
|
|
err = fmt.Errorf("invalid signing key: %v", r)
|
|
}
|
|
}()
|
|
|
|
return s.Sign(rand.Reader, digest, opts)
|
|
}
|
|
|
|
func GenerateTriggerId(userId string, s crypto.Signer) (string, string, *AppError) {
|
|
clientTriggerId := NewId()
|
|
triggerData := strings.Join([]string{clientTriggerId, userId, strconv.FormatInt(GetMillis(), 10)}, ":") + ":"
|
|
|
|
h := crypto.SHA256
|
|
sum := h.New()
|
|
sum.Write([]byte(triggerData))
|
|
signature, err := signForGenerateTriggerId(s, sum.Sum(nil), h)
|
|
if err != nil {
|
|
return "", "", NewAppError("GenerateTriggerId", "interactive_message.generate_trigger_id.signing_failed", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
base64Sig := base64.StdEncoding.EncodeToString(signature)
|
|
|
|
triggerId := base64.StdEncoding.EncodeToString([]byte(triggerData + base64Sig))
|
|
return clientTriggerId, triggerId, nil
|
|
}
|
|
|
|
func (r *PostActionIntegrationRequest) GenerateTriggerId(s crypto.Signer) (string, string, *AppError) {
|
|
clientTriggerId, triggerId, appErr := GenerateTriggerId(r.UserId, s)
|
|
if appErr != nil {
|
|
return "", "", appErr
|
|
}
|
|
|
|
r.TriggerId = triggerId
|
|
return clientTriggerId, triggerId, nil
|
|
}
|
|
|
|
func DecodeAndVerifyTriggerId(triggerId string, s *ecdsa.PrivateKey, timeout time.Duration) (string, string, *AppError) {
|
|
triggerIdBytes, err := base64.StdEncoding.DecodeString(triggerId)
|
|
if err != nil {
|
|
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed", nil, "", http.StatusBadRequest).Wrap(err)
|
|
}
|
|
|
|
split := strings.Split(string(triggerIdBytes), ":")
|
|
if len(split) != 4 {
|
|
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.missing_data", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
clientTriggerId := split[0]
|
|
userId := split[1]
|
|
timestampStr := split[2]
|
|
timestamp, _ := strconv.ParseInt(timestampStr, 10, 64)
|
|
|
|
if time.Since(time.UnixMilli(timestamp)) > timeout {
|
|
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.expired", map[string]any{"Duration": timeout.String()}, "", http.StatusBadRequest)
|
|
}
|
|
|
|
signature, err := base64.StdEncoding.DecodeString(split[3])
|
|
if err != nil {
|
|
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed_signature", nil, "", http.StatusBadRequest).Wrap(err)
|
|
}
|
|
|
|
var esig struct {
|
|
R, S *big.Int
|
|
}
|
|
|
|
if _, err := asn1.Unmarshal(signature, &esig); err != nil {
|
|
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.signature_decode_failed", nil, "", http.StatusBadRequest).Wrap(err)
|
|
}
|
|
|
|
triggerData := strings.Join([]string{clientTriggerId, userId, timestampStr}, ":") + ":"
|
|
|
|
h := crypto.SHA256
|
|
sum := h.New()
|
|
sum.Write([]byte(triggerData))
|
|
|
|
if !ecdsa.Verify(&s.PublicKey, sum.Sum(nil), esig.R, esig.S) {
|
|
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.verify_signature_failed", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
return clientTriggerId, userId, nil
|
|
}
|
|
|
|
func (r *OpenDialogRequest) DecodeAndVerifyTriggerId(s *ecdsa.PrivateKey, timeout time.Duration) (string, string, *AppError) {
|
|
return DecodeAndVerifyTriggerId(r.TriggerId, s, timeout)
|
|
}
|
|
|
|
func (r *OpenDialogRequest) IsValid() error {
|
|
var multiErr *multierror.Error
|
|
if r.URL == "" {
|
|
multiErr = multierror.Append(multiErr, errors.New("empty URL"))
|
|
}
|
|
|
|
if r.TriggerId == "" {
|
|
multiErr = multierror.Append(multiErr, errors.New("empty trigger id"))
|
|
}
|
|
|
|
err := r.Dialog.IsValid()
|
|
if err != nil {
|
|
multiErr = multierror.Append(multiErr, err)
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
func (d *Dialog) IsValid() error {
|
|
var multiErr *multierror.Error
|
|
|
|
if d.Title == "" || len(d.Title) > DialogTitleMaxLength {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("invalid dialog title %q", d.Title))
|
|
}
|
|
|
|
if d.IconURL != "" && !IsValidHTTPURL(d.IconURL) {
|
|
multiErr = multierror.Append(multiErr, errors.New("invalid icon url"))
|
|
}
|
|
|
|
if len(d.Elements) != 0 {
|
|
elementMap := make(map[string]bool)
|
|
|
|
for _, element := range d.Elements {
|
|
if elementMap[element.Name] {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("duplicate dialog element %q", element.Name))
|
|
}
|
|
elementMap[element.Name] = true
|
|
|
|
err := element.IsValid()
|
|
if err != nil {
|
|
multiErr = multierror.Append(multiErr, errors.Wrapf(err, "%q field is not valid", element.Name))
|
|
}
|
|
}
|
|
}
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
func (e *DialogElement) IsValid() error {
|
|
var multiErr *multierror.Error
|
|
textSubTypes := map[string]bool{
|
|
"": true,
|
|
"text": true,
|
|
"email": true,
|
|
"number": true,
|
|
"tel": true,
|
|
"url": true,
|
|
"password": true,
|
|
}
|
|
|
|
if e.MinLength < 0 {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("min length cannot be a negative number, got %d", e.MinLength))
|
|
}
|
|
if e.MinLength > e.MaxLength {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("min length should be less then max length, got %d > %d", e.MinLength, e.MaxLength))
|
|
}
|
|
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("DisplayName", e.DisplayName, DialogElementDisplayNameMaxLength))
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Name", e.Name, DialogElementNameMaxLength))
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("HelpText", e.HelpText, DialogElementHelpTextMaxLength))
|
|
|
|
if e.MultiSelect && e.Type != "select" {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("multiselect can only be used with select elements, got type %q", e.Type))
|
|
}
|
|
|
|
switch e.Type {
|
|
case "text":
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextMaxLength))
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextMaxLength))
|
|
if _, ok := textSubTypes[e.SubType]; !ok {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("invalid subtype %q", e.Type))
|
|
}
|
|
|
|
case "textarea":
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextareaMaxLength))
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextareaMaxLength))
|
|
|
|
if _, ok := textSubTypes[e.SubType]; !ok {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("invalid subtype %q", e.Type))
|
|
}
|
|
|
|
case "select":
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementSelectMaxLength))
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementSelectMaxLength))
|
|
if e.DataSource != "" && e.DataSource != "users" && e.DataSource != "channels" && e.DataSource != "dynamic" {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("invalid data source %q, allowed are 'users', 'channels', or 'dynamic'", e.DataSource))
|
|
}
|
|
if e.DataSource == "dynamic" {
|
|
// Dynamic selects should have a data_source_url
|
|
if e.DataSourceURL == "" {
|
|
multiErr = multierror.Append(multiErr, errors.New("dynamic data_source requires data_source_url"))
|
|
} else if !IsValidLookupURL(e.DataSourceURL) {
|
|
multiErr = multierror.Append(multiErr, errors.New("invalid data_source_url for dynamic select"))
|
|
}
|
|
// Dynamic selects should not have static options
|
|
if len(e.Options) > 0 {
|
|
multiErr = multierror.Append(multiErr, errors.New("dynamic select element should not have static options"))
|
|
}
|
|
} else if e.DataSource == "" {
|
|
if e.MultiSelect {
|
|
if !isMultiSelectDefaultInOptions(e.Default, e.Options) {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("multiselect default value %q contains values not in options", e.Default))
|
|
}
|
|
} else if !isDefaultInOptions(e.Default, e.Options) {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("default value %q doesn't exist in options ", e.Default))
|
|
}
|
|
}
|
|
|
|
case "bool":
|
|
if e.Default != "" && e.Default != "true" && e.Default != "false" {
|
|
multiErr = multierror.Append(multiErr, errors.New("invalid default of bool"))
|
|
}
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementBoolMaxLength))
|
|
|
|
case "radio":
|
|
if !isDefaultInOptions(e.Default, e.Options) {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("default value %q doesn't exist in options ", e.Default))
|
|
}
|
|
|
|
case "date":
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextMaxLength))
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextMaxLength))
|
|
multiErr = multierror.Append(multiErr, validateDateFormat(e.Default))
|
|
multiErr = multierror.Append(multiErr, validateDateFormat(e.MinDate))
|
|
multiErr = multierror.Append(multiErr, validateDateFormat(e.MaxDate))
|
|
|
|
case "datetime":
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextMaxLength))
|
|
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextMaxLength))
|
|
multiErr = multierror.Append(multiErr, validateDateTimeFormat(e.Default))
|
|
multiErr = multierror.Append(multiErr, validateDateFormat(e.MinDate))
|
|
multiErr = multierror.Append(multiErr, validateDateFormat(e.MaxDate))
|
|
// Validate time_interval for datetime fields
|
|
timeInterval := e.TimeInterval
|
|
if timeInterval == 0 {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("time_interval of 0 will be reset to default, %d minutes", DefaultTimeIntervalMinutes))
|
|
} else if timeInterval < 1 || timeInterval > 1440 {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("time_interval must be between 1 and 1440 minutes, got %d", timeInterval))
|
|
} else if 1440%timeInterval != 0 {
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("time_interval must be a divisor of 1440 (24 hours * 60 minutes) to create valid time intervals, got %d", timeInterval))
|
|
}
|
|
|
|
default:
|
|
multiErr = multierror.Append(multiErr, errors.Errorf("invalid element type: %q", e.Type))
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
func isDefaultInOptions(defaultValue string, options []*PostActionOptions) bool {
|
|
if defaultValue == "" {
|
|
return true
|
|
}
|
|
|
|
for _, option := range options {
|
|
if option != nil && defaultValue == option.Value {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func isMultiSelectDefaultInOptions(defaultValue string, options []*PostActionOptions) bool {
|
|
if defaultValue == "" {
|
|
return true
|
|
}
|
|
|
|
for value := range strings.SplitSeq(strings.ReplaceAll(defaultValue, " ", ""), ",") {
|
|
if value == "" {
|
|
continue
|
|
}
|
|
found := false
|
|
for _, option := range options {
|
|
if option != nil && value == option.Value {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// validateRelativePattern validates relative date patterns like +1d, +2w, +1m
|
|
func validateRelativePattern(value string) bool {
|
|
if len(value) < 3 || len(value) > 5 || (value[0] != '+' && value[0] != '-') {
|
|
return false
|
|
}
|
|
|
|
lastChar := strings.ToLower(string(value[len(value)-1]))
|
|
if !strings.Contains("dwm", lastChar) {
|
|
return false
|
|
}
|
|
|
|
numberPart := value[1 : len(value)-1]
|
|
_, err := strconv.Atoi(numberPart)
|
|
return err == nil
|
|
}
|
|
|
|
// isValidRelativeFormat checks if a string matches relative date patterns
|
|
func isValidRelativeFormat(value string) bool {
|
|
relativeFormats := []string{"today", "tomorrow", "yesterday"}
|
|
return slices.Contains(relativeFormats, value) || validateRelativePattern(value)
|
|
}
|
|
|
|
// validateDateFormat validates date strings: ISO date, datetime (with warning), or relative formats
|
|
func validateDateFormat(dateStr string) error {
|
|
if dateStr == "" {
|
|
return nil
|
|
}
|
|
|
|
if isValidRelativeFormat(dateStr) {
|
|
return nil
|
|
}
|
|
if _, err := time.Parse(ISODateFormat, dateStr); err == nil {
|
|
return nil
|
|
}
|
|
|
|
for _, format := range commonDateTimeFormats {
|
|
if parsedTime, err := time.Parse(format, dateStr); err == nil {
|
|
dateOnly := parsedTime.Format(ISODateFormat)
|
|
return fmt.Errorf("date field received datetime format %q, only date portion %q will be used. Consider using date format instead", dateStr, dateOnly)
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("invalid date format: %q, expected ISO format (YYYY-MM-DD), datetime format, or relative format", dateStr)
|
|
}
|
|
|
|
// validateDateTimeFormat validates datetime strings: ISO datetime or relative formats
|
|
func validateDateTimeFormat(dateTimeStr string) error {
|
|
if dateTimeStr == "" || isValidRelativeFormat(dateTimeStr) {
|
|
return nil
|
|
}
|
|
|
|
for _, format := range commonDateTimeFormats {
|
|
if _, err := time.Parse(format, dateTimeStr); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("invalid datetime format: %q, expected ISO format (YYYY-MM-DDTHH:MM:SSZ) or relative format", dateTimeStr)
|
|
}
|
|
|
|
func checkMaxLength(fieldName string, field string, maxLength int) error {
|
|
// DisplayName and Name are required fields
|
|
if fieldName == "DisplayName" || fieldName == "Name" {
|
|
if len(field) == 0 {
|
|
return errors.Errorf("%v cannot be empty", fieldName)
|
|
}
|
|
}
|
|
|
|
if len(field) > maxLength {
|
|
return errors.Errorf("%v cannot be longer than %d characters, got %d", fieldName, maxLength, len(field))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (o *Post) StripActionIntegrations() {
|
|
attachments := o.Attachments()
|
|
if o.GetProp(PostPropsAttachments) != nil {
|
|
o.AddProp(PostPropsAttachments, attachments)
|
|
}
|
|
for _, attachment := range attachments {
|
|
for _, action := range attachment.Actions {
|
|
action.Integration = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (o *Post) GetAction(id string) *PostAction {
|
|
for _, attachment := range o.Attachments() {
|
|
for _, action := range attachment.Actions {
|
|
if action != nil && action.Id == id {
|
|
return action
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *Post) GenerateActionIds() {
|
|
if o.GetProp(PostPropsAttachments) != nil {
|
|
o.AddProp(PostPropsAttachments, o.Attachments())
|
|
}
|
|
if attachments, ok := o.GetProp(PostPropsAttachments).([]*SlackAttachment); ok {
|
|
for _, attachment := range attachments {
|
|
for _, action := range attachment.Actions {
|
|
if action != nil && action.Id == "" {
|
|
action.Id = NewId()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func AddPostActionCookies(o *Post, secret []byte) *Post {
|
|
p := o.Clone()
|
|
|
|
// retainedProps carry over their value from the old post, including no value
|
|
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)
|
|
}
|
|
}
|
|
|
|
attachments := p.Attachments()
|
|
for _, attachment := range attachments {
|
|
for _, action := range attachment.Actions {
|
|
c := &PostActionCookie{
|
|
Type: action.Type,
|
|
ChannelId: p.ChannelId,
|
|
DataSource: action.DataSource,
|
|
Integration: action.Integration,
|
|
RetainProps: retainProps,
|
|
RemoveProps: removeProps,
|
|
}
|
|
|
|
c.PostId = p.Id
|
|
if p.RootId == "" {
|
|
c.RootPostId = p.Id
|
|
} else {
|
|
c.RootPostId = p.RootId
|
|
}
|
|
|
|
b, _ := json.Marshal(c)
|
|
action.Cookie, _ = encryptPostActionCookie(string(b), secret)
|
|
}
|
|
}
|
|
|
|
return p
|
|
}
|
|
|
|
func encryptPostActionCookie(plain string, secret []byte) (string, error) {
|
|
if len(secret) == 0 {
|
|
return plain, nil
|
|
}
|
|
|
|
block, err := aes.NewCipher(secret)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
aesgcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
nonce := make([]byte, aesgcm.NonceSize())
|
|
_, err = io.ReadFull(rand.Reader, nonce)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sealed := aesgcm.Seal(nil, nonce, []byte(plain), nil)
|
|
|
|
combined := append(nonce, sealed...) //nolint:makezero
|
|
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(combined)))
|
|
base64.StdEncoding.Encode(encoded, combined)
|
|
|
|
return string(encoded), nil
|
|
}
|
|
|
|
func DecryptPostActionCookie(encoded string, secret []byte) (string, error) {
|
|
if len(secret) == 0 {
|
|
return encoded, nil
|
|
}
|
|
|
|
block, err := aes.NewCipher(secret)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
aesgcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(encoded)))
|
|
n, err := base64.StdEncoding.Decode(decoded, []byte(encoded))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
decoded = decoded[:n]
|
|
|
|
nonceSize := aesgcm.NonceSize()
|
|
if len(decoded) < nonceSize {
|
|
return "", fmt.Errorf("cookie too short")
|
|
}
|
|
|
|
nonce, decoded := decoded[:nonceSize], decoded[nonceSize:]
|
|
plain, err := aesgcm.Open(nil, nonce, decoded, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(plain), nil
|
|
}
|
|
|
|
// IsValidLookupURL validates if a URL is safe for lookup operations
|
|
func IsValidLookupURL(url string) bool {
|
|
if url == "" {
|
|
return false
|
|
}
|
|
|
|
// Allow plugin paths that start with /plugins/
|
|
if strings.HasPrefix(url, "/plugins/") {
|
|
// Additional validation for plugin paths - ensure no path traversal
|
|
if strings.Contains(url, "..") || strings.Contains(url, "//") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// For external URLs, use the same basic validation as other models
|
|
return IsValidHTTPURL(url)
|
|
}
|