Files
upstream-mattermost/server/public/model/property_field_attrs_validation.go
upstream-mirror 240db4e2a7
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
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

422 lines
13 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"fmt"
"net/url"
"slices"
"strings"
)
// Attribute keys used across property groups. These are the canonical keys
// stored in PropertyField.Attrs and referenced by hooks.
const (
PropertyFieldAttrVisibility = "visibility"
PropertyFieldAttrSortOrder = "sort_order"
PropertyFieldAttrValueType = "value_type"
PropertyFieldAttrLDAP = "ldap"
PropertyFieldAttrSAML = "saml"
PropertyFieldAttrManaged = "managed"
PropertyFieldAttrDisplayName = "display_name"
// PropertyFieldAttrActions lists the rendering actions a field triggers.
PropertyFieldAttrActions = "actions"
// PropertyFieldAttrRequired marks a field whose value must be supplied when the
// target resource is created.
PropertyFieldAttrRequired = "required"
// PropertyFieldAttrEditable allows a value to change after it is first set.
// Absent means editable, and must stay indistinguishable from an explicit true
// so fields predating the key keep their behaviour without a migration.
PropertyFieldAttrEditable = "editable"
// PropertyFieldAttrChangePolicy constrains how a value may move once set. It
// supersedes editable, which can only express change/never-change; the two are
// written together for "never" so readers predating this key still lock.
PropertyFieldAttrChangePolicy = "change_policy"
)
// Valid values for PropertyFieldAttrChangePolicy. Raise and lower compare option
// ranks, so they are only meaningful on a rank-typed field.
const (
PropertyFieldChangePolicyAny = "any"
PropertyFieldChangePolicyRaiseOnly = "raise_only"
PropertyFieldChangePolicyLowerOnly = "lower_only"
PropertyFieldChangePolicyNever = "never"
)
var validPropertyFieldChangePolicies = []string{
PropertyFieldChangePolicyAny,
PropertyFieldChangePolicyRaiseOnly,
PropertyFieldChangePolicyLowerOnly,
PropertyFieldChangePolicyNever,
}
// IsValidPropertyFieldChangePolicy reports whether the given string is a known
// change policy.
func IsValidPropertyFieldChangePolicy(p string) bool {
return slices.Contains(validPropertyFieldChangePolicies, p)
}
// IsOrderedPropertyFieldChangePolicy reports whether the policy compares option
// ranks, and so requires a rank-typed field.
func IsOrderedPropertyFieldChangePolicy(p string) bool {
return p == PropertyFieldChangePolicyRaiseOnly || p == PropertyFieldChangePolicyLowerOnly
}
// SanitizeAndValidatePropertyFieldChangePolicy validates the change_policy attr and
// removes it when unset, empty, or "any", so the permissive default has one
// representation rather than two.
func SanitizeAndValidatePropertyFieldChangePolicy(field *PropertyField) error {
if field.Attrs == nil {
return nil
}
raw, ok := field.Attrs[PropertyFieldAttrChangePolicy]
if !ok {
return nil
}
if raw == nil {
delete(field.Attrs, PropertyFieldAttrChangePolicy)
return nil
}
p, ok := raw.(string)
if !ok {
return fmt.Errorf("change_policy must be a string, got %T", raw)
}
p = strings.TrimSpace(p)
if p == "" || p == PropertyFieldChangePolicyAny {
delete(field.Attrs, PropertyFieldAttrChangePolicy)
return nil
}
if !IsValidPropertyFieldChangePolicy(p) {
return fmt.Errorf("invalid change_policy %q: must be one of %s", p, strings.Join(validPropertyFieldChangePolicies, ", "))
}
// A directional policy compares ranks, so it is stripped off any other type
// rather than kept — same treatment as ldap/saml on a non-text field, and it
// keeps a rank->select type change patchable instead of permanently invalid.
if IsOrderedPropertyFieldChangePolicy(p) && field.Type != PropertyFieldTypeRank {
delete(field.Attrs, PropertyFieldAttrChangePolicy)
return nil
}
field.Attrs[PropertyFieldAttrChangePolicy] = p
return nil
}
// GetPropertyFieldChangePolicy returns the field's change policy, defaulting to
// "any". An explicit editable=false with no change_policy reads as "never", so
// fields written before the key keep their behaviour.
func GetPropertyFieldChangePolicy(field *PropertyField) string {
if field.Attrs == nil {
return PropertyFieldChangePolicyAny
}
if p, _ := field.Attrs[PropertyFieldAttrChangePolicy].(string); p != "" {
return p
}
if editable, ok := field.Attrs[PropertyFieldAttrEditable].(bool); ok && !editable {
return PropertyFieldChangePolicyNever
}
return PropertyFieldChangePolicyAny
}
// IsPropertyFieldRequired reports whether a value must be supplied when the
// target resource is created. Only an explicit boolean true counts: a field
// carrying a stringly "true" is a misconfiguration and must not read as
// required, or a typo silently blocks channel creation.
func IsPropertyFieldRequired(field *PropertyField) bool {
if field == nil || field.Attrs == nil {
return false
}
required, _ := field.Attrs[PropertyFieldAttrRequired].(bool)
return required
}
// Valid action values for PropertyFieldAttrActions.
const (
PropertyFieldActionDisplayBannerTop = "display_banner_top"
PropertyFieldActionDisplayBannerBottom = "display_banner_bottom"
PropertyFieldActionDisplayLabelHeader = "display_label_header"
PropertyFieldActionDisplayLabelInfo = "display_label_info"
)
// Valid visibility values for property fields.
const (
PropertyFieldVisibilityHidden = "hidden"
PropertyFieldVisibilityWhenSet = "when_set"
PropertyFieldVisibilityAlways = "always"
)
// Valid value types for text property fields.
const (
PropertyFieldValueTypeEmail = "email"
PropertyFieldValueTypeURL = "url"
PropertyFieldValueTypePhone = "phone"
)
// PropertyFieldValueTypeTextMaxLength is the maximum character length for text field values.
const PropertyFieldValueTypeTextMaxLength = 64
// IsValidPropertyFieldVisibility reports whether the given string is a known visibility value.
func IsValidPropertyFieldVisibility(v string) bool {
switch v {
case PropertyFieldVisibilityHidden,
PropertyFieldVisibilityWhenSet,
PropertyFieldVisibilityAlways:
return true
default:
return false
}
}
// IsValidPropertyFieldValueType reports whether the given string is a known value type.
func IsValidPropertyFieldValueType(v string) bool {
switch v {
case PropertyFieldValueTypeEmail,
PropertyFieldValueTypeURL,
PropertyFieldValueTypePhone:
return true
default:
return false
}
}
// validPropertyFieldActions is the allow-list for PropertyFieldAttrActions, and
// the source of the list surfaced in validation errors.
var validPropertyFieldActions = []string{
PropertyFieldActionDisplayBannerTop,
PropertyFieldActionDisplayBannerBottom,
PropertyFieldActionDisplayLabelHeader,
PropertyFieldActionDisplayLabelInfo,
}
// IsValidPropertyFieldAction reports whether the given string is a known action value.
func IsValidPropertyFieldAction(a string) bool {
return slices.Contains(validPropertyFieldActions, a)
}
// ValidatePropertyFieldVisibility checks that the visibility attr on a
// PropertyField is either empty or one of hidden/when_set/always.
func ValidatePropertyFieldVisibility(field *PropertyField) error {
if field.Attrs == nil {
return nil
}
raw, ok := field.Attrs[PropertyFieldAttrVisibility]
if !ok {
return nil
}
v, ok := raw.(string)
if !ok {
return fmt.Errorf("visibility must be a string")
}
v = strings.TrimSpace(v)
if v == "" {
return nil
}
if !IsValidPropertyFieldVisibility(v) {
return fmt.Errorf("invalid visibility %q: must be one of hidden, when_set, always", v)
}
return nil
}
// ValidatePropertyFieldSortOrder checks that the sort_order attr on a
// PropertyField is numeric (float64 or json.Number) or absent.
func ValidatePropertyFieldSortOrder(field *PropertyField) error {
if field.Attrs == nil {
return nil
}
raw, ok := field.Attrs[PropertyFieldAttrSortOrder]
if !ok {
return nil
}
switch raw.(type) {
case float64, json.Number, int, int64:
return nil
default:
return fmt.Errorf("sort_order must be numeric, got %T", raw)
}
}
// SanitizeAndValidatePropertyFieldBoolAttr validates a boolean attr and removes it
// when explicitly unset, so absent and cleared read alike. A string such as "true"
// is rejected rather than coerced: a silently-coerced typo would flip whether a
// value is demanded at creation, or whether it can change afterwards.
func SanitizeAndValidatePropertyFieldBoolAttr(field *PropertyField, key string) error {
if field.Attrs == nil {
return nil
}
raw, ok := field.Attrs[key]
if !ok {
return nil
}
if raw == nil {
delete(field.Attrs, key)
return nil
}
v, ok := raw.(bool)
if !ok {
return fmt.Errorf("%s must be a boolean, got %T", key, raw)
}
field.Attrs[key] = v
return nil
}
// SanitizeAndValidatePropertyFieldActions validates the actions attr and writes
// it back in the canonical []any form of trimmed strings, so downstream readers
// see a single shape. An absent, nil, or empty list is removed. Unknown actions
// and duplicates are rejected: the values drive rendering, so a typo must fail
// loudly at write time rather than silently never render.
func SanitizeAndValidatePropertyFieldActions(field *PropertyField) error {
if field.Attrs == nil {
return nil
}
raw, ok := field.Attrs[PropertyFieldAttrActions]
if !ok {
return nil
}
if raw == nil {
delete(field.Attrs, PropertyFieldAttrActions)
return nil
}
// Callers reach this both from JSON ([]any) and from Go code ([]string).
var items []string
switch v := raw.(type) {
case []string:
items = v
case []any:
items = make([]string, 0, len(v))
for i, elem := range v {
s, ok := elem.(string)
if !ok {
return fmt.Errorf("actions[%d] must be a string, got %T", i, elem)
}
items = append(items, s)
}
default:
return fmt.Errorf("actions must be an array, got %T", raw)
}
if len(items) == 0 {
delete(field.Attrs, PropertyFieldAttrActions)
return nil
}
seen := make(map[string]struct{}, len(items))
canonical := make([]any, 0, len(items))
for _, s := range items {
s = strings.TrimSpace(s)
if s == "" {
return fmt.Errorf("actions must not contain empty strings")
}
if !IsValidPropertyFieldAction(s) {
return fmt.Errorf("unknown action %q: must be one of %s", s, strings.Join(validPropertyFieldActions, ", "))
}
if _, dup := seen[s]; dup {
return fmt.Errorf("duplicate action %q", s)
}
seen[s] = struct{}{}
canonical = append(canonical, s)
}
field.Attrs[PropertyFieldAttrActions] = canonical
return nil
}
// ValidatePropertyValueForValueType validates a raw JSON value against the
// given value type constraint. This is called for text fields that have a
// value_type attr (email, url, phone).
func ValidatePropertyValueForValueType(valueType string, value json.RawMessage) error {
if valueType == "" {
return nil
}
var str string
if err := json.Unmarshal(value, &str); err != nil {
return fmt.Errorf("expected string value for value_type %q: %w", valueType, err)
}
str = strings.TrimSpace(str)
if str == "" {
return nil
}
switch valueType {
case PropertyFieldValueTypeEmail:
if !IsValidEmail(str) {
return fmt.Errorf("invalid email: %q", str)
}
case PropertyFieldValueTypeURL:
// ParseRequestURI rejects relative references (url.Parse accepts them),
// and we additionally require a non-empty Host so bare schemes like
// "http:" or "file:///..." without an authority are rejected.
u, err := url.ParseRequestURI(str)
if err != nil {
return fmt.Errorf("invalid url: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return fmt.Errorf("invalid url: %q", str)
}
case PropertyFieldValueTypePhone:
// Phone values are accepted as-is; no structural validation.
default:
return fmt.Errorf("unknown value_type %q", valueType)
}
return nil
}
// GetPropertyFieldValueType extracts the value_type string from a
// PropertyField's attrs. Returns empty string if not set.
func GetPropertyFieldValueType(field *PropertyField) string {
if field.Attrs == nil {
return ""
}
v, _ := field.Attrs[PropertyFieldAttrValueType].(string)
return strings.TrimSpace(v)
}
// IsPropertyFieldSynced reports whether the field has an ldap or saml attr set,
// meaning its values are managed by an external sync service.
func IsPropertyFieldSynced(field *PropertyField) bool {
if field.Attrs == nil {
return false
}
ldap, _ := field.Attrs[PropertyFieldAttrLDAP].(string)
saml, _ := field.Attrs[PropertyFieldAttrSAML].(string)
return ldap != "" || saml != ""
}
// GetPropertyFieldSyncSource returns the sync source for a field: "ldap",
// "saml", or empty string if not synced. If both are set, ldap takes priority.
func GetPropertyFieldSyncSource(field *PropertyField) string {
if field.Attrs == nil {
return ""
}
if ldap, _ := field.Attrs[PropertyFieldAttrLDAP].(string); ldap != "" {
return "ldap"
}
if saml, _ := field.Attrs[PropertyFieldAttrSAML].(string); saml != "" {
return "saml"
}
return ""
}