snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
)
const (
AccessTokenGrantType = "authorization_code"
AccessTokenType = "bearer"
RefreshTokenGrantType = "refresh_token"
)
type AccessData struct {
ClientId string `json:"client_id"`
UserId string `json:"user_id"`
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
RedirectUri string `json:"redirect_uri"`
ExpiresAt int64 `json:"expires_at"`
Scope string `json:"scope"`
Audience string `json:"audience"`
}
type AccessResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresInSeconds int32 `json:"expires_in"`
Scope string `json:"scope"`
RefreshToken string `json:"refresh_token"`
IdToken string `json:"id_token"`
Audience string `json:"audience,omitempty"`
}
// IsValid validates the AccessData and returns an error if it isn't configured
// correctly.
func (ad *AccessData) IsValid() *AppError {
if ad.ClientId == "" || len(ad.ClientId) > 26 {
return NewAppError("AccessData.IsValid", "model.access.is_valid.client_id.app_error", nil, "", http.StatusBadRequest)
}
if ad.UserId == "" || len(ad.UserId) > 26 {
return NewAppError("AccessData.IsValid", "model.access.is_valid.user_id.app_error", nil, "", http.StatusBadRequest)
}
if len(ad.Token) != 26 {
return NewAppError("AccessData.IsValid", "model.access.is_valid.access_token.app_error", nil, "", http.StatusBadRequest)
}
if len(ad.RefreshToken) > 26 {
return NewAppError("AccessData.IsValid", "model.access.is_valid.refresh_token.app_error", nil, "", http.StatusBadRequest)
}
if ad.RedirectUri == "" || len(ad.RedirectUri) > 256 || !IsValidHTTPURL(ad.RedirectUri) {
return NewAppError("AccessData.IsValid", "model.access.is_valid.redirect_uri.app_error", nil, "", http.StatusBadRequest)
}
if ad.Audience != "" {
if err := ValidateResourceParameter(ad.Audience, ad.ClientId, "AccessData.IsValid"); err != nil {
return err
}
}
return nil
}
func (ad *AccessData) IsExpired() bool {
if ad.ExpiresAt <= 0 {
return false
}
if GetMillis() > ad.ExpiresAt {
return true
}
return false
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"slices"
)
// maxActionSearchActions bounds how many actions a single Action Search request
// may ask about, to prevent unbounded PDP evaluation loops from a single call.
const maxActionSearchActions = 16
// RenderDecisionReasonRestrictedByPolicy is the only denial reason exposed to
// clients. It is intentionally generic: it never reveals policy names,
// expressions, attribute names, or values.
const RenderDecisionReasonRestrictedByPolicy = "restricted_by_policy"
// RenderPermissionDecision is a non-authoritative, render-time ABAC decision for
// a single action. MUST NOT be used to authorize — enforcement always re-evaluates
// the PDP live on the server.
type RenderPermissionDecision struct {
Allowed bool `json:"allowed"`
Evaluated bool `json:"evaluated"`
Reason string `json:"reason,omitempty"`
}
// ActionSearchResultAction is the action identity inside an AuthZEN result entry.
type ActionSearchResultAction struct {
Name string `json:"name"`
}
// ActionSearchResult is the AuthZEN-canonical permitted-action entry.
// Only PERMITTED actions appear in the results list; denial is expressed by omission.
type ActionSearchResult struct {
Action ActionSearchResultAction `json:"action"`
}
// ActionSearchSubject is RESERVED for Phase 3 cross-subject evaluation.
// In Phase 2, any Subject.ID that does not match the authenticated session user is
// rejected with 403. The field is present in the contract to make Phase 3 a
// non-breaking extension of this endpoint.
type ActionSearchSubject struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
}
// ActionSearchPage is RESERVED for Phase 3 pagination.
// Accepted in requests but always ignored; next_token is never emitted in responses.
type ActionSearchPage struct {
NextToken string `json:"next_token,omitempty"`
}
// ActionSearchRequest asks "for the current session user, on this resource,
// which actions are allowed?".
//
// Actions is optional:
// - nil or empty → discovery mode: the server evaluates all renderable actions
// registered for the resource type and returns the permitted set.
// - non-empty → targeted mode: the server evaluates exactly those actions
// (max 16; all must be registered for the resource type).
//
// Subject is RESERVED. If provided, its ID must equal the authenticated session
// user ID; mismatches are rejected with 403.
//
// Page is RESERVED. Accepted but always ignored.
type ActionSearchRequest struct {
Resource Resource `json:"resource"`
Actions []string `json:"actions,omitempty"` // optional; nil/empty = discovery
Subject *ActionSearchSubject `json:"subject,omitempty"` // reserved
Page *ActionSearchPage `json:"page,omitempty"` // reserved
}
// ActionSearchResponse returns render-time ABAC decisions for the requested resource.
//
// Results is the AuthZEN-canonical list: only actions the server evaluated as
// PERMITTED appear here. An empty Results ([]) is meaningful — all evaluated actions
// were denied — and is always present (never null).
//
// Decisions is the Mattermost extension: all evaluated actions appear here with full
// decision detail (Allowed, Evaluated, Reason), including denied ones. Always present.
//
// Page is RESERVED; always absent in the current implementation.
type ActionSearchResponse struct {
Resource Resource `json:"resource"`
Results []ActionSearchResult `json:"results"` // no omitempty — [] is meaningful
Decisions map[string]RenderPermissionDecision `json:"decisions"` // no omitempty — {} is meaningful
Page *ActionSearchPage `json:"page,omitempty"`
}
// IsValid validates the shape of an Action Search request. It does not validate
// that the actions/resource type are supported for rendering; that allowlist
// check happens in the App layer against the renderable-action registry.
func (r *ActionSearchRequest) IsValid() *AppError {
if r.Resource.Type == "" {
return NewAppError("ActionSearchRequest.IsValid", "model.access_control_decision.is_valid.resource_type.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(r.Resource.ID) {
return NewAppError("ActionSearchRequest.IsValid", "model.access_control_decision.is_valid.resource_id.app_error", nil, "", http.StatusBadRequest)
}
// nil/empty Actions = discovery mode (valid). Validate bounds only when non-empty.
if len(r.Actions) > maxActionSearchActions {
return NewAppError("ActionSearchRequest.IsValid", "model.access_control_decision.is_valid.actions_too_many.app_error", map[string]any{"Max": maxActionSearchActions}, "", http.StatusBadRequest)
}
if slices.Contains(r.Actions, "") {
return NewAppError("ActionSearchRequest.IsValid", "model.access_control_decision.is_valid.action_empty.app_error", nil, "", http.StatusBadRequest)
}
// Subject shape validation. Identity check (must match session user) is in the app layer.
if r.Subject != nil && !IsValidId(r.Subject.ID) {
return NewAppError("ActionSearchRequest.IsValid", "model.access_control_decision.is_valid.subject_id.app_error", nil, "", http.StatusBadRequest)
}
return nil
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// MaskingFieldAccessMode indicates how a property field's literal values are
// exposed to a given caller under attribute value masking rules.
type MaskingFieldAccessMode int
const (
MaskingFieldAccessUnknown MaskingFieldAccessMode = iota
// MaskingFieldAccessPublic means all values are visible to every caller.
MaskingFieldAccessPublic
// MaskingFieldAccessSharedOnly means the caller sees only values they themselves hold.
MaskingFieldAccessSharedOnly
// MaskingFieldAccessSourceOnly means values are never visible to callers.
MaskingFieldAccessSourceOnly
)
// MaskingTokenValue is the sentinel string written into masked CEL expressions
// to represent one or more hidden values without revealing their content.
const MaskingTokenValue = "--------"
// MaskingFieldInfo bundles per-field, per-caller visibility data for use by
// the canonical CEL AST masking walker.
type MaskingFieldInfo struct {
Access MaskingFieldAccessMode
// VisibleValues contains the literal values the caller may see.
// Populated for MaskingFieldAccessSharedOnly fields; nil for Public/SourceOnly/Unknown.
VisibleValues map[string]struct{}
}
// IsValueHidden reports whether the literal value lit is hidden from the caller
// under this field's access mode. It is the single source of truth for the
// per-value visibility decision shared by the masking, validation, and merge
// walkers.
//
// The masked-token placeholder (MaskingTokenValue) is never itself "hidden": it
// is a server-generated stand-in from a prior read response, not a real value.
// Unknown or unrecognised access modes fail closed (treated as hidden).
func (info *MaskingFieldInfo) IsValueHidden(lit string) bool {
if lit == MaskingTokenValue {
return false
}
switch info.Access {
case MaskingFieldAccessPublic:
return false
case MaskingFieldAccessSourceOnly:
return true
case MaskingFieldAccessSharedOnly:
_, visible := info.VisibleValues[lit]
return !visible
default:
return true
}
}
// MaskingFieldResolver answers field-visibility questions for a property
// attribute identified by its CPA object type (PropertyFieldObjectTypeUser for
// user.attributes.*, PropertyFieldObjectTypeChannel for resource.attributes.*)
// and field name (the suffix after the ".attributes." segment, e.g.
// "department"). The object type is required because a user field and a channel
// field can share a name but differ in visibility, so each must be resolved
// against its own CPA schema.
//
// Implementations must be fail-closed: return a non-nil error for any lookup
// that cannot be proven safe. The walker treats any resolver error as a
// reason to mask all literals for that field.
type MaskingFieldResolver interface {
Resolve(objectType, fieldName string) (*MaskingFieldInfo, error)
}

View File

@@ -0,0 +1,755 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"fmt"
"regexp"
"slices"
"strings"
"github.com/pkg/errors"
"golang.org/x/mod/semver"
)
const (
AccessControlPolicyTypeParent = "parent"
AccessControlPolicyTypeChannel = "channel"
AccessControlPolicyTypePermission = "permission"
AccessControlPolicyTypeTeam = "team"
MaxPolicyNameLength = 128
// PluginAccessControlPolicyTypeSeparator separates the owning plugin ID
// from the resource-type segment of a plugin-owned policy type, e.g.
// "mattermost-ai:agent". Ownership is encoded in the key itself, so core
// treats these types opaquely.
PluginAccessControlPolicyTypeSeparator = ":"
// MaxPluginResourceTypeLength bounds the resource-type segment of a
// plugin-owned policy type.
MaxPluginResourceTypeLength = 64
// MaxPolicyTypeLength bounds a whole policy type to the width of the
// AccessControlPolicies.Type column. A valid plugin ID alone can exceed
// it, so the combined key must be bounded here or an otherwise-valid type
// would fail at insert time instead of validation.
MaxPolicyTypeLength = 128
// MaxPolicyActionLength bounds an action name.
MaxPolicyActionLength = 64
AccessControlPolicyVersionV0_1 = "v0.1"
AccessControlPolicyVersionV0_2 = "v0.2"
AccessControlPolicyVersionV0_3 = "v0.3"
AccessControlPolicyVersionV0_4 = "v0.4"
AccessControlPolicyVersionV0_5 = "v0.5"
AccessControlPolicyActionMembership = "membership"
AccessControlPolicyActionUploadFileAttachment = "upload_file_attachment"
AccessControlPolicyActionDownloadFileAttachment = "download_file_attachment"
AccessControlPolicyScopeTeam = "team"
)
var allowedActionsV0_3 = map[string]bool{
AccessControlPolicyActionMembership: true,
AccessControlPolicyActionUploadFileAttachment: true,
AccessControlPolicyActionDownloadFileAttachment: true,
}
// allowedChannelRolesV0_4 is the set of channel-scoped roles that may appear
// on a v0.4 channel resource policy rule.
var allowedChannelRolesV0_4 = map[string]bool{
ChannelGuestRoleId: true,
ChannelUserRoleId: true,
ChannelAdminRoleId: true,
}
// allowedPermissionActionsV0_4 is the set of non-membership actions that may
// appear on a v0.4 channel resource policy rule. These rules govern per-action
// behavior (file upload/download) and must carry a channel-scoped role.
var allowedPermissionActionsV0_4 = map[string]bool{
AccessControlPolicyActionUploadFileAttachment: true,
AccessControlPolicyActionDownloadFileAttachment: true,
}
// IsPermissionAction reports whether the given action is a non-membership
// permission action governed by a v0.4 channel rule.
func IsPermissionAction(action string) bool {
return allowedPermissionActionsV0_4[action]
}
// pluginResourceTypeRe constrains the resource-type segment of a plugin-owned
// policy type to the same charset as a plugin ID.
var pluginResourceTypeRe = regexp.MustCompile(ValidIdRegex)
// policyActionRe constrains action names to lowercase alphanumeric segments
// joined by single underscores, matching the shape of the built-in actions
// (membership, upload_file_attachment). It rejects the "*" wildcard.
var policyActionRe = regexp.MustCompile(`^[a-z0-9]+(_[a-z0-9]+)*$`)
// SplitPluginAccessControlPolicyType splits a plugin-owned policy type into
// its owning plugin ID and resource-type segment. ok is false unless both
// segments are well formed.
func SplitPluginAccessControlPolicyType(policyType string) (pluginID string, resourceType string, ok bool) {
if len(policyType) > MaxPolicyTypeLength {
return "", "", false
}
pluginID, resourceType, found := strings.Cut(policyType, PluginAccessControlPolicyTypeSeparator)
if !found || !IsValidPluginId(pluginID) {
return "", "", false
}
if resourceType == "" || len(resourceType) > MaxPluginResourceTypeLength || !pluginResourceTypeRe.MatchString(resourceType) {
return "", "", false
}
return pluginID, resourceType, true
}
// IsPluginAccessControlPolicyType reports whether policyType is a well-formed
// plugin-owned resource-policy type ("<pluginID>:<resourceType>").
func IsPluginAccessControlPolicyType(policyType string) bool {
_, _, ok := SplitPluginAccessControlPolicyType(policyType)
return ok
}
// PluginOwnsAccessControlPolicyType reports whether policyType is a
// well-formed plugin policy type whose plugin-ID prefix is pluginID. The
// prefix is the entire ownership check. The comparison is exact: the stored
// type is matched byte-for-byte everywhere (delete, evaluation), so accepting
// case variants here would let a mixed-case type be read but never deleted or
// evaluated.
func PluginOwnsAccessControlPolicyType(pluginID, policyType string) bool {
owner, _, ok := SplitPluginAccessControlPolicyType(policyType)
return ok && owner == pluginID
}
// IsValidPolicyAction reports whether action is a well-formed action name.
// Plugin policies choose their own action names, so core validates only the
// format.
func IsValidPolicyAction(action string) bool {
return len(action) <= MaxPolicyActionLength && policyActionRe.MatchString(action)
}
// HasPermissionRuleAction reports whether ANY rule on this policy
// carries a non-membership permission action (file upload/download).
// Used by the API4 layer to gate channel-scope policies behind the
// ChannelPermissionPolicies feature flag: if a channel policy
// includes a permission rule and the flag is off, the request is
// rejected before reaching the PAP. Returns false for a nil/empty
// policy so callers can use it as a guard without nil checks.
func (p *AccessControlPolicy) HasPermissionRuleAction() bool {
if p == nil {
return false
}
for i := range p.Rules {
if slices.ContainsFunc(p.Rules[i].Actions, IsPermissionAction) {
return true
}
}
return false
}
// AccessControlAttribute represents a user attribute with its name and possible values
type AccessControlAttribute struct {
Attribute PropertyField `json:"attribute"`
Values []string `json:"values"`
}
type AccessControlPolicyTestResponse struct {
Users []*User `json:"users"`
Total int64 `json:"total"`
}
type GetAccessControlPolicyOptions struct {
Type string `json:"type"`
ParentID string `json:"parent_id"`
Cursor AccessControlPolicyCursor `json:"cursor"`
Limit int `json:"limit"`
}
type AccessControlPolicySearch struct {
Term string `json:"term"`
Type string `json:"type"`
ParentID string `json:"parent_id"`
IDs []string `json:"ids"`
Cursor AccessControlPolicyCursor `json:"cursor"`
Limit int `json:"limit"`
IncludeChildren bool `json:"include_children"`
Active bool `json:"active"`
TeamID string `json:"team_id"`
Scope string `json:"scope,omitempty"`
ScopeID string `json:"scope_id,omitempty"`
Actions []string `json:"actions"`
}
type AccessControlPolicyCursor struct {
ID string `json:"id"`
}
type AccessControlPoliciesWithCount struct {
Policies []*AccessControlPolicy `json:"policies"`
Total int64 `json:"total"`
}
type AccessControlPolicy struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Active bool `json:"active"`
CreateAt int64 `json:"create_at"`
Revision int `json:"revision"`
Version string `json:"version"`
Roles []string `json:"roles"`
Imports []string `json:"imports"`
Rules []AccessControlPolicyRule `json:"rules"`
Scope string `json:"scope,omitempty"` // "" (system) or "team"
ScopeID string `json:"scope_id,omitempty"` // team ID when scope="team"
Props map[string]any `json:"props"` // add auto-sync property here, also maybe the attributes being used in the expression
}
type AccessControlPolicyRule struct {
Actions []string `json:"actions"`
Expression string `json:"expression"`
// Name is an admin-facing label for the rule. Required for v0.4 permission
// rules and must be unique within the same policy.
Name string `json:"name,omitempty"`
// Role is the channel-scoped role this rule applies to (channel_guest,
// channel_user, channel_admin) for v0.4 permission rules. Membership rules
// must leave this empty.
Role string `json:"role,omitempty"`
}
type CELExpressionError struct {
Line int `json:"line"`
Column int `json:"column"`
Message string `json:"message"`
}
type AccessControlQueryResult struct {
MatchedSubjectIDs []string `json:"matched_subject_ids"`
}
// AccessControlPolicyActiveUpdate represents a single policy's active status update.
type AccessControlPolicyActiveUpdate struct {
ID string `json:"id"`
Active bool `json:"active"`
}
// AccessControlPolicyActiveUpdateRequest is used in the API to update active status for multiple policies.
type AccessControlPolicyActiveUpdateRequest struct {
Entries []AccessControlPolicyActiveUpdate `json:"entries"`
TeamID string `json:"team_id,omitempty"`
}
func (r *AccessControlPolicyActiveUpdateRequest) Auditable() map[string]any {
entries := make([]map[string]any, 0, len(r.Entries))
for _, entry := range r.Entries {
entries = append(entries, map[string]any{
"id": entry.ID,
"active": entry.Active,
})
}
result := map[string]any{
"entries": entries,
}
if r.TeamID != "" {
result["team_id"] = r.TeamID
}
return result
}
func (p *AccessControlPolicy) IsValid() *AppError {
if p.Scope != "" || p.ScopeID != "" {
if appErr := p.validateScope(); appErr != nil {
return appErr
}
}
switch p.Version {
case AccessControlPolicyVersionV0_1:
return p.accessPolicyVersionV0_1()
case AccessControlPolicyVersionV0_2:
return p.accessPolicyVersionV0_2()
case AccessControlPolicyVersionV0_3:
return p.accessPolicyVersionV0_3()
case AccessControlPolicyVersionV0_4:
return p.accessPolicyVersionV0_4()
case AccessControlPolicyVersionV0_5:
return p.accessPolicyVersionV0_5()
default:
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
}
}
func (p *AccessControlPolicy) validateScope() *AppError {
switch p.Scope {
case "":
if p.ScopeID != "" {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope_id_without_scope.app_error", nil, "", 400)
}
case AccessControlPolicyScopeTeam:
if !IsValidId(p.ScopeID) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope_id.app_error", nil, "", 400)
}
default:
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope.app_error", nil, "", 400)
}
return nil
}
func (p *AccessControlPolicy) accessPolicyVersionV0_1() *AppError {
if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel}, p.Type) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400)
}
if !IsValidId(p.ID) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
}
if p.Type == AccessControlPolicyTypeParent && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
}
if p.Revision < 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
}
if !semver.IsValid(p.Version) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
}
switch p.Type {
case AccessControlPolicyTypeParent:
if len(p.Rules) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
}
if len(p.Imports) > 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
}
case AccessControlPolicyTypeChannel:
if len(p.Rules) == 0 && len(p.Imports) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
}
if len(p.Rules) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
}
if len(p.Imports) > 1 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
}
}
return nil
}
func (p *AccessControlPolicy) accessPolicyVersionV0_2() *AppError {
if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel}, p.Type) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400)
}
if !IsValidId(p.ID) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
}
if p.Type == AccessControlPolicyTypeParent && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
}
if p.Revision < 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
}
if !semver.IsValid(p.Version) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
}
switch p.Type {
case AccessControlPolicyTypeParent:
if len(p.Rules) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
}
if len(p.Imports) > 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
}
case AccessControlPolicyTypeChannel:
if len(p.Rules) == 0 && len(p.Imports) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
}
}
return nil
}
func (p *AccessControlPolicy) accessPolicyVersionV0_3() *AppError {
if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel, AccessControlPolicyTypePermission, AccessControlPolicyTypeTeam}, p.Type) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400)
}
if !IsValidId(p.ID) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
}
if (p.Type == AccessControlPolicyTypeParent || p.Type == AccessControlPolicyTypePermission) && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
}
if p.Revision < 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
}
if !semver.IsValid(p.Version) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
}
switch p.Type {
case AccessControlPolicyTypeParent:
if len(p.Rules) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
}
if len(p.Imports) > 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
}
case AccessControlPolicyTypeChannel:
if len(p.Rules) == 0 && len(p.Imports) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
}
case AccessControlPolicyTypeTeam:
if len(p.Rules) == 0 && len(p.Imports) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
}
case AccessControlPolicyTypePermission:
if len(p.Rules) == 0 && len(p.Imports) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
}
// Permissions are only allowed to be applied to a single role as of v0.3
// role hierarchy is resolved at the PDP
if len(p.Roles) != 1 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
}
for _, role := range p.Roles {
if strings.TrimSpace(role) == "" {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
}
}
if len(p.Imports) > 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
}
}
for _, rule := range p.Rules {
if len(rule.Actions) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, "actions must not be empty", 400)
}
for _, action := range rule.Actions {
if !allowedActionsV0_3[action] {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("unrecognized action: %s", action), 400)
}
}
if slices.Contains(rule.Actions, AccessControlPolicyActionMembership) && strings.Contains(rule.Expression, "user.session") {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.session_attribute_on_membership.app_error", nil, "", 400)
}
}
return nil
}
// accessPolicyVersionV0_4 validates a v0.4 policy. v0.4 extends v0.3 by
// allowing channel resource policies to carry channel-role-scoped permission
// rules (upload/download file attachments) alongside membership rules.
//
// Constraints layered on top of v0.3:
// - Permission action rules MUST carry a non-empty Name (unique within the
// policy) and a Role in {channel_guest, channel_user, channel_admin}.
// - Membership rules MUST NOT carry a Role and MUST be alone in their rule
// entry (cannot be combined with permission actions).
// - Permission action rules are only allowed on `channel` policy types.
// `parent` and system `permission` policy types remain membership-only at
// v0.4 (multi-action support there is a follow-up iteration).
func (p *AccessControlPolicy) accessPolicyVersionV0_4() *AppError {
if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel, AccessControlPolicyTypePermission, AccessControlPolicyTypeTeam}, p.Type) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400)
}
if !IsValidId(p.ID) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
}
if (p.Type == AccessControlPolicyTypeParent || p.Type == AccessControlPolicyTypePermission) && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
}
if p.Revision < 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
}
if !semver.IsValid(p.Version) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
}
switch p.Type {
case AccessControlPolicyTypeParent:
if len(p.Rules) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
}
if len(p.Imports) > 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
}
case AccessControlPolicyTypeChannel:
if len(p.Rules) == 0 && len(p.Imports) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
}
case AccessControlPolicyTypeTeam:
if len(p.Rules) == 0 && len(p.Imports) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
}
case AccessControlPolicyTypePermission:
if len(p.Rules) == 0 && len(p.Imports) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
}
if len(p.Roles) != 1 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
}
for _, role := range p.Roles {
if strings.TrimSpace(role) == "" {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
}
}
if len(p.Imports) > 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
}
}
seenNames := make(map[string]struct{})
for _, rule := range p.Rules {
if len(rule.Actions) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, "actions must not be empty", 400)
}
hasMembership := false
hasPermission := false
for _, action := range rule.Actions {
if !allowedActionsV0_3[action] {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("unrecognized action: %s", action), 400)
}
if action == AccessControlPolicyActionMembership {
hasMembership = true
}
if allowedPermissionActionsV0_4[action] {
hasPermission = true
}
}
// Membership cannot be combined with permission actions in the same rule.
if hasMembership && hasPermission {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.membership_combined.app_error", nil, "membership cannot be combined with other actions in the same rule", 400)
}
// Permission rules are only allowed on channel-type policies in v0.4.
if hasPermission && p.Type != AccessControlPolicyTypeChannel {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.permission_type.app_error", nil, "permission action rules are only allowed on channel policies", 400)
}
// Permission rules require a Name (unique within policy) and a Role.
// Normalise once: TrimSpace lets the empty-/length-/uniqueness
// checks share the same view of the name, so authoring errors
// like "Uploads" vs "Uploads " are caught as duplicates instead
// of slipping through and forming two visually identical rules.
if hasPermission {
n := strings.TrimSpace(rule.Name)
if n == "" || len(n) > MaxPolicyNameLength {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name.app_error", nil, "permission rules require a non-empty name within the policy max length", 400)
}
if !allowedChannelRolesV0_4[rule.Role] {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, fmt.Sprintf("invalid channel role: %q", rule.Role), 400)
}
if _, exists := seenNames[n]; exists {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name_unique.app_error", nil, fmt.Sprintf("duplicate rule name: %q", n), 400)
}
seenNames[n] = struct{}{}
}
// Membership rules must not carry a role.
if hasMembership && rule.Role != "" {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, "membership rules must not have a role", 400)
}
}
return nil
}
// accessPolicyVersionV0_5 validates a v0.5 policy. v0.5 is the lane for
// plugin-owned resource policies, which differ from the core versions in ways
// that are not expressible there: the type is a "<pluginID>:<resourceType>"
// key rather than one of the core type constants, and actions are
// plugin-defined rather than drawn from the core action set. On top of that it
// pins resource-scoped policies to system scope with no imports or roles.
func (p *AccessControlPolicy) accessPolicyVersionV0_5() *AppError {
if !IsPluginAccessControlPolicyType(p.Type) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.plugin_type.app_error", nil, "", 400)
}
if !IsValidId(p.ID) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
}
// Name is required — no backing entity supplies one for plugin types.
if p.Name == "" || len(p.Name) > MaxPolicyNameLength {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
}
if p.Revision < 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
}
if !semver.IsValid(p.Version) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
}
if len(p.Rules) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
}
if len(p.Imports) > 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
}
if len(p.Roles) > 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
}
// validateScope allows team scopes; plugin policies are system scope only.
if p.Scope != "" || p.ScopeID != "" {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope.app_error", nil, "", 400)
}
for _, rule := range p.Rules {
if len(rule.Actions) == 0 {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, "actions must not be empty", 400)
}
for _, action := range rule.Actions {
if !IsValidPolicyAction(action) {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("malformed action: %s", action), 400)
}
}
if rule.Role != "" {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, "plugin policy rules must not have a role", 400)
}
if len(rule.Name) > MaxPolicyNameLength {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name.app_error", nil, "rule name exceeds the policy max length", 400)
}
}
return nil
}
func (p *AccessControlPolicy) Inherit(parent *AccessControlPolicy) *AppError {
rules := make([]AccessControlPolicyRule, len(p.Rules))
switch p.Version {
case AccessControlPolicyVersionV0_1:
p.Imports = []string{parent.ID}
for i, rule := range p.Rules {
actions := make([]string, len(rule.Actions))
copy(actions, rule.Actions)
rules[i] = AccessControlPolicyRule{
Actions: actions,
Expression: fmt.Sprintf("policies.id_%s", p.ID),
}
}
case AccessControlPolicyVersionV0_2:
if slices.Contains(p.Imports, parent.ID) {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.already_imported.app_error", nil, "", 400)
}
p.Imports = append(p.Imports, parent.ID)
case AccessControlPolicyVersionV0_3:
if p.Type == AccessControlPolicyTypePermission || parent.Type == AccessControlPolicyTypePermission {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.permission.app_error", nil, "", 400)
}
if parent.Type != AccessControlPolicyTypeParent {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.parent_type.app_error", nil, "imports must target a parent-type policy", 400)
}
if parent.Version != AccessControlPolicyVersionV0_3 {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400)
}
if slices.Contains(p.Imports, parent.ID) {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.already_imported.app_error", nil, "", 400)
}
p.Imports = append(p.Imports, parent.ID)
case AccessControlPolicyVersionV0_4:
if p.Type == AccessControlPolicyTypePermission || parent.Type == AccessControlPolicyTypePermission {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.permission.app_error", nil, "", 400)
}
// v0.4 channel policies may import v0.3 or v0.4 parent policies.
// Parents themselves remain membership-only at v0.4 (validator enforces).
if parent.Version != AccessControlPolicyVersionV0_3 && parent.Version != AccessControlPolicyVersionV0_4 {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400)
}
// v0.4 inherit is strictly child-channel → parent-membership.
// A channel→channel or permission→channel import has no
// well-defined semantics in the v0.4 model (parents are the
// only carriers of reusable membership rules), so reject the
// import rather than silently appending a peer policy's ID
// into Imports where the loader would later treat it as a
// membership parent.
if parent.Type != AccessControlPolicyTypeParent {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.parent_type.app_error", nil, "v0.4 imports must target a membership parent policy", 400)
}
if slices.Contains(p.Imports, parent.ID) {
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.already_imported.app_error", nil, "", 400)
}
// Stage Imports on a probe copy so a post-merge IsValid failure
// leaves the receiver untouched (transactional contract).
newImports := append(slices.Clone(p.Imports), parent.ID)
probe := *p
probe.Imports = newImports
if appErr := probe.IsValid(); appErr != nil {
return appErr
}
p.Imports = newImports
return nil
default:
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400)
}
if appErr := p.IsValid(); appErr != nil {
return appErr
}
return nil
}
func (c *AccessControlPolicyCursor) IsEmpty() bool {
return c.ID == ""
}
func (c *AccessControlPolicyCursor) IsValid() error {
if c.IsEmpty() {
return nil
}
if !IsValidId(c.ID) {
return errors.New("cursor id is invalid")
}
return nil
}
func (p *AccessControlPolicy) Auditable() map[string]any {
return map[string]any{
"id": p.ID,
"type": p.Type,
"revision": p.Revision,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,649 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// AccessControlSubjectScope* enumerates the supported scopes for ScopedRole.
const (
AccessControlSubjectScopeSystem = "system"
AccessControlSubjectScopeChannel = "channel"
)
// ScopedRole pairs a role identifier with the scope it applies to. A subject
// may carry multiple ScopedRoles (e.g. one for the system, one for a channel)
// so the PDP can select the appropriate role when matching against a v0.4
// channel resource policy rule whose Role field is a channel-scoped role.
type ScopedRole struct {
// Scope is one of AccessControlSubjectScope* constants.
Scope string `json:"scope"`
// Role is the role identifier within that scope (e.g. "system_user",
// "channel_admin").
Role string `json:"role"`
}
// Subject represents the user or a virtual entity for which the Authorization
// API is called.
type Subject struct {
// ID is the unique identifier of the Subject.
// it can be a user ID, bot ID, etc and it is scoped to the Type.
ID string `json:"id"`
// Type specifies the type of the Subject, eg. user, bot, etc.
Type string `json:"type"`
// Role is the system role of the subject (e.g. "system_user", "system_guest", "system_admin").
// This is separate from custom profile attributes since it's a first-class system concept.
//
// Deprecated: prefer ScopedRoles which can express both system and
// channel-scoped roles. Role is still populated for backward
// compatibility and acts as the system-scope fallback inside
// RoleForScope: a system-scope lookup returns Role whenever
// ScopedRoles has no entry whose Scope is system — including
// when the slice is empty AND when it contains only
// channel-scoped entries. Populating ScopedRoles with non-system
// entries does NOT suppress this fallback.
Role string `json:"role"`
// ScopedRoles carries roles paired with the scope they apply to (system
// or channel). The PDP uses this slice to match a rule's scoped Role
// (e.g. v0.4 channel resource policy rules) against the subject.
ScopedRoles []ScopedRole `json:"scoped_roles,omitempty"`
// Attributes are the key-value pairs assicuated with the subject.
// An attribute may be single-valued or multi-valued and can be a primitive type
// (string, boolean, number) or a complex type like a JSON object or array.
Attributes map[string]any `json:"attributes"`
// Session carries environmental / per-session attributes that policy
// authors reference as `user.session.<key>` (e.g. user.session.network_status,
// user.session.client_type, user.session.device_managed, user.session.ip_range,
// user.session.platform, user.session.device_id).
//
// Session lives under the Subject — not as a sibling top-level CEL
// variable — because every value here is keyed to the requesting
// principal: the network the user is currently on, the client they're
// using, whether their device is MDM-managed, etc. Modeling it as part
// of the Subject keeps the Subject the single source of truth for
// "everything we know about the requester at decision time" and
// matches OpenID AuthZen's subject.properties / subject.session shape.
//
// The simulator populates this map from the picker's session-attribute
// overrides and the requesting admin's active-session snapshot. The
// live PDP populates it from rctx.Session() once the production wiring
// for environmental telemetry lands; until then SavePolicy rejects
// rules that reference user.session.* (see access_control.administration
// in the enterprise repo) so authors cannot ship a control whose
// production behaviour silently diverges from the simulator preview.
Session map[string]any `json:"session,omitempty"`
// Email is the subject's email address (model.User.Email), exposed to
// CEL policies as user.email. Populated by BuildAccessControlSubject.
Email string `json:"email,omitempty"`
// EmailVerified mirrors model.User.EmailVerified, exposed as user.verified.
EmailVerified bool `json:"email_verified,omitempty"`
// IsBot mirrors model.User.IsBot (derived from the Bots table on the
// cached user read), exposed as user.isbot.
IsBot bool `json:"is_bot,omitempty"`
// CreateAt mirrors model.User.CreateAt (epoch ms), exposed as user.createat.
CreateAt int64 `json:"create_at,omitempty"`
}
// RoleForScope returns the role assigned to this subject within the given
// scope. It first walks ScopedRoles for a matching Scope; for the system
// scope it falls back to the legacy Role field whenever no system-scoped
// entry exists in ScopedRoles (including when the slice is empty or
// contains only channel-scoped entries).
func (s *Subject) RoleForScope(scope string) string {
for _, sr := range s.ScopedRoles {
if sr.Scope == scope {
return sr.Role
}
}
if scope == AccessControlSubjectScopeSystem {
return s.Role
}
return ""
}
// RolesForScope returns every role assigned to this subject within the
// given scope, preserving the order they appear in ScopedRoles. Unlike
// RoleForScope it does NOT fall back to the legacy Role field — callers
// that need legacy single-role fallback should keep using RoleForScope.
//
// The current PDP only ever populates one entry per scope, so this
// helper returns at most a single-element slice today. It exists to
// give multi-role-per-scope consumers (a future capability — Mattermost
// users can carry multiple system roles like "system_user system_admin")
// a stable accessor that won't change shape when the underlying
// invariant is relaxed.
//
// Returns nil when no entry matches the scope.
func (s *Subject) RolesForScope(scope string) []string {
var roles []string
for _, sr := range s.ScopedRoles {
if sr.Scope == scope {
roles = append(roles, sr.Role)
}
}
return roles
}
// SetScopedRole upserts a single role for the given scope, preserving
// the per-scope uniqueness invariant the PDP currently relies on. If an
// entry for the scope already exists, its role is replaced (keeping its
// position in ScopedRoles); any later duplicates with the same scope
// are removed. If no entry exists, a new one is appended.
//
// Passing an empty role removes every entry for the scope. This mirrors
// the convention used by the channel-scope hot path in
// attachChannelScopedRole, where an empty channel role lookup means "no
// channel role applies — drop any stale entry from the cached subject."
//
// Passing an empty scope is a no-op (defensive — the PDP never
// constructs scope="" entries).
//
// SetScopedRole always allocates a fresh ScopedRoles backing array, so
// it is safe to call on a Subject whose ScopedRoles slice is aliased
// with another Subject (e.g. the per-user cached Subject reused across
// many channels in attachChannelScopedRole).
func (s *Subject) SetScopedRole(scope, role string) {
if scope == "" {
return
}
updated := false
out := make([]ScopedRole, 0, len(s.ScopedRoles)+1)
for _, sr := range s.ScopedRoles {
if sr.Scope != scope {
out = append(out, sr)
continue
}
if role == "" || updated {
continue
}
out = append(out, ScopedRole{Scope: scope, Role: role})
updated = true
}
if !updated && role != "" {
out = append(out, ScopedRole{Scope: scope, Role: role})
}
s.ScopedRoles = out
}
type SubjectSearchOptions struct {
Term string `json:"term"`
TeamID string `json:"team_id"`
// Query and Args should be generated within the Access Control Service
// and passed here wrt database driver
Query string `json:"query"`
Args []any `json:"args"`
Limit int `json:"limit"`
Cursor SubjectCursor `json:"cursor"`
AllowInactive bool `json:"allow_inactive"`
IgnoreCount bool `json:"ignore_count"`
// ExcludeChannelMembers is used to exclude members from the search results
// specifically used when syncing channel members
ExcludeChannelMembers string `json:"exclude_members"`
// SubjectID is used to filter search results to a specific user ID
// This is particularly useful for validation queries where we only need to check
// if a specific user matches an expression, rather than fetching all matching users
SubjectID string `json:"subject_id"`
// ExcludeNativeAttributes strips native user-attribute predicates (user.email,
// user.verified, user.isbot, user.createat[.youngerThanDays]) from the expression
// before building SQL, so self-inclusion validation checks only the CPA parts.
ExcludeNativeAttributes bool `json:"exclude_native_attributes,omitempty"`
// ExcludeFullNames drops FirstName/LastName from the Term search fields so a
// term query cannot probe users' real names when PrivacySettings.ShowFullName
// is off for a non-privileged caller. Mirrors the AllowFullNames gate in the
// normal user-search path. Zero value (false) preserves the full-name search
// used by privileged callers (e.g. the admin CEL tester).
ExcludeFullNames bool `json:"exclude_full_names,omitempty"`
// ResourceID is the channel whose custom attributes an ad-hoc expression test
// resolves resource.attributes.* against, so a resource-referencing expression
// can be previewed against one specific channel's values. Set from the
// channelId on a cel/test request. Unused when the expression references no
// resource attributes. Stored-policy search paths derive the resource from
// the policy itself and ignore this.
ResourceID string `json:"resource_id,omitempty"`
}
type SubjectCursor struct {
TargetID string `json:"target_id"`
}
// Resource is the target of an access request.
type Resource struct {
// ID is the unique identifier of the Resource.
// It can be a channel ID, post ID, etc and it is scoped to the Type.
ID string `json:"id"`
// Type specifies the type of the Resource, eg. channel, post, etc.
Type string `json:"type"`
}
// AccessRequest represents the input to the Policy Decision Point (PDP).
// It contains the Subject, Resource, Action and optional Context attributes.
type AccessRequest struct {
Subject Subject `json:"subject"`
Resource Resource `json:"resource"`
Action string `json:"action"`
Context map[string]any `json:"context,omitempty"`
}
// AccessDecisionContextKeyReason is the AuthZEN decision-context key under
// which the PDP reports an AccessDecisionReason.
const AccessDecisionContextKeyReason = "reason"
// AccessDecisionReason enumerates the well-known reasons the PDP reports in
// the decision context.
type AccessDecisionReason string
// AccessDecisionReasonNoPolicy marks an allow as vacuous: no policy governs
// the request, so callers may apply their own defaults instead of treating the
// allow as an explicit grant.
const AccessDecisionReasonNoPolicy AccessDecisionReason = "no_policy"
// AccessDecision is the PDP's answer to an AccessRequest. It follows the
// OpenID AuthZEN evaluation response: a boolean Decision plus an optional
// Context carrying additional detail.
type AccessDecision struct {
Decision bool `json:"decision"`
Context map[string]any `json:"context,omitempty"`
}
// NewNoPolicyAccessDecision returns the vacuous allow for a request no policy
// governs.
func NewNoPolicyAccessDecision() AccessDecision {
return AccessDecision{
Decision: true,
Context: map[string]any{AccessDecisionContextKeyReason: string(AccessDecisionReasonNoPolicy)},
}
}
// Reason returns the well-known reason recorded in the decision context, or
// the empty reason when the context carries none.
func (d AccessDecision) Reason() AccessDecisionReason {
reason, _ := d.Context[AccessDecisionContextKeyReason].(string)
return AccessDecisionReason(reason)
}
// IsNoPolicy reports whether the request is unregulated: no policy governs it,
// so the caller may apply its own defaults. A denial is never treated as a
// no-policy fallback, however it is labelled, so a contradictory response
// (decision false carrying the no_policy reason) stays a deny.
func (d AccessDecision) IsNoPolicy() bool {
return d.Decision && d.Reason() == AccessDecisionReasonNoPolicy
}
type QueryExpressionParams struct {
Expression string `json:"expression"`
Term string `json:"term"`
Limit int `json:"limit"`
After string `json:"after"`
ChannelId string `json:"channelId,omitempty"`
TeamId string `json:"teamId,omitempty"`
}
// PolicySimulationBlameSource enumerates where a deny originated when running
// the test (simulate) workflow against a draft policy.
const (
// PolicySimulationBlameSourceThisRule means the deny came from the rule
// that the author is currently editing.
PolicySimulationBlameSourceThisRule = "this_rule"
// PolicySimulationBlameSourceSiblingRule means the deny came from another
// rule inside the same draft policy (same channel, different role/action
// or different rule on the same role/action that resolves to deny).
PolicySimulationBlameSourceSiblingRule = "sibling_rule"
// PolicySimulationBlameSourceChannelPolicy means the deny came from a
// resource-policy rule that is not the one being edited but contributes
// to the same effective decision (e.g. an inherited parent policy).
PolicySimulationBlameSourceChannelPolicy = "channel_policy"
// PolicySimulationBlameSourceSystemPermission means the deny came from a
// truly higher-scoped, persisted permission policy. Distinct from
// PolicySimulationBlameSourcePeerPolicy (same-scope) — the simulator
// emits both as system_permission, but the public-server reclassifies
// peer-scope blame entries before the response leaves the server. The
// expression of an upper-scoped policy is intentionally not exposed
// to the simulate UI to preserve scope privacy.
PolicySimulationBlameSourceSystemPermission = "system_permission"
// PolicySimulationBlameSourcePeerPolicy means the deny came from another
// persisted policy at the SAME scope as the draft (same Type and same
// ParentID). It's carved out of system_permission by the public-server
// post-processing so the picker can show the peer's name + the failing
// rule's CEL expression instead of an opaque "upper-scoped policy"
// chip — at the editing scope, peers are visible to the author.
PolicySimulationBlameSourcePeerPolicy = "peer_policy"
// PolicySimulationBlameSourceNoApplicablePolicy is a synthetic blame
// source emitted by the simulator when the draft policy does not apply
// to a candidate user (e.g. a system_user user is added to test a
// system_admin policy). The decision is recorded as ALLOW (vacuously,
// because the policy is silent on this user) and the picker renders a
// "Policy doesn't apply" pill from this entry. Never produced by
// production evaluation — simulation-only.
PolicySimulationBlameSourceNoApplicablePolicy = "no_applicable_policy"
// PolicySimulationBlameSourceNoSessionData is a synthetic blame source
// emitted by the simulator when a picked user has no cached session
// attributes (and no explicit session_overrides) but the action's
// contributing rules reference user.session.*. The decision is recorded
// as a vacuous ALLOW so the picker renders a neutral "No recent
// session" pill instead of a misleading deny. Simulation-only.
PolicySimulationBlameSourceNoSessionData = "no_session_data"
// PolicySimulationBlameSourceSiblingSaved is attached to an ALLOW
// decision when the rule the author is editing alone would have DENIED
// the subject, but a sibling rule (same role + action, OR-combined at
// compile time) flipped the bucket back to ALLOW. Useful so the
// picker can surface "this rule alone wouldn't have allowed them — a
// sibling did". Simulation-only.
PolicySimulationBlameSourceSiblingSaved = "sibling_saved"
// PolicySimulationBlameSourceNoApplicableRule is the synthetic blame
// source the "this rule only" post-process emits when the rule the
// author is editing is silent on the subject — either a sibling
// rule's OR-bucket saved an otherwise-denied user, or the deny
// originated entirely outside the editing rule (upper-scoped policy,
// peer policy, etc.). The decision is normalized to a vacuous ALLOW
// like no_applicable_policy and the picker renders a neutral
// "This rule doesn't apply" pill from this entry instead of the
// misleading "Allowed · another rule" / plain "Allowed" chips that
// the sibling_saved / orphaned-deny branches used to surface.
// Simulation-only and only emitted under the "this_rule"
// EvaluationScope (the "All policies" view keeps the original
// sibling_saved chip because at that scope the other rule IS
// relevant context for the verdict).
PolicySimulationBlameSourceNoApplicableRule = "no_applicable_rule"
)
// PolicySimulationBlameOutcome enumerates the per-blame verdict the
// simulator records for a contributing policy. Most blame entries
// carry the deny that produced the overall decision (PolicySimulationBlameOutcomeDeny);
// the simulator additionally emits informational entries with PolicySimulationBlameOutcomeAllow
// so the picker can show "your draft policy allowed this user" in
// multi-policy contexts where a peer policy is the denier.
const (
PolicySimulationBlameOutcomeDeny = "deny"
PolicySimulationBlameOutcomeAllow = "allow"
)
// PolicySimulationBlame attributes a deny decision back to the rule or policy
// that caused it. Some entries are informational (Outcome="allow") rather
// than deniers — those exist so the picker can surface the editing draft's
// evaluation alongside any peer policies' deny attribution; consumers that
// only care about deny attribution should filter to Outcome=="" or
// Outcome==PolicySimulationBlameOutcomeDeny (empty Outcome is treated as
// deny for backward compatibility with simulator builds that pre-date the
// field).
type PolicySimulationBlame struct {
// Source is one of the PolicySimulationBlameSource* constants.
Source string `json:"source"`
// Outcome is one of the PolicySimulationBlameOutcome* constants.
// Defaults to "deny" semantically when empty (backward compat with
// older simulators) — every blame entry shipped before this field
// existed was a denier. The picker uses Outcome to differentiate
// the editing draft's "I allowed" informational entry from the
// peer policies that actually caused the deny so each can render
// with the right indicator.
Outcome string `json:"outcome,omitempty"`
// PolicyID is the ID of the contributing policy (for system permission
// or channel policy sources). Empty when the deny originated from the
// draft itself (no persisted ID exists yet).
PolicyID string `json:"policy_id,omitempty"`
// PolicyName is the human-readable name of the contributing policy.
PolicyName string `json:"policy_name,omitempty"`
// RuleName is the name of the contributing rule (v0.4 permission rules
// always carry a unique name within their policy).
RuleName string `json:"rule_name,omitempty"`
// Role is the scoped role (system_* or channel_*) of the contributing
// rule or policy. Useful for explaining hierarchy fallbacks.
Role string `json:"role,omitempty"`
// Expression is the CEL text of the contributing rule. Only populated
// for blame entries at the draft's own scope (this_rule, sibling_rule,
// sibling_saved, peer_policy). Truly upper-scoped sources
// (system_permission, channel_policy) deliberately omit this field so
// the simulate UI can't leak the expression of a policy outside the
// editing scope.
Expression string `json:"expression,omitempty"`
// EvaluationTree is the per-node evaluation breakdown of the
// contributing rule, mirroring the boolean shape of the CEL
// expression's AST. Same scope-privacy rule as Expression: only
// populated for draft-side / peer-policy blame; truly upper-scoped
// sources omit it. The simulate UI renders it as a structured
// AND/OR/NOT tree showing exactly which sub-expression(s) produced
// the deny.
EvaluationTree *PolicySimulationEvaluationNode `json:"evaluation_tree,omitempty"`
// MergedRules lists every authored rule that was OR-folded into
// `Expression` for this contribution (see engine.JoinExpressions).
// Populated only when the contributing scope has more than one
// rule sharing the same (role, action) — single-rule
// contributions leave this empty so the simulate UI can keep the
// simpler "Rule: <name>" header. Order mirrors the policy's rule
// order, which is also the order JoinExpressions used when
// constructing the merged expression — so a UI can number rules
// consistently with the merged tree's branches.
//
// Same scope-privacy rule as Expression: populated only for
// same-scope blame (this_rule / sibling_rule / sibling_saved /
// peer_policy). Truly upper-scoped sources never carry this so
// the picker can't enumerate the rules of an out-of-scope policy.
MergedRules []PolicySimulationMergedRule `json:"merged_rules,omitempty"`
}
// PolicySimulationMergedRule is one entry in a blame's MergedRules:
// the name + expression + standalone evaluation tree of a single rule
// that was OR-folded into the blame's merged expression. A standalone
// tree (computed against the same activation as the merged tree) lets
// the UI render a per-rule breakdown numbered 1..N alongside the
// merged tree, so authors can map specific branches back to the rule
// they came from. The standalone tree carries the same scope-privacy
// rule as the surrounding blame's Expression; truly upper-scoped
// blame never carries MergedRules at all.
type PolicySimulationMergedRule struct {
// Name of the contributing rule (matches AccessControlPolicy.Rules[i].Name).
Name string `json:"name"`
// Expression is the rule's CEL text, before JoinExpressions wraps
// it in parens for the OR-fold. Useful when the UI wants to show
// the contributing rule on its own without reparsing.
Expression string `json:"expression,omitempty"`
// EvaluationTree is the standalone per-node evaluation breakdown
// of just this rule's expression (not the merged whole). The
// outcome on the root reflects whether THIS rule alone matched
// for the subject, which is what the picker needs to render
// "rule 1: TRUE / rule 2: FALSE" per-rule chips above each tree.
EvaluationTree *PolicySimulationEvaluationNode `json:"evaluation_tree,omitempty"`
}
// Kind values for PolicySimulationEvaluationNode.Kind. Compound kinds
// carry children; leaf kinds carry attribute / actual / expected
// metadata. PolicySimulationEvaluationKindOther is the catch-all for
// shapes the simulator doesn't decompose (bare attribute reference,
// ternary, unknown call).
const (
PolicySimulationEvaluationKindAnd = "and"
PolicySimulationEvaluationKindOr = "or"
PolicySimulationEvaluationKindNot = "not"
PolicySimulationEvaluationKindCompare = "compare"
PolicySimulationEvaluationKindFunction = "function"
PolicySimulationEvaluationKindOther = "other"
)
// Outcome values for PolicySimulationEvaluationNode.Outcome. Mirrors
// the three-way truth result of CEL evaluation — a clean true/false,
// or an error condition (missing attribute, type mismatch).
const (
PolicySimulationEvaluationOutcomeTrue = "true"
PolicySimulationEvaluationOutcomeFalse = "false"
PolicySimulationEvaluationOutcomeError = "error"
)
// PolicySimulationEvaluationNode is a single node in the evaluation
// tree returned by the simulate-by-users endpoint when the simulator
// is asked to explain a deny. The tree mirrors the boolean shape of
// the failing rule's CEL expression — short-circuit branches are
// walked regardless of their parent's outcome so the consumer can
// render the state of every clause, not just the first one that
// decided the verdict.
type PolicySimulationEvaluationNode struct {
// Kind classifies the node (compound vs leaf vs other). One of the
// PolicySimulationEvaluationKind* constants above.
Kind string `json:"kind"`
// Expression is the textual form of THIS subtree, suitable for the
// UI to render a snippet without rebuilding text from the AST.
Expression string `json:"expression"`
// Outcome is the per-node verdict. One of the
// PolicySimulationEvaluationOutcome* constants.
Outcome string `json:"outcome"`
// Error is a human-readable description of an evaluation-time
// failure. Populated only when Outcome == "error".
Error string `json:"error,omitempty"`
// Operator names the leaf operation: "==", "!=", "<", ">", ">=",
// "<=", "in", "startsWith", "endsWith", "contains". Empty for
// compound and other nodes.
Operator string `json:"operator,omitempty"`
// Attribute is the user-attribute path the leaf references when
// it could be unambiguously identified
// (e.g. user.attributes.region). Empty when the leaf does not
// reference an attribute or when both sides are non-attribute
// expressions.
Attribute string `json:"attribute,omitempty"`
// ActualValue is a display-formatted rendering of the user's
// value for Attribute. Empty when the attribute is missing — a
// missing attribute is also reflected in Outcome="error".
ActualValue string `json:"actual_value,omitempty"`
// ExpectedValue is a display-formatted rendering of the literal
// (or list of literals) the leaf compared against. Empty when the
// other side is itself an attribute reference.
ExpectedValue string `json:"expected_value,omitempty"`
// Children are the operands of a compound node, walked in
// expression order. Empty for leaf and other nodes.
Children []PolicySimulationEvaluationNode `json:"children,omitempty"`
}
// PolicySimulationActionDecision is the per-action verdict for a single user.
type PolicySimulationActionDecision struct {
Decision bool `json:"decision"`
Blame []PolicySimulationBlame `json:"blame,omitempty"`
}
// PolicySimulationSession is the per-session breakdown entry for the
// simulate-by-users response. Populated when the caller requests per-session
// evaluation (typically a system admin: their active sessions are individually
// evaluated so the picker can show why two sessions of the same user come
// back with different verdicts). Channel admins receive at most a single
// synthetic session populated with default values that they can override
// through the per-row session-attribute editor.
type PolicySimulationSession struct {
// ID is the persistent session identifier. Empty for synthetic sessions.
ID string `json:"id,omitempty"`
// Device is a human-readable device/client label (e.g. "MacBook Pro").
Device string `json:"device,omitempty"`
// Network classifies the connection (e.g. "WiFi", "VPN", "Mobile").
Network string `json:"network,omitempty"`
// LastActiveAt is the last-active timestamp in milliseconds since epoch.
LastActiveAt int64 `json:"last_active_at,omitempty"`
// Decisions maps action name → verdict for THIS session specifically,
// using the session's own session.* attributes (the user's profile
// attributes are constant across sessions).
Decisions map[string]PolicySimulationActionDecision `json:"decisions,omitempty"`
// Attributes is the session-attribute snapshot the simulator used when
// evaluating this session (network_status, device_managed, ip_range,
// etc.). Surfaced to the picker's "Decision details" view so the
// author can read the deny like an evaluation trace. Optional — omitted
// when the simulator hasn't populated it.
Attributes map[string]string `json:"attributes,omitempty"`
}
// PolicySimulationUserResult is one row in the simulation response.
type PolicySimulationUserResult struct {
User *User `json:"user"`
// Decisions maps action name → verdict. Always populated when the
// simulation request had non-empty Actions; nil when ExpressionOnly is
// true (fallback mode). When Sessions is populated, this represents the
// "headline" decision (e.g. from the most-recently-active session) so
// the picker can render a single chip without consulting Sessions.
Decisions map[string]PolicySimulationActionDecision `json:"decisions,omitempty"`
// Sessions is the optional per-session breakdown. Empty/nil falls back
// to the user-level Decisions only.
Sessions []PolicySimulationSession `json:"sessions,omitempty"`
// Attributes is the user profile attribute snapshot the simulator used
// when evaluating this user (department, region, clearance, etc.).
// Surfaced to the picker's "Decision details" view so the author can
// read the deny as an evaluation trace. Optional — omitted when the
// simulator hasn't populated it.
Attributes map[string]string `json:"attributes,omitempty"`
}
// PolicySimulationResponse is the body returned by cel/simulate_users.
type PolicySimulationResponse struct {
Results []PolicySimulationUserResult `json:"results"`
Total int64 `json:"total"`
}
// PolicySimulationUserOverride captures the per-user inputs the picker UI
// sends to /access_control_policies/cel/simulate_users. The simulator
// resolves each user's profile attributes from CPA storage and then layers
// session context on top: the requesting admin's resolved session
// attributes (the same user_agent_* / ip_address bag the live PDP reads
// via App.GetSessionAttributes) are applied as a baseline, then the
// explicit SessionOverrides map overrides individual keys.
type PolicySimulationUserOverride struct {
// UserID identifies the user to simulate against.
UserID string `json:"user_id"`
// UseActiveSession is retained for API backward compatibility. The
// simulator now always layers the requesting admin's resolved session
// snapshot under SessionOverrides — leaving overrides empty means
// "evaluate against the session as the server resolves it" — so this
// flag is effectively a no-op. New clients should leave it unset.
UseActiveSession bool `json:"use_active_session,omitempty"`
// SessionOverrides replaces individual session.* attributes for this
// user only. Applied on top of the active-session snapshot when both
// are set, so a future "configure" panel can shadow specific values
// without discarding the rest of the active session.
//
// Mirrors the shape of Subject.Session (map[string]any) so the picker
// can carry mixed-typed session attributes (e.g. boolean
// device_managed alongside string network_status) without coercing
// everything through string. Nested maps / slices flow through to the
// CEL evaluator unchanged.
SessionOverrides map[string]any `json:"session_overrides,omitempty"`
}
// PolicyEvaluationScope* constants enumerate the supported evaluation
// scopes for /cel/simulate_users.
const (
// PolicyEvaluationScopeThisRule evaluates ONLY the rule the author is
// editing — sibling rules in the same policy, system permission
// policies, imported parent policies, and any other peer policies are
// excluded. This is the authoring-time "what does this rule alone do?"
// view: useful for iterating on a single rule's expression without
// other rules shadowing or compensating for it. Default when the
// request omits EvaluationScope.
PolicyEvaluationScopeThisRule = "this_rule"
// PolicyEvaluationScopeAll co-evaluates every contributing program —
// the entire draft policy (all rules), persisted system permission
// policies, parent policies — exactly as the live PDP would at
// request time. This is the "what verdict will the user actually
// experience?" view.
PolicyEvaluationScopeAll = "all"
)
// PolicySimulationByUsersParams is the request body for
// /access_control_policies/cel/simulate_users.
//
// The picker-based "Simulate access" UX hand-selects users to dry-run a
// draft policy against. Each user is run through the same dual-lane PDP
// path the live request would take and the response carries per-user,
// per-action ALLOW/DENY decisions plus blame attribution.
type PolicySimulationByUsersParams struct {
// Policy is the draft policy as it currently sits in the editor. Not
// persisted; compiled in-memory only.
Policy *AccessControlPolicy `json:"policy"`
// Actions is the set of permission actions to simulate. Required —
// a picker UX only makes sense once an action is in scope.
Actions []string `json:"actions"`
// RuleName identifies which rule in Policy.Rules the author is
// editing (used for blame attribution). Optional. When set, denies
// originating from this rule are tagged source=this_rule; other
// denies in the same draft are tagged source=sibling_rule.
RuleName string `json:"rule_name,omitempty"`
// ChannelID and TeamID provide context for delegated admin auth and
// channel-scope evaluation.
ChannelID string `json:"channel_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
// Users is the explicit set of users to evaluate, with per-user
// session-attribute overrides.
Users []PolicySimulationUserOverride `json:"users"`
// EvaluationScope selects whether the simulator considers only the
// rule under simulation (this_rule) or co-evaluates every contributing
// program (all). Empty defaults to this_rule on the server.
EvaluationScope string `json:"evaluation_scope,omitempty"`
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestAccessDecisionJSON(t *testing.T) {
t.Run("bare decision omits the context", func(t *testing.T) {
data, err := json.Marshal(AccessDecision{Decision: true})
require.NoError(t, err)
require.JSONEq(t, `{"decision":true}`, string(data))
})
t.Run("no-policy decision round-trips", func(t *testing.T) {
in := NewNoPolicyAccessDecision()
data, err := json.Marshal(in)
require.NoError(t, err)
require.JSONEq(t, `{"decision":true,"context":{"reason":"no_policy"}}`, string(data))
var out AccessDecision
require.NoError(t, json.Unmarshal(data, &out))
require.Equal(t, in, out)
require.True(t, out.IsNoPolicy())
})
}
func TestAccessDecisionIsNoPolicy(t *testing.T) {
tests := []struct {
name string
decision AccessDecision
want bool
}{
{"no context", AccessDecision{Decision: true}, false},
{"no reason key", AccessDecision{Decision: true, Context: map[string]any{"other": "x"}}, false},
{"non-string reason", AccessDecision{Decision: true, Context: map[string]any{AccessDecisionContextKeyReason: 1}}, false},
{"unrelated reason", AccessDecision{Decision: true, Context: map[string]any{AccessDecisionContextKeyReason: "whatever"}}, false},
{"no_policy reason", NewNoPolicyAccessDecision(), true},
// A deny must never be classified as an unregulated request, however
// the context labels it.
{
"deny contradicting the no_policy reason",
AccessDecision{Decision: false, Context: map[string]any{AccessDecisionContextKeyReason: string(AccessDecisionReasonNoPolicy)}},
false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, tc.decision.IsNoPolicy())
})
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAccessIsValid(t *testing.T) {
ad := AccessData{}
require.NotNil(t, ad.IsValid())
ad.ClientId = NewRandomString(28)
require.NotNil(t, ad.IsValid())
ad.ClientId = ""
require.NotNil(t, ad.IsValid())
ad.ClientId = NewId()
require.NotNil(t, ad.IsValid())
ad.UserId = NewRandomString(28)
require.NotNil(t, ad.IsValid())
ad.UserId = ""
require.NotNil(t, ad.IsValid())
ad.UserId = NewId()
require.NotNil(t, ad.IsValid())
ad.Token = NewRandomString(22)
require.NotNil(t, ad.IsValid())
ad.Token = NewId()
require.NotNil(t, ad.IsValid())
ad.RefreshToken = NewRandomString(28)
require.NotNil(t, ad.IsValid())
ad.RefreshToken = NewId()
require.NotNil(t, ad.IsValid())
ad.RedirectUri = ""
require.NotNil(t, ad.IsValid())
ad.RedirectUri = NewRandomString(28)
require.NotNil(t, ad.IsValid())
ad.RedirectUri = "http://example.com"
require.Nil(t, ad.IsValid())
}
func TestAccessData_AudienceValidation(t *testing.T) {
// Create a valid AccessData base
accessData := &AccessData{
ClientId: NewId(),
UserId: NewId(),
Token: NewId(),
RefreshToken: "",
RedirectUri: "https://example.com/callback",
ExpiresAt: GetMillis() + 3600000,
Scope: "user",
}
t.Run("Valid AccessData without audience should pass", func(t *testing.T) {
require.Nil(t, accessData.IsValid())
})
t.Run("Valid AccessData with valid audience should pass", func(t *testing.T) {
accessData.Audience = "https://api.example.com"
require.Nil(t, accessData.IsValid())
})
t.Run("AccessData with invalid audience should fail", func(t *testing.T) {
accessData.Audience = "invalid-audience"
require.NotNil(t, accessData.IsValid())
})
t.Run("AccessData with audience containing fragment should fail", func(t *testing.T) {
accessData.Audience = "https://api.example.com/resource#fragment"
require.NotNil(t, accessData.IsValid())
})
t.Run("AccessData with audience too long should fail", func(t *testing.T) {
// Create an audience longer than 512 characters
longPath := make([]byte, 500) // Base URI will push us over 512
for i := range longPath {
longPath[i] = 'a'
}
accessData.Audience = "https://example.com/" + string(longPath)
require.NotNil(t, accessData.IsValid())
})
t.Run("AccessData with empty audience should pass", func(t *testing.T) {
accessData.Audience = ""
require.Nil(t, accessData.IsValid())
})
t.Run("AccessData with audience containing query parameters should pass", func(t *testing.T) {
accessData.Audience = "https://api.example.com/resource?param=value"
require.Nil(t, accessData.IsValid())
})
t.Run("AccessData with various valid audience formats should pass", func(t *testing.T) {
validAudiences := []string{
"https://api.example.com",
"https://api.example.com/v1",
"https://api.example.com:8080/resource",
"http://localhost:3000/api",
"https://sub.domain.example.com/path/to/resource",
"ftp://example.com/file", // Any absolute URI is valid per RFC 8707
}
for _, audience := range validAudiences {
accessData.Audience = audience
require.Nil(t, accessData.IsValid(),
"Expected valid audience '%s' to pass validation", audience)
}
})
t.Run("AccessData with various invalid audience formats should fail", func(t *testing.T) {
invalidAudiences := []string{
"not-a-uri",
"relative/path",
"/absolute/path",
"malformed://[invalid",
}
for _, audience := range invalidAudiences {
accessData.Audience = audience
require.NotNil(t, accessData.IsValid(),
"Expected invalid audience '%s' to fail validation", audience)
}
})
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type BridgeAgentInfo struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Username string `json:"username"`
ServiceID string `json:"service_id"`
ServiceType string `json:"service_type"`
IsDefault bool `json:"is_default,omitempty"`
}
type BridgeServiceInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
type AgentsIntegrityResponse struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type AIBridgeTestHelperStatus struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
type AIBridgeTestHelperCompletion struct {
Completion string `json:"completion,omitempty"`
Error string `json:"error,omitempty"`
StatusCode int `json:"status_code,omitempty"`
}
type AIBridgeTestHelperMessage struct {
Role string `json:"role"`
Message string `json:"message"`
FileIDs []string `json:"file_ids,omitempty"`
}
type AIBridgeTestHelperFeatureFlags struct {
EnableAIPluginBridge *bool `json:"enable_ai_plugin_bridge,omitempty"`
EnableAIRecaps *bool `json:"enable_ai_recaps,omitempty"`
}
type AIBridgeTestHelperConfig struct {
Status *AIBridgeTestHelperStatus `json:"status,omitempty"`
Agents []BridgeAgentInfo `json:"agents,omitempty"`
Services []BridgeServiceInfo `json:"services,omitempty"`
AgentCompletions map[string][]AIBridgeTestHelperCompletion `json:"agent_completions,omitempty"`
FeatureFlags *AIBridgeTestHelperFeatureFlags `json:"feature_flags,omitempty"`
RecordRequests *bool `json:"record_requests,omitempty"`
}
type AIBridgeTestHelperRecordedRequest struct {
Operation string `json:"operation"`
ClientOperation string `json:"client_operation,omitempty"`
OperationSubType string `json:"operation_sub_type,omitempty"`
SessionUserID string `json:"session_user_id,omitempty"`
UserID string `json:"user_id,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
ServiceID string `json:"service_id,omitempty"`
Messages []AIBridgeTestHelperMessage `json:"messages"`
JSONOutputFormat map[string]any `json:"json_output_format,omitempty"`
}
type AIBridgeTestHelperState struct {
Status *AIBridgeTestHelperStatus `json:"status,omitempty"`
Agents []BridgeAgentInfo `json:"agents,omitempty"`
Services []BridgeServiceInfo `json:"services,omitempty"`
AgentCompletions map[string][]AIBridgeTestHelperCompletion `json:"agent_completions,omitempty"`
FeatureFlags *AIBridgeTestHelperFeatureFlags `json:"feature_flags,omitempty"`
RecordRequests bool `json:"record_requests"`
RecordedRequests []AIBridgeTestHelperRecordedRequest `json:"recorded_requests"`
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// UnlimitedValue is the sentinel value indicating a limit is disabled/unlimited
const UnlimitedValue = -1
// LimitSource indicates where the effective limits originated from
type LimitSource string
const (
LimitSourceSystem LimitSource = "system"
LimitSourceGroup LimitSource = "group"
LimitSourceUser LimitSource = "user"
)
// EffectiveRecapLimits contains resolved limit values for a user.
// These are non-pointer fields because resolution has already happened.
// A value of -1 (UnlimitedValue) means the limit is disabled/unlimited.
type EffectiveRecapLimits struct {
// Resolved limit values (-1 = unlimited/disabled)
MaxRecapsPerDay int `json:"max_recaps_per_day"`
MaxScheduledRecaps int `json:"max_scheduled_recaps"`
MaxChannelsPerRecap int `json:"max_channels_per_recap"`
MaxPostsPerRecap int `json:"max_posts_per_recap"`
MaxTokensPerRecap int `json:"max_tokens_per_recap"`
MaxPostsPerDay int `json:"max_posts_per_day"`
CooldownMinutes int `json:"cooldown_minutes"`
// Source tracking (for debugging/UI display)
Source LimitSource `json:"source"` // Where limits came from
SourceID string `json:"source_id"` // Group ID or User ID if overridden, empty for system
}
// RecapLimitStatus contains the current user's limit status for UI display
type RecapLimitStatus struct {
EffectiveLimits EffectiveRecapLimits `json:"effective_limits"`
Daily DailyUsageStatus `json:"daily"`
Cooldown CooldownStatus `json:"cooldown"`
}
// DailyUsageStatus tracks daily recap usage
type DailyUsageStatus struct {
Used int `json:"used"`
Limit int `json:"limit"`
ResetAt int64 `json:"reset_at"` // Unix timestamp ms for midnight in user timezone
}
// CooldownStatus tracks cooldown state
type CooldownStatus struct {
IsActive bool `json:"is_active"`
AvailableAt int64 `json:"available_at"` // Unix timestamp ms when cooldown ends
RetryAfterSeconds int `json:"retry_after_seconds"` // Seconds until available
}
// IsLimitEnabled returns true if the given limit value is enabled (not unlimited).
// Useful for enforcement code to check if a limit should be enforced.
func IsLimitEnabled(limitValue int) bool {
return limitValue != UnlimitedValue
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
)
// RecapLimitSettings configures the limits for AI Recaps
type RecapLimitSettings struct {
MaxRecapsPerDay *int `access:"ai_recaps"` // Default: 10, -1 = unlimited
MaxScheduledRecaps *int `access:"ai_recaps"` // Default: 5, -1 = unlimited
MaxChannelsPerRecap *int `access:"ai_recaps"` // Default: -1 (unlimited)
MaxPostsPerRecap *int `access:"ai_recaps"` // Default: 500, -1 = unlimited
MaxTokensPerRecap *int `access:"ai_recaps"` // Default: 100000, -1 = unlimited
MaxPostsPerDay *int `access:"ai_recaps"` // Default: 5000, -1 = unlimited
CooldownMinutes *int `access:"ai_recaps"` // Default: 60, 0 = no cooldown
}
// SetDefaults sets the default values for RecapLimitSettings
func (s *RecapLimitSettings) SetDefaults() {
if s.MaxRecapsPerDay == nil {
s.MaxRecapsPerDay = NewPointer(10)
}
if s.MaxScheduledRecaps == nil {
s.MaxScheduledRecaps = NewPointer(5)
}
if s.MaxChannelsPerRecap == nil {
s.MaxChannelsPerRecap = NewPointer(-1) // unlimited by default
}
if s.MaxPostsPerRecap == nil {
s.MaxPostsPerRecap = NewPointer(500)
}
if s.MaxTokensPerRecap == nil {
s.MaxTokensPerRecap = NewPointer(100000)
}
if s.MaxPostsPerDay == nil {
s.MaxPostsPerDay = NewPointer(5000)
}
if s.CooldownMinutes == nil {
s.CooldownMinutes = NewPointer(60)
}
}
// isValid validates the RecapLimitSettings
func (s *RecapLimitSettings) isValid() *AppError {
// MaxRecapsPerDay: must be >= 1 OR == -1 (unlimited)
if s.MaxRecapsPerDay != nil && *s.MaxRecapsPerDay != -1 && *s.MaxRecapsPerDay < 1 {
return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", nil, "", http.StatusBadRequest)
}
// MaxScheduledRecaps: must be >= 1 OR == -1 (unlimited)
if s.MaxScheduledRecaps != nil && *s.MaxScheduledRecaps != -1 && *s.MaxScheduledRecaps < 1 {
return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_scheduled_recaps.app_error", nil, "", http.StatusBadRequest)
}
// MaxChannelsPerRecap: must be >= 1 OR == -1 (unlimited)
if s.MaxChannelsPerRecap != nil && *s.MaxChannelsPerRecap != -1 && *s.MaxChannelsPerRecap < 1 {
return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_channels_per_recap.app_error", nil, "", http.StatusBadRequest)
}
// MaxPostsPerRecap: must be >= 1 OR == -1 (unlimited)
if s.MaxPostsPerRecap != nil && *s.MaxPostsPerRecap != -1 && *s.MaxPostsPerRecap < 1 {
return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_posts_per_recap.app_error", nil, "", http.StatusBadRequest)
}
// MaxTokensPerRecap: must be >= 1 OR == -1 (unlimited)
if s.MaxTokensPerRecap != nil && *s.MaxTokensPerRecap != -1 && *s.MaxTokensPerRecap < 1 {
return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_tokens_per_recap.app_error", nil, "", http.StatusBadRequest)
}
// MaxPostsPerDay: must be >= 1 OR == -1 (unlimited)
if s.MaxPostsPerDay != nil && *s.MaxPostsPerDay != -1 && *s.MaxPostsPerDay < 1 {
return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.max_posts_per_day.app_error", nil, "", http.StatusBadRequest)
}
// CooldownMinutes: must be >= 0 (0 = no cooldown)
if s.CooldownMinutes != nil && *s.CooldownMinutes < 0 {
return NewAppError("Config.IsValid", "model.config.is_valid.ai_recap.cooldown_minutes.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
// AIRecapSettings configures the AI Recap feature limits
type AIRecapSettings struct {
Enable *bool `access:"ai_recaps"` // Master toggle, default: true
// System-wide default limits
DefaultLimits *RecapLimitSettings `access:"ai_recaps"`
// Per-limit enforcement toggles (all default to true)
EnforceRecapsPerDay *bool `access:"ai_recaps"`
EnforceScheduledRecaps *bool `access:"ai_recaps"`
EnforceChannelsPerRecap *bool `access:"ai_recaps"`
EnforcePostsPerRecap *bool `access:"ai_recaps"`
EnforceTokensPerRecap *bool `access:"ai_recaps"`
EnforcePostsPerDay *bool `access:"ai_recaps"`
EnforceCooldown *bool `access:"ai_recaps"`
}
// SetDefaults sets the default values for AIRecapSettings
func (s *AIRecapSettings) SetDefaults() {
if s.Enable == nil {
s.Enable = NewPointer(true)
}
if s.DefaultLimits == nil {
s.DefaultLimits = &RecapLimitSettings{}
}
s.DefaultLimits.SetDefaults()
if s.EnforceRecapsPerDay == nil {
s.EnforceRecapsPerDay = NewPointer(true)
}
if s.EnforceScheduledRecaps == nil {
s.EnforceScheduledRecaps = NewPointer(true)
}
if s.EnforceChannelsPerRecap == nil {
s.EnforceChannelsPerRecap = NewPointer(true)
}
if s.EnforcePostsPerRecap == nil {
s.EnforcePostsPerRecap = NewPointer(true)
}
if s.EnforceTokensPerRecap == nil {
s.EnforceTokensPerRecap = NewPointer(true)
}
if s.EnforcePostsPerDay == nil {
s.EnforcePostsPerDay = NewPointer(true)
}
if s.EnforceCooldown == nil {
s.EnforceCooldown = NewPointer(true)
}
}
// IsEnabled reports whether the admin AI Recaps master toggle is enabled.
func (s *AIRecapSettings) IsEnabled() bool {
return s == nil || s.Enable == nil || *s.Enable
}
// AIRecapsEnabled reports whether AI Recaps are enabled by both feature flag and admin config.
func (o *Config) AIRecapsEnabled() bool {
return o != nil && o.FeatureFlags.EnableAIRecaps && o.AIRecapSettings.IsEnabled()
}
// IsValid validates the AIRecapSettings
func (s *AIRecapSettings) IsValid() *AppError {
if s.DefaultLimits != nil {
if appErr := s.DefaultLimits.isValid(); appErr != nil {
return appErr
}
}
return nil
}

View File

@@ -0,0 +1,236 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAIRecapSettingsSetDefaults(t *testing.T) {
t.Run("sets all defaults on empty struct", func(t *testing.T) {
s := &AIRecapSettings{}
s.SetDefaults()
// Master toggle
require.NotNil(t, s.Enable)
assert.True(t, *s.Enable)
// Enforcement toggles - all should be true
require.NotNil(t, s.EnforceRecapsPerDay)
assert.True(t, *s.EnforceRecapsPerDay)
require.NotNil(t, s.EnforceScheduledRecaps)
assert.True(t, *s.EnforceScheduledRecaps)
require.NotNil(t, s.EnforceChannelsPerRecap)
assert.True(t, *s.EnforceChannelsPerRecap)
require.NotNil(t, s.EnforcePostsPerRecap)
assert.True(t, *s.EnforcePostsPerRecap)
require.NotNil(t, s.EnforceTokensPerRecap)
assert.True(t, *s.EnforceTokensPerRecap)
require.NotNil(t, s.EnforcePostsPerDay)
assert.True(t, *s.EnforcePostsPerDay)
require.NotNil(t, s.EnforceCooldown)
assert.True(t, *s.EnforceCooldown)
// Default limits
require.NotNil(t, s.DefaultLimits)
require.NotNil(t, s.DefaultLimits.MaxRecapsPerDay)
assert.Equal(t, 10, *s.DefaultLimits.MaxRecapsPerDay)
require.NotNil(t, s.DefaultLimits.MaxScheduledRecaps)
assert.Equal(t, 5, *s.DefaultLimits.MaxScheduledRecaps)
require.NotNil(t, s.DefaultLimits.MaxChannelsPerRecap)
assert.Equal(t, -1, *s.DefaultLimits.MaxChannelsPerRecap) // unlimited
require.NotNil(t, s.DefaultLimits.MaxPostsPerRecap)
assert.Equal(t, 500, *s.DefaultLimits.MaxPostsPerRecap)
require.NotNil(t, s.DefaultLimits.MaxTokensPerRecap)
assert.Equal(t, 100000, *s.DefaultLimits.MaxTokensPerRecap)
require.NotNil(t, s.DefaultLimits.MaxPostsPerDay)
assert.Equal(t, 5000, *s.DefaultLimits.MaxPostsPerDay)
require.NotNil(t, s.DefaultLimits.CooldownMinutes)
assert.Equal(t, 60, *s.DefaultLimits.CooldownMinutes)
})
}
func TestRecapLimitSettingsValidation(t *testing.T) {
t.Run("valid positive limits", func(t *testing.T) {
s := &RecapLimitSettings{
MaxRecapsPerDay: NewPointer(10),
MaxScheduledRecaps: NewPointer(5),
MaxChannelsPerRecap: NewPointer(3),
MaxPostsPerRecap: NewPointer(500),
MaxTokensPerRecap: NewPointer(100000),
MaxPostsPerDay: NewPointer(5000),
CooldownMinutes: NewPointer(60),
}
assert.Nil(t, s.isValid())
})
t.Run("valid unlimited (-1) values", func(t *testing.T) {
s := &RecapLimitSettings{
MaxRecapsPerDay: NewPointer(-1),
MaxScheduledRecaps: NewPointer(-1),
MaxChannelsPerRecap: NewPointer(-1),
MaxPostsPerRecap: NewPointer(-1),
MaxTokensPerRecap: NewPointer(-1),
MaxPostsPerDay: NewPointer(-1),
CooldownMinutes: NewPointer(0), // 0 = no cooldown
}
assert.Nil(t, s.isValid())
})
t.Run("valid cooldown of 0", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.CooldownMinutes = NewPointer(0)
assert.Nil(t, s.isValid())
})
t.Run("invalid MaxRecapsPerDay = 0", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.MaxRecapsPerDay = NewPointer(0)
err := s.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", err.Id)
})
t.Run("invalid MaxRecapsPerDay = -2 (only -1 allowed for unlimited)", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.MaxRecapsPerDay = NewPointer(-2)
err := s.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", err.Id)
})
t.Run("invalid MaxScheduledRecaps = 0", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.MaxScheduledRecaps = NewPointer(0)
err := s.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.max_scheduled_recaps.app_error", err.Id)
})
t.Run("invalid MaxChannelsPerRecap = 0", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.MaxChannelsPerRecap = NewPointer(0)
err := s.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.max_channels_per_recap.app_error", err.Id)
})
t.Run("invalid MaxPostsPerRecap = 0", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.MaxPostsPerRecap = NewPointer(0)
err := s.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.max_posts_per_recap.app_error", err.Id)
})
t.Run("invalid MaxTokensPerRecap = 0", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.MaxTokensPerRecap = NewPointer(0)
err := s.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.max_tokens_per_recap.app_error", err.Id)
})
t.Run("invalid MaxPostsPerDay = 0", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.MaxPostsPerDay = NewPointer(0)
err := s.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.max_posts_per_day.app_error", err.Id)
})
t.Run("invalid CooldownMinutes negative", func(t *testing.T) {
s := &RecapLimitSettings{}
s.SetDefaults()
s.CooldownMinutes = NewPointer(-1)
err := s.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.cooldown_minutes.app_error", err.Id)
})
}
func TestAIRecapSettingsPreservesExistingValues(t *testing.T) {
t.Run("preserves existing MaxRecapsPerDay", func(t *testing.T) {
s := &AIRecapSettings{
DefaultLimits: &RecapLimitSettings{
MaxRecapsPerDay: NewPointer(20),
},
}
s.SetDefaults()
assert.Equal(t, 20, *s.DefaultLimits.MaxRecapsPerDay)
})
t.Run("preserves existing Enable value", func(t *testing.T) {
s := &AIRecapSettings{
Enable: NewPointer(false),
}
s.SetDefaults()
assert.False(t, *s.Enable)
})
t.Run("preserves existing enforcement toggle", func(t *testing.T) {
s := &AIRecapSettings{
EnforceRecapsPerDay: NewPointer(false),
}
s.SetDefaults()
assert.False(t, *s.EnforceRecapsPerDay)
// Other toggles should be set to true (default)
assert.True(t, *s.EnforceScheduledRecaps)
})
}
func TestAIRecapSettingsIsValid(t *testing.T) {
t.Run("valid with defaults", func(t *testing.T) {
s := &AIRecapSettings{}
s.SetDefaults()
assert.Nil(t, s.IsValid())
})
t.Run("valid with nil DefaultLimits", func(t *testing.T) {
s := &AIRecapSettings{
Enable: NewPointer(true),
}
assert.Nil(t, s.IsValid())
})
t.Run("invalid if DefaultLimits are invalid", func(t *testing.T) {
s := &AIRecapSettings{
Enable: NewPointer(true),
DefaultLimits: &RecapLimitSettings{
MaxRecapsPerDay: NewPointer(0), // invalid
},
}
err := s.IsValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.ai_recap.max_recaps_per_day.app_error", err.Id)
})
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type AnalyticsRow struct {
Name string `json:"name"`
Value float64 `json:"value"`
}
type AnalyticsRows []*AnalyticsRow

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type Audit struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UserId string `json:"user_id"`
Action string `json:"action"`
ExtraInfo string `json:"extra_info"`
IpAddress string `json:"ip_address"`
SessionId string `json:"session_id"`
}

View File

@@ -0,0 +1,558 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// Access Control & Security
const (
AuditEventApplyIPFilters = "applyIPFilters" // apply IP address filtering
AuditEventAssignAccessPolicy = "assignAccessPolicy" // assign access control policy to channels and/or teams
AuditEventCreateAccessControlPolicy = "createAccessControlPolicy" // create access control policy
AuditEventDeleteAccessControlPolicy = "deleteAccessControlPolicy" // delete access control policy
AuditEventUnassignAccessPolicy = "unassignAccessPolicy" // remove access control policy from channels and/or teams
AuditEventUpdateActiveStatus = "updateActiveStatus" // update active/inactive status of access control policy
AuditEventSetActiveStatus = "setActiveStatus" // set active/inactive status of multiple access control policies
AuditEventSavePluginAccessControlPolicy = "savePluginAccessControlPolicy" // create/update plugin-owned access control policy (activation implicit)
AuditEventDeletePluginAccessControlPolicy = "deletePluginAccessControlPolicy" // delete plugin-owned access control policy
AuditEventCreateTeamAccessPolicy = "createTeamAccessPolicy" // create team-scoped access control policy
AuditEventUpdateTeamAccessPolicy = "updateTeamAccessPolicy" // update team-scoped access control policy
AuditEventDeleteTeamAccessPolicy = "deleteTeamAccessPolicy" // delete team-scoped access control policy
AuditEventAssignTeamAccessPolicy = "assignTeamAccessPolicy" // assign channels to team-scoped access control policy
AuditEventUnassignTeamAccessPolicy = "unassignTeamAccessPolicy" // remove channels from team-scoped access control policy
AuditEventTriggerTeamPolicySync = "triggerTeamPolicySync" // trigger sync for team-scoped access control policies
AuditEventTeamMembershipAdded = "teamMembershipAdded" // user auto-added to a team by its membership policy
AuditEventTeamMembershipRemoved = "teamMembershipRemoved" // user removed from a team by its membership policy
AuditEventTeamCascadedChannelRemoval = "teamCascadedChannelRemoval" // channel membership dropped as a cascade of a policy-driven team removal
)
// Audit & Certificates
const (
AuditEventAddAuditLogCertificate = "addAuditLogCertificate" // add certificate for secure audit log transmission
AuditEventGetAudits = "getAudits" // get audit log entries
AuditEventGetUserAudits = "getUserAudits" // get audit log entries for specific user
AuditEventRemoveAuditLogCertificate = "removeAuditLogCertificate" // remove certificate used for audit log transmission
)
// Bots
const (
AuditEventAssignBot = "assignBot" // assign bot to user
AuditEventConvertBotToUser = "convertBotToUser" // convert bot account to regular user account
AuditEventConvertUserToBot = "convertUserToBot" // convert regular user account to bot account
AuditEventCreateBot = "createBot" // create bot account
AuditEventPatchBot = "patchBot" // update bot properties
AuditEventUpdateBotActive = "updateBotActive" // enable or disable bot account
)
// Branding
const (
AuditEventDeleteBrandImage = "deleteBrandImage" // delete brand image
AuditEventUploadBrandImage = "uploadBrandImage" // upload brand image
)
// Channel Bookmarks
const (
AuditEventCreateChannelBookmark = "createChannelBookmark" // create bookmark in channels
AuditEventDeleteChannelBookmark = "deleteChannelBookmark" // delete bookmark
AuditEventUpdateChannelBookmark = "updateChannelBookmark" // update bookmark
AuditEventUpdateChannelBookmarkSortOrder = "updateChannelBookmarkSortOrder" // update display order of bookmarks
AuditEventListChannelBookmarksForChannel = "listChannelBookmarksForChannel" // list bookmarks for channel
)
// Boards
const (
AuditEventCreateBoard = "createBoard" // create board channel
)
// Views
const (
AuditEventCreateView = "createView" // create view in channel
AuditEventGetView = "getView" // get view by ID
AuditEventUpdateView = "updateView" // update view
AuditEventDeleteView = "deleteView" // delete view
AuditEventListViewsForChannel = "listViewsForChannel" // list views for channel
AuditEventUpdateViewSortOrder = "updateViewSortOrder" // update view sort order
AuditEventGetPostsForView = "getPostsForView" // get posts for view
)
// Channel Categories
const (
AuditEventCreateCategoryForTeamForUser = "createCategoryForTeamForUser" // create channel category for user
AuditEventDeleteCategoryForTeamForUser = "deleteCategoryForTeamForUser" // delete channel category
AuditEventUpdateCategoriesForTeamForUser = "updateCategoriesForTeamForUser" // update multiple channel categories
AuditEventUpdateCategoryForTeamForUser = "updateCategoryForTeamForUser" // update single channel category
AuditEventUpdateCategoryOrderForTeamForUser = "updateCategoryOrderForTeamForUser" // update display order of the categories
)
// Channels
const (
AuditEventAddChannelMember = "addChannelMember" // add member to channel
AuditEventConvertGroupMessageToChannel = "convertGroupMessageToChannel" // convert group message to private channel
AuditEventCreateChannel = "createChannel" // create public or private channel
AuditEventCreateChannelJoinRequest = "createChannelJoinRequest" // request to join a discoverable private channel
AuditEventUpdateChannelJoinRequest = "updateChannelJoinRequest" // approve or deny a channel join request
AuditEventWithdrawChannelJoinRequest = "withdrawChannelJoinRequest" // requester cancels their channel join request
AuditEventCreateDirectChannel = "createDirectChannel" // create direct message channel between two users
AuditEventCreateGroupChannel = "createGroupChannel" // create group message channel with multiple users
AuditEventDeleteChannel = "deleteChannel" // delete channel
AuditEventGetPinnedPosts = "getPinnedPosts" // get pinned posts
AuditEventLocalAddChannelMember = "localAddChannelMember" // add channel member locally
AuditEventLocalCreateChannel = "localCreateChannel" // create channel locally
AuditEventLocalDeleteChannel = "localDeleteChannel" // delete channel locally
AuditEventLocalMoveChannel = "localMoveChannel" // move channel locally
AuditEventLocalPatchChannel = "localPatchChannel" // patch channel locally
AuditEventLocalRemoveChannelMember = "localRemoveChannelMember" // remove channel member locally
AuditEventLocalRestoreChannel = "localRestoreChannel" // restore channel locally
AuditEventLocalUpdateChannelPrivacy = "localUpdateChannelPrivacy" // update channel privacy locally
AuditEventMoveChannel = "moveChannel" // move channel to different team
AuditEventPatchChannel = "patchChannel" // update channel properties
AuditEventPatchChannelModerations = "patchChannelModerations" // update channel moderation settings
AuditEventRemoveChannelMember = "removeChannelMember" // remove member from channel
AuditEventRestoreChannel = "restoreChannel" // restore previously deleted channel
AuditEventSetChannelMembers = "setChannelMembers" // bulk set (replace) channel memberships
AuditEventUpdateChannel = "updateChannel" // update channel properties
AuditEventUpdateChannelMemberNotifyProps = "updateChannelMemberNotifyProps" // update notification preferences
AuditEventUpdateChannelMemberAutotranslation = "updateChannelMemberAutotranslation" // update autotranslation setting
AuditEventUpdateChannelMemberRoles = "updateChannelMemberRoles" // update roles and permissions
AuditEventUpdateChannelMemberSchemeRoles = "updateChannelMemberSchemeRoles" // update scheme-based roles
AuditEventUpdateChannelPrivacy = "updateChannelPrivacy" // change channel privacy settings
AuditEventUpdateChannelScheme = "updateChannelScheme" // update permission scheme applied to channel
)
// Commands
const (
AuditEventCreateCommand = "createCommand" // create slash command
AuditEventDeleteCommand = "deleteCommand" // delete command
AuditEventExecuteCommand = "executeCommand" // execute command
AuditEventLocalCreateCommand = "localCreateCommand" // create command locally
AuditEventMoveCommand = "moveCommand" // move command to another team
AuditEventRegenCommandToken = "regenCommandToken" // regenerate authentication token for command
AuditEventUpdateCommand = "updateCommand" // update command
)
// Compliance
const (
AuditEventCreateComplianceReport = "createComplianceReport" // create compliance report
AuditEventDownloadComplianceReport = "downloadComplianceReport" // download compliance report
AuditEventGetComplianceReport = "getComplianceReport" // get specific compliance report
AuditEventGetComplianceReports = "getComplianceReports" // get all compliance reports
)
// Configuration
const (
AuditEventAutoSelectPushNotificationServer = "autoSelectPushNotificationServer" // automatically switch push notification server based on license entitlement
AuditEventConfigReload = "configReload" // reload server configuration
AuditEventGetConfig = "getConfig" // get current server configuration
AuditEventLocalGetClientConfig = "localGetClientConfig" // get client configuration locally
AuditEventLocalGetConfig = "localGetConfig" // get server configuration locally
AuditEventLocalPatchConfig = "localPatchConfig" // update server configuration locally
AuditEventLocalUpdateConfig = "localUpdateConfig" // update server configuration locally
AuditEventMigrateConfig = "migrateConfig" // migrate configs with file values from one store to another
AuditEventPatchConfig = "patchConfig" // update server configuration
AuditEventUpdateConfig = "updateConfig" // update server configuration
)
// Custom Profile Attributes
const (
AuditEventCreateCPAField = "createCPAField" // create custom profile attribute
AuditEventDeleteCPAField = "deleteCPAField" // delete custom profile attribute
AuditEventPatchCPAField = "patchCPAField" // update custom profile attribute field
AuditEventPatchCPAValues = "patchCPAValues" // update custom profile attribute values
// AuditEventCPAValueChange is emitted from the common app-layer value write
// path for every effective CPA value change, regardless of caller (session,
// local admin, plugin owner, or LDAP/SAML sync). It is the single place all
// owners converge, capturing the caller ID and acting-as scope.
AuditEventCPAValueChange = "cpaValueChange"
)
// Property Fields
const (
AuditEventCreatePropertyField = "createPropertyField" // create property field
AuditEventDeletePropertyField = "deletePropertyField" // delete property field
AuditEventGetPropertyFields = "getPropertyFields" // list property fields
AuditEventPatchPropertyField = "patchPropertyField" // update property field
)
// Property Values
const (
AuditEventGetPropertyValues = "getPropertyValues" // get property values for target
AuditEventPatchPropertyValues = "patchPropertyValues" // update property values for target
)
// Data Retention Policies
const (
AuditEventAddChannelsToPolicy = "addChannelsToPolicy" // add channels to data retention policy
AuditEventAddTeamsToPolicy = "addTeamsToPolicy" // add teams to data retention policy
AuditEventCreatePolicy = "createPolicy" // create data retention policy
AuditEventDeletePolicy = "deletePolicy" // delete data retention policy
AuditEventPatchPolicy = "patchPolicy" // update data retention policy
AuditEventRemoveChannelsFromPolicy = "removeChannelsFromPolicy" // remove channels from data retention policy
AuditEventRemoveTeamsFromPolicy = "removeTeamsFromPolicy" // remove teams from data retention policy
)
// Ephemeral Mode
const (
AuditEventAutoCacheCleanupRun = "autoCacheCleanupRun" // automatic cache cleanup run
AuditEventOfflinePurge = "offlinePurge" // offline purge
AuditEventSessionWipe = "sessionWipe" // session wipe
)
// Emojis
const (
AuditEventCreateEmoji = "createEmoji" // create emoji
AuditEventDeleteEmoji = "deleteEmoji" // delete emoji
)
// Exports
const (
AuditEventBulkExport = "bulkExport" // bulk export data to a file
AuditEventDeleteExport = "deleteExport" // delete exported file
AuditEventGeneratePresignURLExport = "generatePresignURLExport" // generate presigned URL to download the exported file
AuditEventScheduleExport = "scheduleExport" // schedule export job
)
// Files
const (
AuditEventGetFile = "getFile" // get or download file
AuditEventGetFileLink = "getFileLink" // generate link for file sharing
AuditEventUploadFileMultipart = "uploadFileMultipart" // upload file using multipart form data
AuditEventUploadFileMultipartLegacy = "uploadFileMultipartLegacy" // upload file using legacy multipart method
AuditEventUploadFileSimple = "uploadFileSimple" // upload file using simple direct upload method
AuditEventGetFileThumbnail = "getFileThumbnail" // get file thumbnail
AuditEventGetFileInfosForPost = "getFileInfosForPost" // get file infos for post
AuditEventGetFileInfo = "getFileInfo" // get file info
AuditEventGetFilePreview = "getFilePreview" // get file preview
AuditEventSearchFiles = "searchFiles" // search for files
)
// Groups
const (
AuditEventAddGroupMembers = "addGroupMembers" // add members to group
AuditEventAddUserToGroupSyncables = "addUserToGroupSyncables" // add user to group-synchronized teams and channels
AuditEventCreateGroup = "createGroup" // create group
AuditEventDeleteGroup = "deleteGroup" // delete group
AuditEventDeleteGroupMembers = "deleteGroupMembers" // remove members from group
AuditEventLinkGroupSyncable = "linkGroupSyncable" // link group to team or channel for synchronization
AuditEventPatchGroup = "patchGroup" // update group
AuditEventPatchGroupSyncable = "patchGroupSyncable" // update group synchronization settings
AuditEventRestoreGroup = "restoreGroup" // restore previously deleted group
AuditEventUnlinkGroupSyncable = "unlinkGroupSyncable" // unlink group from team or channel synchronization
)
// Imports
const (
AuditEventBulkImport = "bulkImport" // bulk import data from a file
AuditEventDeleteImport = "deleteImport" // delete import file
)
// Jobs
const (
AuditEventCancelJob = "cancelJob" // cancel a job
AuditEventCreateJob = "createJob" // create a job
AuditEventJobServer = "jobServer" // start job server
AuditEventUpdateJobStatus = "updateJobStatus" // update status of a job
)
// LDAP
const (
AuditEventAddLdapPrivateCertificate = "addLdapPrivateCertificate" // add private certificate for LDAP
AuditEventAddLdapPublicCertificate = "addLdapPublicCertificate" // add public certificate for LDAP
AuditEventIdMigrateLdap = "idMigrateLdap" // migrate user ID mapping to another attribute
AuditEventLinkLdapGroup = "linkLdapGroup" // link LDAP group to Mattermost team or channel
AuditEventRemoveLdapPrivateCertificate = "removeLdapPrivateCertificate" // remove private certificate for LDAP
AuditEventRemoveLdapPublicCertificate = "removeLdapPublicCertificate" // remove public certificate for LDAP
AuditEventSyncLdap = "syncLdap" // synchronize users and groups from LDAP
AuditEventUnlinkLdapGroup = "unlinkLdapGroup" // unlink LDAP group from Mattermost team or channel
)
// Licensing
const (
AuditEventAddLicense = "addLicense" // add license
AuditEventLocalAddLicense = "localAddLicense" // add license locally
AuditEventLocalRemoveLicense = "localRemoveLicense" // remove license locally
AuditEventRemoveLicense = "removeLicense" // remove license
AuditEventRequestTrialLicense = "requestTrialLicense" // request trial license
)
// OAuth
const (
AuditEventAuthorizeOAuthApp = "authorizeOAuthApp" // authorize OAuth app
AuditEventAuthorizeOAuthPage = "authorizeOAuthPage" // authorize OAuth page
AuditEventCompleteOAuth = "completeOAuth" // complete OAuth authorization flow
AuditEventCreateOAuthApp = "createOAuthApp" // create OAuth app
AuditEventCreateOutgoingOauthConnection = "createOutgoingOauthConnection" // create outgoing OAuth connection
AuditEventDeauthorizeOAuthApp = "deauthorizeOAuthApp" // revoke OAuth app authorization
AuditEventDeleteOAuthApp = "deleteOAuthApp" // delete OAuth app
AuditEventDeleteOutgoingOAuthConnection = "deleteOutgoingOAuthConnection" // delete outgoing OAuth connection
AuditEventGetAccessToken = "getAccessToken" // get OAuth access token
AuditEventLoginWithOAuth = "loginWithOAuth" // login using OAuth authentication provider
AuditEventMobileLoginWithOAuth = "mobileLoginWithOAuth" // mobile application login using OAuth authentication provider
AuditEventRegenerateOAuthAppSecret = "regenerateOAuthAppSecret" // regenerate secret key for OAuth app
AuditEventRegisterOAuthClient = "registerOAuthClient" // register OAuth client via dynamic client registration (RFC 7591)
AuditEventSignupWithOAuth = "signupWithOAuth" // create account using OAuth authentication provider
AuditEventUpdateOAuthApp = "updateOAuthApp" // update OAuth app
AuditEventUpdateOutgoingOAuthConnection = "updateOutgoingOAuthConnection" // update outgoing OAuth connection
AuditEventValidateOutgoingOAuthConnectionCredentials = "validateOutgoingOAuthConnectionCredentials" // validate credentials for outgoing OAuth connection
)
// Plugins
const (
AuditEventDisablePlugin = "disablePlugin" // disable installed plugin
AuditEventEnablePlugin = "enablePlugin" // enable installed plugin
AuditEventGetFirstAdminVisitMarketplaceStatus = "getFirstAdminVisitMarketplaceStatus" // get first admin visit status
AuditEventInstallMarketplacePlugin = "installMarketplacePlugin" // install plugin from official marketplace
AuditEventInstallPluginFromURL = "installPluginFromURL" // install plugin from external URL
AuditEventRemovePlugin = "removePlugin" // delete plugin
AuditEventSetFirstAdminVisitMarketplaceStatus = "setFirstAdminVisitMarketplaceStatus" // set first admin visit status
AuditEventUploadPlugin = "uploadPlugin" // upload plugin file to server for installation
)
// Posts
const (
AuditEventCreateEphemeralPost = "createEphemeralPost" // create ephemeral post
AuditEventCreatePost = "createPost" // create post
AuditEventDeletePost = "deletePost" // delete post
AuditEventGetEditHistoryForPost = "getEditHistoryForPost" // get edit history for post
AuditEventGetFlaggedPosts = "getFlaggedPosts" // get flagged posts
AuditEventGetPostsForChannel = "getPostsForChannel" // get posts for channel
AuditEventGetPostsForChannelAroundLastUnread = "getPostsForChannelAroundLastUnread" // get posts for channel around last unread
AuditEventGetPost = "getPost" // get post
AuditEventGetPostThread = "getPostThread" // get post thread
AuditEventGetPostsByIds = "getPostsByIds" // get posts by ids
AuditEventGetThreadForUser = "getThreadForUser" // get thread for user
AuditEventLocalDeletePost = "localDeletePost" // delete post locally
AuditEventMoveThread = "moveThread" // move thread and replies to different channel
AuditEventNotificationAck = "notificationAck" // notification ack
AuditEventPatchPost = "patchPost" // update post meta properties
AuditEventRestorePostVersion = "restorePostVersion" // restore post to previous version
AuditEventSaveIsPinnedPost = "saveIsPinnedPost" // pin or unpin post
AuditEventSearchPosts = "searchPosts" // search for posts
AuditEventUpdatePost = "updatePost" // update post content
AuditEventRevealPost = "revealPost" // reveal a post that was hidden due to burn on read
AuditEventBurnPost = "burnPost" // burn a post that was hidden due to burn on read
AuditEventWebsocketPost = "websocketPost" // post received via websocket
)
// Recaps
const (
AuditEventCreateRecap = "createRecap" // create recap summarizing channel content
AuditEventGetRecap = "getRecap" // view a single recap
AuditEventGetRecaps = "getRecaps" // list user's recaps
AuditEventMarkRecapAsRead = "markRecapAsRead" // mark recap as read
AuditEventMarkRecapsAsViewed = "markRecapsAsViewed" // bulk mark user's finished recaps as viewed
AuditEventRegenerateRecap = "regenerateRecap" // regenerate recap with updated channel content
AuditEventDeleteRecap = "deleteRecap" // delete recap
)
// Scheduled Recaps
const (
AuditEventCreateScheduledRecap = "createScheduledRecap" // create scheduled recap configuration
AuditEventGetScheduledRecap = "getScheduledRecap" // view a single scheduled recap
AuditEventGetScheduledRecaps = "getScheduledRecaps" // list user's scheduled recaps
AuditEventUpdateScheduledRecap = "updateScheduledRecap" // update scheduled recap configuration
AuditEventDeleteScheduledRecap = "deleteScheduledRecap" // delete scheduled recap
AuditEventPauseScheduledRecap = "pauseScheduledRecap" // pause scheduled recap execution
AuditEventResumeScheduledRecap = "resumeScheduledRecap" // resume paused scheduled recap
)
// Preferences
const (
AuditEventDeletePreferences = "deletePreferences" // delete user preferences
AuditEventUpdatePreferences = "updatePreferences" // update user preferences
)
// Remote Clusters
const (
AuditEventCreateRemoteCluster = "createRemoteCluster" // create connection to remote Mattermost cluster
AuditEventDeleteRemoteCluster = "deleteRemoteCluster" // delete connection to remote Mattermost cluster
AuditEventGenerateRemoteClusterInvite = "generateRemoteClusterInvite" // generate invitation token for remote cluster connection
AuditEventInviteRemoteClusterToChannel = "inviteRemoteClusterToChannel" // invite remote cluster users to shared channel
AuditEventPatchRemoteCluster = "patchRemoteCluster" // update remote cluster connection settings
AuditEventRemoteClusterAcceptInvite = "remoteClusterAcceptInvite" // accept invitation from remote cluster
AuditEventRemoteClusterAcceptMessage = "remoteClusterAcceptMessage" // accept message from remote cluster
AuditEventRemoteUploadProfileImage = "remoteUploadProfileImage" // upload profile image from remote cluster
AuditEventUninviteRemoteClusterToChannel = "uninviteRemoteClusterToChannel" // remove remote cluster access from shared channel
AuditEventUploadRemoteData = "uploadRemoteData" // upload data to remote cluster
)
// Roles
const (
AuditEventPatchRole = "patchRole" // update role permissions
)
// SAML
const (
AuditEventAddSamlIdpCertificate = "addSamlIdpCertificate" // add SAML identity provider certificate
AuditEventAddSamlPrivateCertificate = "addSamlPrivateCertificate" // add SAML private certificate
AuditEventAddSamlPublicCertificate = "addSamlPublicCertificate" // add SAML public certificate
AuditEventCompleteSaml = "completeSaml" // complete SAML authentication flow
AuditEventRemoveSamlIdpCertificate = "removeSamlIdpCertificate" // remove SAML identity provider certificate
AuditEventRemoveSamlPrivateCertificate = "removeSamlPrivateCertificate" // remove SAML private certificate
AuditEventRemoveSamlPublicCertificate = "removeSamlPublicCertificate" // remove SAML public certificate
)
// Scheduled Posts
const (
AuditEventCreateSchedulePost = "createSchedulePost" // create post scheduled for future delivery
AuditEventDeleteScheduledPost = "deleteScheduledPost" // delete scheduled post before delivery
AuditEventUpdateScheduledPost = "updateScheduledPost" // update scheduled post
)
// Schemes
const (
AuditEventCreateScheme = "createScheme" // create permission scheme with role definitions
AuditEventDeleteScheme = "deleteScheme" // delete scheme
AuditEventPatchScheme = "patchScheme" // update scheme
)
// Search Indexes
const (
AuditEventPurgeBleveIndexes = "purgeBleveIndexes" // purge Bleve search indexes
AuditEventPurgeElasticsearchIndexes = "purgeElasticsearchIndexes" // purge Elasticsearch search indexes
)
// Server Administration
const (
AuditEventClearServerBusy = "clearServerBusy" // clear server busy status to allow normal operations
AuditEventCompleteOnboarding = "completeOnboarding" // complete system onboarding process
AuditEventDatabaseRecycle = "databaseRecycle" // closes active connections
AuditEventDownloadLogs = "downloadLogs" // download server log files
AuditEventGenerateSupportPacket = "generateSupportPacket" // generate support packet with server diagnostics and logs
AuditEventGetAppliedSchemaMigrations = "getAppliedSchemaMigrations" // get list of applied database schema migrations
AuditEventGetLogs = "getLogs" // get server log entries
AuditEventGetOnboarding = "getOnboarding" // get system onboarding status
AuditEventInvalidateCaches = "invalidateCaches" // clear server caches
AuditEventLocalCheckIntegrity = "localCheckIntegrity" // check database integrity locally
AuditEventQueryLogs = "queryLogs" // search server log entries
AuditEventRestartServer = "restartServer" // restart Mattermost server process
AuditEventSetServerBusy = "setServerBusy" // set server busy status to disallow any operations
AuditEventUpdateViewedProductNotices = "updateViewedProductNotices" // update viewed status of product notices
AuditEventUpgradeToEnterprise = "upgradeToEnterprise" // upgrade server to Enterprise edition
)
// Teams
const (
AuditEventAddTeamMember = "addTeamMember" // add member to team
AuditEventAddTeamMembers = "addTeamMembers" // add multiple members to team
AuditEventAddUserToTeamFromInvite = "addUserToTeamFromInvite" // add user to team using invitation link
AuditEventCreateTeam = "createTeam" // create team
AuditEventDeleteTeam = "deleteTeam" // delete team
AuditEventInvalidateAllEmailInvites = "invalidateAllEmailInvites" // invalidate all pending email invitations
AuditEventInviteGuestsToChannels = "inviteGuestsToChannels" // invite guest users to specific channels
AuditEventInviteUsersToTeam = "inviteUsersToTeam" // invite users to team
AuditEventLocalCreateTeam = "localCreateTeam" // create team locally
AuditEventLocalDeleteTeam = "localDeleteTeam" // delete team locally
AuditEventLocalInviteUsersToTeam = "localInviteUsersToTeam" // invite users to team locally
AuditEventPatchTeam = "patchTeam" // update team properties
AuditEventRegenerateTeamInviteId = "regenerateTeamInviteId" // regenerate team invitation ID
AuditEventRemoveTeamIcon = "removeTeamIcon" // remove custom icon from team
AuditEventRemoveTeamMember = "removeTeamMember" // remove member from team
AuditEventRestoreTeam = "restoreTeam" // restore previously deleted team
AuditEventSetTeamIcon = "setTeamIcon" // set custom icon for team
AuditEventUpdateTeam = "updateTeam" // update team properties
AuditEventUpdateTeamMemberRoles = "updateTeamMemberRoles" // update roles of team members
AuditEventUpdateTeamMemberSchemeRoles = "updateTeamMemberSchemeRoles" // update scheme-based roles of team members
AuditEventUpdateTeamPrivacy = "updateTeamPrivacy" // change team privacy settings
AuditEventUpdateTeamScheme = "updateTeamScheme" // update scheme applied to team
)
// Terms of Service
const (
AuditEventCreateTermsOfService = "createTermsOfService" // create terms of service
AuditEventSaveUserTermsOfService = "saveUserTermsOfService" // save user acceptance of terms of service
)
// Threads
const (
AuditEventFollowThreadByUser = "followThreadByUser" // follow thread to receive notifications about replies
AuditEventSetUnreadThreadByPostId = "setUnreadThreadByPostId" // mark thread as unread for user by post ID
AuditEventUnfollowThreadByUser = "unfollowThreadByUser" // unfollow thread to stop receiving notifications about replies
AuditEventUpdateReadStateAllThreadsByUser = "updateReadStateAllThreadsByUser" // update read status for all threads for user
AuditEventUpdateReadStateThreadByUser = "updateReadStateThreadByUser" // update read status for specific thread for user
)
// Uploads
const (
AuditEventCreateUpload = "createUpload" // create file upload session
AuditEventUploadData = "uploadData" // upload file data to server storage
)
// Users
const (
AuditEventAttachDeviceId = "attachDeviceId" // attach device IDs (standard or VoIP) to user session for mobile app
AuditEventCreateUser = "createUser" // create user account
AuditEventCreateUserAccessToken = "createUserAccessToken" // create personal access token for user API access
AuditEventDeleteUser = "deleteUser" // delete user account
AuditEventDemoteUserToGuest = "demoteUserToGuest" // demote regular user to guest account with limited permissions
AuditEventDisableUserAccessToken = "disableUserAccessToken" // disable user personal access token
AuditEventEnableUserAccessToken = "enableUserAccessToken" // enable user personal access token
AuditEventExtendSessionExpiry = "extendSessionExpiry" // extend user session expiration time
AuditEventLocalDeleteUser = "localDeleteUser" // delete user locally
AuditEventLocalPermanentDeleteAllUsers = "localPermanentDeleteAllUsers" // permanently delete all users locally
AuditEventLogin = "login" // user login to system
AuditEventLoginWithDesktopToken = "loginWithDesktopToken" // user login to system with desktop token
AuditEventLogout = "logout" // user logout from system
AuditEventMarkMessagesRead = "markAllMessagesRead" // user marked all direct and group messages as read
AuditEventMarkTeamRead = "markFullTeamRead" // user marked an entire team as read
AuditEventMigrateAuthToLdap = "migrateAuthToLdap" // migrate user authentication method to LDAP
AuditEventMigrateAuthToSaml = "migrateAuthToSaml" // migrate user authentication method to SAML
AuditEventPatchUser = "patchUser" // update user properties
AuditEventPromoteGuestToUser = "promoteGuestToUser" // promote guest account to regular user
AuditEventResetPassword = "resetPassword" // reset user password
AuditEventResetPasswordFailedAttempts = "resetPasswordFailedAttempts" // reset failed password attempt counter
AuditEventRevokeAllSessionsAllUsers = "revokeAllSessionsAllUsers" // revoke all active sessions for all users
AuditEventRevokeAllSessionsForUser = "revokeAllSessionsForUser" // revoke all active sessions for specific user
AuditEventRevokeSession = "revokeSession" // revoke specific user session
AuditEventRejectExpiredUserAccessToken = "rejectExpiredUserAccessToken" // rejected an API request because the personal access token has expired
AuditEventRevokeUserAccessToken = "revokeUserAccessToken" // revoke user personal access token
AuditEventRevokeNonCompliantUserAccessTokens = "revokeNonCompliantUserAccessTokens" // revoke all personal access tokens that violate the maximum lifetime policy
AuditEventRotateUserAccessToken = "rotateUserAccessToken" // rotate (regenerate secret for) user personal access token
AuditEventSendPasswordReset = "sendPasswordReset" // send password reset email to user
AuditEventSendVerificationEmail = "sendVerificationEmail" // send email verification link to user
AuditEventSetDefaultProfileImage = "setDefaultProfileImage" // set user profile image to default avatar
AuditEventSetProfileImage = "setProfileImage" // set custom profile image for user
AuditEventSwitchAccountType = "switchAccountType" // switch user authentication method from one to another
AuditEventUpdatePassword = "updatePassword" // update user password
AuditEventUpdateUser = "updateUser" // update user account properties
AuditEventUpdateUserActive = "updateUserActive" // update user active status
AuditEventUpdateUserAuth = "updateUserAuth" // update user authentication method
AuditEventUpdateUserMfa = "updateUserMfa" // update user multi-factor authentication settings
AuditEventUpdateUserRoles = "updateUserRoles" // update user roles
AuditEventVerifyUserEmail = "verifyUserEmail" // verify user email address using verification token
AuditEventVerifyUserEmailWithoutToken = "verifyUserEmailWithoutToken" // verify user email address without verification token
)
// Webhooks
const (
AuditEventCreateIncomingHook = "createIncomingHook" // create incoming webhook
AuditEventCreateOutgoingHook = "createOutgoingHook" // create outgoing webhook
AuditEventDeleteIncomingHook = "deleteIncomingHook" // delete incoming webhook
AuditEventDeleteOutgoingHook = "deleteOutgoingHook" // delete outgoing webhook
AuditEventGetIncomingHook = "getIncomingHook" // get incoming webhook details
AuditEventGetOutgoingHook = "getOutgoingHook" // get outgoing webhook details
AuditEventLocalCreateIncomingHook = "localCreateIncomingHook" // create incoming webhook locally
AuditEventRegenOutgoingHookToken = "regenOutgoingHookToken" // regenerate authentication token
AuditEventUpdateIncomingHook = "updateIncomingHook" // update incoming webhook
AuditEventUpdateOutgoingHook = "updateOutgoingHook" // update outgoing webhook
)
// Content Flagging
const (
AuditEventFlagPost = "flagPost" // flag post for review
AuditEventGetFlaggedPost = "getFlaggedPost" // get flagged post details
AuditEventPermanentlyRemoveFlaggedPost = "permanentlyRemoveFlaggedPost" // permanently remove flagged post
AuditEventKeepFlaggedPost = "keepFlaggedPost" // keep flagged post
AuditEventUpdateContentFlaggingConfig = "updateContentFlaggingConfig" // update content flagging configuration
AuditEventSetReviewer = "setFlaggedPostReviewer" // assign reviewer for flagged post
AuditEventGenerateFlaggedPostReport = "generateFlaggedPostReport" // generate flagged post data report
AuditEventGeneratePostExposureReport = "generatePostExposureReport" // generate flagged post exposure report
)
// Post Delivery Tracking
const (
AuditEventUpdateDeliveryTrackingConfig = "updateDeliveryTrackingConfig" // update post delivery tracking configuration
AuditEventPostDelivered = "postDelivered" // a post's content was delivered to a user or integration
)

View File

@@ -0,0 +1,149 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const (
AuditKeyActor = "actor"
AuditKeyAPIPath = "api_path"
AuditKeyEvent = "event"
AuditKeyEventData = "event_data"
AuditKeyEventName = "event_name"
AuditKeyMeta = "meta"
AuditKeyError = "error"
AuditKeyStatus = "status"
AuditKeyUserID = "user_id"
AuditKeySessionID = "session_id"
AuditKeyClient = "client"
AuditKeyIPAddress = "ip_address"
AuditKeyClusterID = "cluster_id"
AuditStatusSuccess = "success"
AuditStatusAttempt = "attempt"
AuditStatusFail = "fail"
)
// AuditRecord provides a consistent set of fields used for all audit logging.
type AuditRecord struct {
EventName string `json:"event_name"`
Status string `json:"status"`
EventData AuditEventData `json:"event"`
Actor AuditEventActor `json:"actor"`
Meta map[string]any `json:"meta"`
Error AuditEventError `json:"error"`
}
// AuditEventData contains all event specific data about the modified entity
type AuditEventData struct {
Parameters map[string]any `json:"parameters"` // Payload and parameters being processed as part of the request
PriorState map[string]any `json:"prior_state"` // Prior state of the object being modified, nil if no prior state
ResultState map[string]any `json:"resulting_state"` // Resulting object after creating or modifying it
ObjectType string `json:"object_type"` // String representation of the object type. eg. "post"
}
// AuditEventActor is the subject triggering the event
type AuditEventActor struct {
UserId string `json:"user_id"`
SessionId string `json:"session_id"`
Client string `json:"client"`
IpAddress string `json:"ip_address"`
XForwardedFor string `json:"x_forwarded_for"`
}
// EventMeta is a key-value store to store related information to the event that is not directly related to the modified entity
type EventMeta struct {
ApiPath string `json:"api_path"`
ClusterId string `json:"cluster_id"`
}
// AuditEventError contains error information in case of failure of the event
type AuditEventError struct {
Description string `json:"description,omitempty"`
Code int `json:"status_code,omitempty"`
}
// Auditable for sensitive object classes, consider implementing Auditable and include whatever the
// AuditableObject returns. For example: it's likely OK to write a user object to the
// audit logs, but not the user password in cleartext or hashed form
type Auditable interface {
Auditable() map[string]any
}
// Success marks the audit record status as successful.
func (rec *AuditRecord) Success() {
rec.Status = AuditStatusSuccess
}
// Fail marks the audit record status as failed.
func (rec *AuditRecord) Fail() {
rec.Status = AuditStatusFail
}
// AddEventParameterToAuditRec adds a parameter, e.g. query or post body, to the event
func AddEventParameterToAuditRec[T string | bool | int | int64 | []string | map[string]string](rec *AuditRecord, key string, val T) {
if rec.EventData.Parameters == nil {
rec.EventData.Parameters = make(map[string]any)
}
rec.EventData.Parameters[key] = val
}
// AddEventParameterAuditableToAuditRec adds an object that is of type Auditable to the event
func AddEventParameterAuditableToAuditRec(rec *AuditRecord, key string, val Auditable) {
if rec.EventData.Parameters == nil {
rec.EventData.Parameters = make(map[string]any)
}
rec.EventData.Parameters[key] = val.Auditable()
}
// AddEventParameterAuditableArrayToAuditRec adds an array of objects of type Auditable to the event
func AddEventParameterAuditableArrayToAuditRec[T Auditable](rec *AuditRecord, key string, val []T) {
if rec.EventData.Parameters == nil {
rec.EventData.Parameters = make(map[string]any)
}
processedAuditables := make([]map[string]any, 0, len(val))
for _, auditableVal := range val {
processedAuditables = append(processedAuditables, auditableVal.Auditable())
}
rec.EventData.Parameters[key] = processedAuditables
}
// AddEventPriorState adds the prior state of the modified object to the audit record
func (rec *AuditRecord) AddEventPriorState(object Auditable) {
rec.EventData.PriorState = object.Auditable()
}
// AddEventResultState adds the result state of the modified object to the audit record
func (rec *AuditRecord) AddEventResultState(object Auditable) {
rec.EventData.ResultState = object.Auditable()
}
// AddEventObjectType adds the object type of the modified object to the audit record
func (rec *AuditRecord) AddEventObjectType(objectType string) {
rec.EventData.ObjectType = objectType
}
// AddMeta adds a key/value entry to the audit record that can be used for related information not directly related to
// the modified object, e.g. authentication method
func (rec *AuditRecord) AddMeta(name string, val any) {
rec.Meta[name] = val
}
// AddErrorCode adds the error code for a failed event to the audit record
func (rec *AuditRecord) AddErrorCode(code int) {
rec.Error.Code = code
}
// AddErrorDesc adds the error description for a failed event to the audit record
func (rec *AuditRecord) AddErrorDesc(description string) {
rec.Error.Description = description
}
// AddAppError adds an AppError to the audit record
func (rec *AuditRecord) AddAppError(err *AppError) {
rec.AddErrorCode(err.StatusCode)
rec.AddErrorDesc(err.Error())
}

View File

@@ -0,0 +1,776 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strings"
"github.com/francoispqt/gojay"
)
// AuditModelTypeConv converts key model types to something better suited for audit output.
func AuditModelTypeConv(val any) (newVal any, converted bool) {
if val == nil {
return nil, false
}
switch v := val.(type) {
case *Channel:
return newAuditChannel(v), true
case Channel:
return newAuditChannel(&v), true
case *Team:
return newAuditTeam(v), true
case Team:
return newAuditTeam(&v), true
case *User:
return newAuditUser(v), true
case User:
return newAuditUser(&v), true
case *UserPatch:
return newAuditUserPatch(v), true
case UserPatch:
return newAuditUserPatch(&v), true
case *Command:
return newAuditCommand(v), true
case Command:
return newAuditCommand(&v), true
case *CommandArgs:
return newAuditCommandArgs(v), true
case CommandArgs:
return newAuditCommandArgs(&v), true
case *Bot:
return newAuditBot(v), true
case Bot:
return newAuditBot(&v), true
case *ChannelModerationPatch:
return newAuditChannelModerationPatch(v), true
case ChannelModerationPatch:
return newAuditChannelModerationPatch(&v), true
case *Emoji:
return newAuditEmoji(v), true
case Emoji:
return newAuditEmoji(&v), true
case *FileInfo:
return newAuditFileInfo(v), true
case FileInfo:
return newAuditFileInfo(&v), true
case *Group:
return newAuditGroup(v), true
case Group:
return newAuditGroup(&v), true
case *Job:
return newAuditJob(v), true
case Job:
return newAuditJob(&v), true
case *OAuthApp:
return newAuditOAuthApp(v), true
case OAuthApp:
return newAuditOAuthApp(&v), true
case *Post:
return newAuditPost(v), true
case Post:
return newAuditPost(&v), true
case *Role:
return newAuditRole(v), true
case Role:
return newAuditRole(&v), true
case *Scheme:
return newAuditScheme(v), true
case Scheme:
return newAuditScheme(&v), true
case *SchemeRoles:
return newAuditSchemeRoles(v), true
case SchemeRoles:
return newAuditSchemeRoles(&v), true
case *Session:
return newAuditSession(v), true
case Session:
return newAuditSession(&v), true
case *IncomingWebhook:
return newAuditIncomingWebhook(v), true
case IncomingWebhook:
return newAuditIncomingWebhook(&v), true
case *OutgoingWebhook:
return newAuditOutgoingWebhook(v), true
case OutgoingWebhook:
return newAuditOutgoingWebhook(&v), true
case *RemoteCluster:
return newRemoteCluster(v), true
case RemoteCluster:
return newRemoteCluster(&v), true
}
return val, false
}
type auditChannel struct {
ID string
Name string
Type ChannelType
}
// newAuditChannel creates a simplified representation of Channel for output to audit log.
func newAuditChannel(c *Channel) auditChannel {
var channel auditChannel
if c != nil {
channel.ID = c.Id
channel.Name = c.Name
channel.Type = c.Type
}
return channel
}
func (c auditChannel) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", c.ID)
enc.StringKey("name", c.Name)
enc.StringKey("type", string(c.Type))
}
func (c auditChannel) IsNil() bool {
return false
}
type auditTeam struct {
ID string
Name string
Type string
}
// newAuditTeam creates a simplified representation of Team for output to audit log.
func newAuditTeam(t *Team) auditTeam {
var team auditTeam
if t != nil {
team.ID = t.Id
team.Name = t.Name
team.Type = t.Type
}
return team
}
func (t auditTeam) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", t.ID)
enc.StringKey("name", t.Name)
enc.StringKey("type", t.Type)
}
func (t auditTeam) IsNil() bool {
return false
}
type auditUser struct {
ID string
Name string
Roles string
}
// newAuditUser creates a simplified representation of User for output to audit log.
func newAuditUser(u *User) auditUser {
var user auditUser
if u != nil {
user.ID = u.Id
user.Name = u.Username
user.Roles = u.Roles
}
return user
}
type auditUserPatch struct {
Name string
}
// newAuditUserPatch creates a simplified representation of UserPatch for output to audit log.
func newAuditUserPatch(up *UserPatch) auditUserPatch {
var userPatch auditUserPatch
if up != nil {
if up.Username != nil {
userPatch.Name = *up.Username
}
}
return userPatch
}
func (u auditUser) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", u.ID)
enc.StringKey("name", u.Name)
enc.StringKey("roles", u.Roles)
}
func (u auditUser) IsNil() bool {
return false
}
type auditCommand struct {
ID string
CreatorID string
TeamID string
Trigger string
Method string
Username string
IconURL string
AutoComplete bool
AutoCompleteDesc string
AutoCompleteHint string
DisplayName string
Description string
URL string
}
// newAuditCommand creates a simplified representation of Command for output to audit log.
func newAuditCommand(c *Command) auditCommand {
var cmd auditCommand
if c != nil {
cmd.ID = c.Id
cmd.CreatorID = c.CreatorId
cmd.TeamID = c.TeamId
cmd.Trigger = c.Trigger
cmd.Method = c.Method
cmd.Username = c.Username
cmd.IconURL = c.IconURL
cmd.AutoComplete = c.AutoComplete
cmd.AutoCompleteDesc = c.AutoCompleteDesc
cmd.AutoCompleteHint = c.AutoCompleteHint
cmd.DisplayName = c.DisplayName
cmd.Description = c.Description
cmd.URL = c.URL
}
return cmd
}
func (cmd auditCommand) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", cmd.ID)
enc.StringKey("creator_id", cmd.CreatorID)
enc.StringKey("team_id", cmd.TeamID)
enc.StringKey("trigger", cmd.Trigger)
enc.StringKey("method", cmd.Method)
enc.StringKey("username", cmd.Username)
enc.StringKey("icon_url", cmd.IconURL)
enc.BoolKey("auto_complete", cmd.AutoComplete)
enc.StringKey("auto_complete_desc", cmd.AutoCompleteDesc)
enc.StringKey("auto_complete_hint", cmd.AutoCompleteHint)
enc.StringKey("display", cmd.DisplayName)
enc.StringKey("desc", cmd.Description)
enc.StringKey("url", cmd.URL)
}
func (cmd auditCommand) IsNil() bool {
return false
}
type auditCommandArgs struct {
ChannelID string
TeamID string
TriggerID string
Command string
}
// newAuditCommandArgs creates a simplified representation of CommandArgs for output to audit log.
func newAuditCommandArgs(ca *CommandArgs) auditCommandArgs {
var cmdargs auditCommandArgs
if ca != nil {
cmdargs.ChannelID = ca.ChannelId
cmdargs.TeamID = ca.TeamId
cmdargs.TriggerID = ca.TriggerId
cmdFields := strings.Fields(ca.Command)
if len(cmdFields) > 0 {
cmdargs.Command = cmdFields[0]
}
}
return cmdargs
}
func (ca auditCommandArgs) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("channel_id", ca.ChannelID)
enc.StringKey("team_id", ca.TriggerID)
enc.StringKey("trigger_id", ca.TeamID)
enc.StringKey("command", ca.Command)
}
func (ca auditCommandArgs) IsNil() bool {
return false
}
type auditBot struct {
UserID string
Username string
Displayname string
}
// newAuditBot creates a simplified representation of Bot for output to audit log.
func newAuditBot(b *Bot) auditBot {
var bot auditBot
if b != nil {
bot.UserID = b.UserId
bot.Username = b.Username
bot.Displayname = b.DisplayName
}
return bot
}
func (b auditBot) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("user_id", b.UserID)
enc.StringKey("username", b.Username)
enc.StringKey("display", b.Displayname)
}
func (b auditBot) IsNil() bool {
return false
}
type auditChannelModerationPatch struct {
Name string
RoleGuests bool
RoleMembers bool
}
// newAuditChannelModerationPatch creates a simplified representation of ChannelModerationPatch for output to audit log.
func newAuditChannelModerationPatch(p *ChannelModerationPatch) auditChannelModerationPatch {
var patch auditChannelModerationPatch
if p != nil {
if p.Name != nil {
patch.Name = *p.Name
}
if p.Roles.Guests != nil {
patch.RoleGuests = *p.Roles.Guests
}
if p.Roles.Members != nil {
patch.RoleMembers = *p.Roles.Members
}
}
return patch
}
func (p auditChannelModerationPatch) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("name", p.Name)
enc.BoolKey("role_guests", p.RoleGuests)
enc.BoolKey("role_members", p.RoleMembers)
}
func (p auditChannelModerationPatch) IsNil() bool {
return false
}
type auditEmoji struct {
ID string
Name string
}
// newAuditEmoji creates a simplified representation of Emoji for output to audit log.
func newAuditEmoji(e *Emoji) auditEmoji {
var emoji auditEmoji
if e != nil {
emoji.ID = e.Id
emoji.Name = e.Name
}
return emoji
}
func (e auditEmoji) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", e.ID)
enc.StringKey("name", e.Name)
}
func (e auditEmoji) IsNil() bool {
return false
}
type auditFileInfo struct {
ID string
PostID string
Path string
Name string
Extension string
Size int64
}
// newAuditFileInfo creates a simplified representation of FileInfo for output to audit log.
func newAuditFileInfo(f *FileInfo) auditFileInfo {
var fi auditFileInfo
if f != nil {
fi.ID = f.Id
fi.PostID = f.PostId
fi.Path = f.Path
fi.Name = f.Name
fi.Extension = f.Extension
fi.Size = f.Size
}
return fi
}
func (fi auditFileInfo) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", fi.ID)
enc.StringKey("post_id", fi.PostID)
enc.StringKey("path", fi.Path)
enc.StringKey("name", fi.Name)
enc.StringKey("ext", fi.Extension)
enc.Int64Key("size", fi.Size)
}
func (fi auditFileInfo) IsNil() bool {
return false
}
type auditGroup struct {
ID string
Name string
DisplayName string
Description string
}
// newAuditGroup creates a simplified representation of Group for output to audit log.
func newAuditGroup(g *Group) auditGroup {
var group auditGroup
if g != nil {
group.ID = g.Id
if g.Name == nil {
group.Name = ""
} else {
group.Name = *g.Name
}
group.DisplayName = g.DisplayName
group.Description = g.Description
}
return group
}
func (g auditGroup) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", g.ID)
enc.StringKey("name", g.Name)
enc.StringKey("display", g.DisplayName)
enc.StringKey("desc", g.Description)
}
func (g auditGroup) IsNil() bool {
return false
}
type auditJob struct {
ID string
Type string
Priority int64
StartAt int64
}
// newAuditJob creates a simplified representation of Job for output to audit log.
func newAuditJob(j *Job) auditJob {
var job auditJob
if j != nil {
job.ID = j.Id
job.Type = j.Type
job.Priority = j.Priority
job.StartAt = j.StartAt
}
return job
}
func (j auditJob) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", j.ID)
enc.StringKey("type", j.Type)
enc.Int64Key("priority", j.Priority)
enc.Int64Key("start_at", j.StartAt)
}
func (j auditJob) IsNil() bool {
return false
}
type auditOAuthApp struct {
ID string
CreatorID string
Name string
Description string
IsTrusted bool
}
// newAuditOAuthApp creates a simplified representation of OAuthApp for output to audit log.
func newAuditOAuthApp(o *OAuthApp) auditOAuthApp {
var oauth auditOAuthApp
if o != nil {
oauth.ID = o.Id
oauth.CreatorID = o.CreatorId
oauth.Name = o.Name
oauth.Description = o.Description
oauth.IsTrusted = o.IsTrusted
}
return oauth
}
func (o auditOAuthApp) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", o.ID)
enc.StringKey("creator_id", o.CreatorID)
enc.StringKey("name", o.Name)
enc.StringKey("desc", o.Description)
enc.BoolKey("trusted", o.IsTrusted)
}
func (o auditOAuthApp) IsNil() bool {
return false
}
type auditPost struct {
ID string
ChannelID string
Type string
IsPinned bool
}
// newAuditPost creates a simplified representation of Post for output to audit log.
func newAuditPost(p *Post) auditPost {
var post auditPost
if p != nil {
post.ID = p.Id
post.ChannelID = p.ChannelId
post.Type = p.Type
post.IsPinned = p.IsPinned
}
return post
}
func (p auditPost) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", p.ID)
enc.StringKey("channel_id", p.ChannelID)
enc.StringKey("type", p.Type)
enc.BoolKey("pinned", p.IsPinned)
}
func (p auditPost) IsNil() bool {
return false
}
type auditRole struct {
ID string
Name string
DisplayName string
Permissions []string
SchemeManaged bool
BuiltIn bool
}
// newAuditRole creates a simplified representation of Role for output to audit log.
func newAuditRole(r *Role) auditRole {
var role auditRole
if r != nil {
role.ID = r.Id
role.Name = r.Name
role.DisplayName = r.DisplayName
role.Permissions = append(role.Permissions, r.Permissions...)
role.SchemeManaged = r.SchemeManaged
role.BuiltIn = r.BuiltIn
}
return role
}
func (r auditRole) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", r.ID)
enc.StringKey("name", r.Name)
enc.StringKey("display", r.DisplayName)
enc.SliceStringKey("perms", r.Permissions)
enc.BoolKey("schemeManaged", r.SchemeManaged)
enc.BoolKey("builtin", r.BuiltIn)
}
func (r auditRole) IsNil() bool {
return false
}
type auditScheme struct {
ID string
Name string
DisplayName string
Scope string
}
// newAuditScheme creates a simplified representation of Scheme for output to audit log.
func newAuditScheme(s *Scheme) auditScheme {
var scheme auditScheme
if s != nil {
scheme.ID = s.Id
scheme.Name = s.Name
scheme.DisplayName = s.DisplayName
scheme.Scope = s.Scope
}
return scheme
}
func (s auditScheme) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", s.ID)
enc.StringKey("name", s.Name)
enc.StringKey("display", s.DisplayName)
enc.StringKey("scope", s.Scope)
}
func (s auditScheme) IsNil() bool {
return false
}
type auditSchemeRoles struct {
SchemeAdmin bool
SchemeUser bool
SchemeGuest bool
}
// newAuditSchemeRoles creates a simplified representation of SchemeRoles for output to audit log.
func newAuditSchemeRoles(s *SchemeRoles) auditSchemeRoles {
var roles auditSchemeRoles
if s != nil {
roles.SchemeAdmin = s.SchemeAdmin
roles.SchemeUser = s.SchemeUser
roles.SchemeGuest = s.SchemeGuest
}
return roles
}
func (s auditSchemeRoles) MarshalJSONObject(enc *gojay.Encoder) {
enc.BoolKey("admin", s.SchemeAdmin)
enc.BoolKey("user", s.SchemeUser)
enc.BoolKey("guest", s.SchemeGuest)
}
func (s auditSchemeRoles) IsNil() bool {
return false
}
type auditSession struct {
ID string
UserId string
DeviceId string
}
// newAuditSession creates a simplified representation of Session for output to audit log.
func newAuditSession(s *Session) auditSession {
var session auditSession
if s != nil {
session.ID = s.Id
session.UserId = s.UserId
session.DeviceId = s.DeviceId
}
return session
}
func (s auditSession) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", s.ID)
enc.StringKey("user_id", s.UserId)
enc.StringKey("device_id", s.DeviceId)
}
func (s auditSession) IsNil() bool {
return false
}
type auditIncomingWebhook struct {
ID string
ChannelID string
TeamId string
DisplayName string
Description string
}
// newAuditIncomingWebhook creates a simplified representation of IncomingWebhook for output to audit log.
func newAuditIncomingWebhook(h *IncomingWebhook) auditIncomingWebhook {
var hook auditIncomingWebhook
if h != nil {
hook.ID = h.Id
hook.ChannelID = h.ChannelId
hook.TeamId = h.TeamId
hook.DisplayName = h.DisplayName
hook.Description = h.Description
}
return hook
}
func (h auditIncomingWebhook) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", h.ID)
enc.StringKey("channel_id", h.ChannelID)
enc.StringKey("team_id", h.TeamId)
enc.StringKey("display", h.DisplayName)
enc.StringKey("desc", h.Description)
}
func (h auditIncomingWebhook) IsNil() bool {
return false
}
type auditOutgoingWebhook struct {
ID string
ChannelID string
TeamID string
TriggerWords StringArray
TriggerWhen int
DisplayName string
Description string
ContentType string
Username string
}
// newAuditOutgoingWebhook creates a simplified representation of OutgoingWebhook for output to audit log.
func newAuditOutgoingWebhook(h *OutgoingWebhook) auditOutgoingWebhook {
var hook auditOutgoingWebhook
if h != nil {
hook.ID = h.Id
hook.ChannelID = h.ChannelId
hook.TeamID = h.TeamId
hook.TriggerWords = h.TriggerWords
hook.TriggerWhen = h.TriggerWhen
hook.DisplayName = h.DisplayName
hook.Description = h.Description
hook.ContentType = h.ContentType
hook.Username = h.Username
}
return hook
}
func (h auditOutgoingWebhook) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("id", h.ID)
enc.StringKey("channel_id", h.ChannelID)
enc.StringKey("team_id", h.TeamID)
enc.SliceStringKey("trigger_words", h.TriggerWords)
enc.IntKey("trigger_when", h.TriggerWhen)
enc.StringKey("display", h.DisplayName)
enc.StringKey("desc", h.Description)
enc.StringKey("content_type", h.ContentType)
enc.StringKey("username", h.Username)
}
func (h auditOutgoingWebhook) IsNil() bool {
return false
}
type auditRemoteCluster struct {
RemoteId string
RemoteTeamId string
Name string
DisplayName string
SiteURL string
CreateAt int64
LastPingAt int64
CreatorId string
}
// newRemoteCluster creates a simplified representation of RemoteCluster for output to audit log.
func newRemoteCluster(r *RemoteCluster) auditRemoteCluster {
var rc auditRemoteCluster
if r != nil {
rc.RemoteId = r.RemoteId
rc.Name = r.Name
rc.DisplayName = r.DisplayName
rc.SiteURL = r.SiteURL
rc.CreateAt = r.CreateAt
rc.LastPingAt = r.LastPingAt
rc.CreatorId = r.CreatorId
}
return rc
}
func (r auditRemoteCluster) MarshalJSONObject(enc *gojay.Encoder) {
enc.StringKey("remote_id", r.RemoteId)
enc.StringKey("remote_team_id", r.RemoteTeamId)
enc.StringKey("name", r.Name)
enc.StringKey("display_name", r.DisplayName)
enc.StringKey("site_url", r.SiteURL)
enc.Int64Key("create_at", r.CreateAt)
enc.Int64Key("last_ping_at", r.LastPingAt)
enc.StringKey("creator_id", r.CreatorId)
}
func (r auditRemoteCluster) IsNil() bool {
return false
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type Sample struct {
flag bool
name string
}
func TestAuditModelTypeConv(t *testing.T) {
sample := &Sample{flag: true, name: "sample"}
sample2 := &Sample{name: "sample2"}
sampleArr := []*Sample{sample, sample2}
user := &User{}
userPatch := &UserPatch{}
type args struct {
val any
}
tests := []struct {
name string
args args
wantConverted bool
wantNewVal any
}{
{name: "nil value", args: args{val: nil}, wantConverted: false, wantNewVal: nil},
{name: "string value", args: args{val: "hello"}, wantConverted: false, wantNewVal: "hello"},
{name: "string array", args: args{val: []string{"hello", "there"}}, wantConverted: false, wantNewVal: []string{"hello", "there"}},
{name: "int value", args: args{val: 77}, wantConverted: false, wantNewVal: 77},
{name: "int array", args: args{val: []int{77, 68}}, wantConverted: false, wantNewVal: []int{77, 68}},
{name: "struct pointer value", args: args{val: sample}, wantConverted: false, wantNewVal: sample},
{name: "struct pointer array", args: args{val: sampleArr}, wantConverted: false, wantNewVal: sampleArr},
{name: "model user pointer", args: args{val: user}, wantConverted: true, wantNewVal: "XXX"},
{name: "model user pointer", args: args{val: user}, wantConverted: true, wantNewVal: "XXX"},
{name: "user patch pointer", args: args{val: userPatch}, wantConverted: true, wantNewVal: "XXX"},
{name: "user patch value", args: args{val: *userPatch}, wantConverted: true, wantNewVal: "XXX"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotNewVal, gotConverted := AuditModelTypeConv(tt.args.val)
assert.Equal(t, tt.wantConverted, gotConverted)
if !tt.wantConverted {
assert.Equal(t, tt.wantNewVal, gotNewVal)
}
})
}
}
func TestAuditModelTypeConvCommandArgs(t *testing.T) {
tcs := []struct {
name string
input CommandArgs
expectedCommand string
}{
{
name: "empty input",
input: CommandArgs{},
expectedCommand: "",
},
{
name: "no arguments",
input: CommandArgs{
Command: "/command",
},
expectedCommand: "/command",
},
{
name: "some arguments",
input: CommandArgs{
Command: "/command --test test --test2 test",
},
expectedCommand: "/command",
},
{
name: "with multiple spaces and tabs",
input: CommandArgs{
Command: "/command --test test --test2 test",
},
expectedCommand: "/command",
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
args := newAuditCommandArgs(&tc.input)
require.Equal(t, tc.expectedCommand, args.Command)
})
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type Audits []Audit
func (o Audits) Etag() string {
if len(o) > 0 {
// the first in the list is always the most current
return Etag(o[0].CreateAt)
}
return ""
}

View File

@@ -0,0 +1,300 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"crypto/sha256"
"encoding/base64"
"net/http"
"net/url"
"regexp"
)
var (
codeChallengeRegex = regexp.MustCompile("^[A-Za-z0-9_-]+$")
codeVerifierRegex = regexp.MustCompile(`^[A-Za-z0-9\-._~]+$`)
)
const (
AuthCodeExpireTime = 60 * 10 // 10 minutes
AuthCodeResponseType = "code"
ImplicitResponseType = "token"
DefaultScope = "user"
PKCECodeChallengeMethodS256 = "S256"
PKCECodeChallengeMinLength = 43
PKCECodeChallengeMaxLength = 128
PKCECodeVerifierMinLength = 43
PKCECodeVerifierMaxLength = 128
)
type AuthData struct {
ClientId string `json:"client_id"`
UserId string `json:"user_id"`
Code string `json:"code"`
ExpiresIn int32 `json:"expires_in"`
CreateAt int64 `json:"create_at"`
RedirectUri string `json:"redirect_uri"`
State string `json:"state"`
Scope string `json:"scope"`
CodeChallenge string `json:"code_challenge,omitempty"`
CodeChallengeMethod string `json:"code_challenge_method,omitempty"`
Resource string `json:"resource,omitempty"`
}
type AuthorizeRequest struct {
ResponseType string `json:"response_type"`
ClientId string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Scope string `json:"scope"`
State string `json:"state"`
CodeChallenge string `json:"code_challenge,omitempty"`
CodeChallengeMethod string `json:"code_challenge_method,omitempty"`
Resource string `json:"resource,omitempty"`
}
// IsValid validates the AuthData and returns an error if it isn't configured
// correctly.
func (ad *AuthData) IsValid() *AppError {
if !IsValidId(ad.ClientId) {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.client_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(ad.UserId) {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.user_id.app_error", nil, "", http.StatusBadRequest)
}
if ad.Code == "" || len(ad.Code) > 128 {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.auth_code.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
if ad.ExpiresIn == 0 {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.expires.app_error", nil, "", http.StatusBadRequest)
}
if ad.CreateAt <= 0 {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.create_at.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
if len(ad.RedirectUri) > 256 || !IsValidHTTPURL(ad.RedirectUri) {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.redirect_uri.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
if len(ad.State) > 1024 {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.state.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
if len(ad.Scope) > 128 {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.scope.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
// PKCE validation - if one PKCE field is present, both must be present and valid
if ad.CodeChallenge != "" || ad.CodeChallengeMethod != "" {
if err := ad.validatePKCE(); err != nil {
return err
}
}
// Resource validation per RFC 8707
if ad.Resource != "" {
if err := ValidateResourceParameter(ad.Resource, ad.ClientId, "AuthData.IsValid"); err != nil {
return err
}
}
return nil
}
// IsValid validates the AuthorizeRequest and returns an error if it isn't configured
// correctly.
func (ar *AuthorizeRequest) IsValid() *AppError {
if !IsValidId(ar.ClientId) {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.client_id.app_error", nil, "", http.StatusBadRequest)
}
if ar.ResponseType == "" {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.response_type.app_error", nil, "", http.StatusBadRequest)
}
if ar.RedirectURI == "" || len(ar.RedirectURI) > 256 || !IsValidHTTPURL(ar.RedirectURI) {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.redirect_uri.app_error", nil, "client_id="+ar.ClientId, http.StatusBadRequest)
}
if len(ar.State) > 1024 {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.state.app_error", nil, "client_id="+ar.ClientId, http.StatusBadRequest)
}
if len(ar.Scope) > 128 {
return NewAppError("AuthData.IsValid", "model.authorize.is_valid.scope.app_error", nil, "client_id="+ar.ClientId, http.StatusBadRequest)
}
// PKCE validation - if one PKCE field is present, both must be present and valid
if ar.CodeChallenge != "" || ar.CodeChallengeMethod != "" {
if err := ar.validatePKCE(); err != nil {
return err
}
}
// Resource validation per RFC 8707
if ar.Resource != "" {
if err := ValidateResourceParameter(ar.Resource, ar.ClientId, "AuthorizeRequest.IsValid"); err != nil {
return err
}
}
return nil
}
func (ad *AuthData) PreSave() {
if ad.ExpiresIn == 0 {
ad.ExpiresIn = AuthCodeExpireTime
}
if ad.CreateAt == 0 {
ad.CreateAt = GetMillis()
}
if ad.Scope == "" {
ad.Scope = DefaultScope
}
}
func (ad *AuthData) IsExpired() bool {
return GetMillis() > ad.CreateAt+int64(ad.ExpiresIn*1000)
}
// validatePKCEParameters validates PKCE parameters (shared validation logic)
func validatePKCEParameters(codeChallenge, codeChallengeMethod, clientId, caller string) *AppError {
if codeChallenge == "" {
return NewAppError(caller, "model.authorize.is_valid.code_challenge.app_error", nil, "client_id="+clientId, http.StatusBadRequest)
}
if codeChallengeMethod == "" {
return NewAppError(caller, "model.authorize.is_valid.code_challenge_method.app_error", nil, "client_id="+clientId, http.StatusBadRequest)
}
// Only support S256 method for security
if codeChallengeMethod != PKCECodeChallengeMethodS256 {
return NewAppError(caller, "model.authorize.is_valid.code_challenge_method.unsupported.app_error", nil, "client_id="+clientId+", method="+codeChallengeMethod, http.StatusBadRequest)
}
// Validate code challenge format (base64url encoded)
if len(codeChallenge) < PKCECodeChallengeMinLength || len(codeChallenge) > PKCECodeChallengeMaxLength {
return NewAppError(caller, "model.authorize.is_valid.code_challenge.length.app_error", nil, "client_id="+clientId, http.StatusBadRequest)
}
// Validate base64url format (no padding, URL-safe characters)
if !codeChallengeRegex.MatchString(codeChallenge) {
return NewAppError(caller, "model.authorize.is_valid.code_challenge.format.app_error", nil, "client_id="+clientId, http.StatusBadRequest)
}
return nil
}
// validatePKCE validates PKCE parameters for AuthData
func (ad *AuthData) validatePKCE() *AppError {
return validatePKCEParameters(ad.CodeChallenge, ad.CodeChallengeMethod, ad.ClientId, "AuthData.validatePKCE")
}
// validatePKCE validates PKCE parameters for AuthorizeRequest
func (ar *AuthorizeRequest) validatePKCE() *AppError {
return validatePKCEParameters(ar.CodeChallenge, ar.CodeChallengeMethod, ar.ClientId, "AuthorizeRequest.validatePKCE")
}
// VerifyPKCE verifies a PKCE code_verifier against the stored code_challenge
func (ad *AuthData) VerifyPKCE(codeVerifier string) bool {
// Both empty = no PKCE was used (backward compatibility)
if ad.CodeChallenge == "" && ad.CodeChallengeMethod == "" {
return true
}
// Only one empty = invalid data state
if ad.CodeChallenge == "" || ad.CodeChallengeMethod == "" {
return false
}
// Validate code verifier length
if len(codeVerifier) < PKCECodeVerifierMinLength || len(codeVerifier) > PKCECodeVerifierMaxLength {
return false
}
// Validate code verifier format (unreserved characters from RFC 3986)
if !codeVerifierRegex.MatchString(codeVerifier) {
return false
}
// Only S256 method is supported
if ad.CodeChallengeMethod != PKCECodeChallengeMethodS256 {
return false
}
// Calculate S256 challenge: BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
hash := sha256.Sum256([]byte(codeVerifier))
calculatedChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
return calculatedChallenge == ad.CodeChallenge
}
// ValidatePKCEForClientType validates PKCE parameters based on OAuth client type and security requirements
func (ad *AuthData) ValidatePKCEForClientType(isPublicClient bool, codeVerifier string) *AppError {
if isPublicClient {
// RFC 7636: Public clients MUST use PKCE
if ad.CodeChallenge == "" {
return NewAppError("AuthData.ValidatePKCEForClientType", "model.authorize.validate_pkce.public_client_required.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
if codeVerifier == "" {
return NewAppError("AuthData.ValidatePKCEForClientType", "model.authorize.validate_pkce.verifier_required.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
// Verify the code verifier matches the stored code challenge
if !ad.VerifyPKCE(codeVerifier) {
return NewAppError("AuthData.ValidatePKCEForClientType", "model.authorize.validate_pkce.verification_failed.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
} else {
// Confidential clients: PKCE is optional but enforced if initiated
if ad.CodeChallenge != "" {
// Client started flow with PKCE - code_verifier is required
if codeVerifier == "" {
return NewAppError("AuthData.ValidatePKCEForClientType", "model.authorize.validate_pkce.verifier_required.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
// Verify the code verifier matches the stored code challenge
if !ad.VerifyPKCE(codeVerifier) {
return NewAppError("AuthData.ValidatePKCEForClientType", "model.authorize.validate_pkce.verification_failed.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
} else if codeVerifier != "" {
// Client provided code_verifier but didn't use PKCE in authorization - reject
return NewAppError("AuthData.ValidatePKCEForClientType", "model.authorize.validate_pkce.not_used_in_auth.app_error", nil, "client_id="+ad.ClientId, http.StatusBadRequest)
}
}
return nil
}
// ValidateResourceParameter validates a resource parameter per RFC 8707
func ValidateResourceParameter(resource, clientId, caller string) *AppError {
// Empty resource parameter is allowed (no resource specified)
if resource == "" {
return nil
}
// Resource must not exceed 512 characters to fit in database column
if len(resource) > 512 {
return NewAppError(caller, "model.authorize.is_valid.resource.length.app_error", nil, "client_id="+clientId, http.StatusBadRequest)
}
parsedURL, err := url.Parse(resource)
if err != nil {
return NewAppError(caller, "model.authorize.is_valid.resource.invalid_uri.app_error", nil, "client_id="+clientId, http.StatusBadRequest)
}
// Must be absolute URI (has scheme)
if !parsedURL.IsAbs() {
return NewAppError(caller, "model.authorize.is_valid.resource.not_absolute.app_error", nil, "client_id="+clientId, http.StatusBadRequest)
}
// Must not include a fragment component per RFC 8707
if parsedURL.Fragment != "" {
return NewAppError(caller, "model.authorize.is_valid.resource.has_fragment.app_error", nil, "client_id="+clientId, http.StatusBadRequest)
}
return nil
}

View File

@@ -0,0 +1,371 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAuthPreSave(t *testing.T) {
a1 := AuthData{}
a1.ClientId = NewId()
a1.UserId = NewId()
a1.Code = NewId()
a1.PreSave()
a1.IsExpired()
}
func TestAuthIsValid(t *testing.T) {
ad := AuthData{}
require.NotNil(t, ad.IsValid())
ad.ClientId = NewRandomString(28)
require.NotNil(t, ad.IsValid(), "Should have failed Client Id")
ad.ClientId = NewId()
require.NotNil(t, ad.IsValid())
ad.UserId = NewRandomString(28)
require.NotNil(t, ad.IsValid(), "Should have failed User Id")
ad.UserId = NewId()
require.NotNil(t, ad.IsValid())
ad.Code = NewRandomString(129)
require.NotNil(t, ad.IsValid(), "Should have failed Code to long")
ad.Code = ""
require.NotNil(t, ad.IsValid(), "Should have failed Code not set")
ad.Code = NewId()
require.NotNil(t, ad.IsValid())
ad.ExpiresIn = 0
require.NotNil(t, ad.IsValid(), "Should have failed invalid ExpiresIn")
ad.ExpiresIn = 1
require.NotNil(t, ad.IsValid())
ad.CreateAt = 0
require.NotNil(t, ad.IsValid(), "Should have failed Invalid Create At")
ad.CreateAt = 1
require.NotNil(t, ad.IsValid())
ad.State = NewRandomString(129)
require.NotNil(t, ad.IsValid(), "Should have failed invalid State")
ad.State = NewRandomString(128)
require.NotNil(t, ad.IsValid())
ad.Scope = NewRandomString(1025)
require.NotNil(t, ad.IsValid(), "Should have failed invalid Scope")
ad.Scope = NewRandomString(128)
require.NotNil(t, ad.IsValid())
ad.RedirectUri = ""
require.NotNil(t, ad.IsValid(), "Should have failed Redirect URI not set")
ad.RedirectUri = NewRandomString(28)
require.NotNil(t, ad.IsValid(), "Should have failed invalid URL")
ad.RedirectUri = NewRandomString(257)
require.NotNil(t, ad.IsValid(), "Should have failed invalid URL")
ad.RedirectUri = "http://example.com"
require.Nil(t, ad.IsValid())
}
func TestAuthDataVerifyPKCE(t *testing.T) {
t.Run("S256_ValidVerifier", func(t *testing.T) {
codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
expectedChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
authData := &AuthData{
CodeChallenge: expectedChallenge,
CodeChallengeMethod: PKCECodeChallengeMethodS256,
}
require.True(t, authData.VerifyPKCE(codeVerifier))
})
t.Run("S256_InvalidVerifier", func(t *testing.T) {
authData := &AuthData{
CodeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
CodeChallengeMethod: PKCECodeChallengeMethodS256,
}
require.False(t, authData.VerifyPKCE("wrong_verifier"))
require.False(t, authData.VerifyPKCE(""))
require.False(t, authData.VerifyPKCE("short"))
longVerifier := make([]byte, PKCECodeVerifierMaxLength+1)
for i := range longVerifier {
longVerifier[i] = 'a'
}
require.False(t, authData.VerifyPKCE(string(longVerifier)))
require.False(t, authData.VerifyPKCE("invalid@characters#here!"))
})
t.Run("BackwardCompatibility", func(t *testing.T) {
authData := &AuthData{}
require.True(t, authData.VerifyPKCE("any_verifier"))
require.True(t, authData.VerifyPKCE(""))
})
t.Run("UnsupportedMethod", func(t *testing.T) {
authData := &AuthData{
CodeChallenge: "some_challenge",
CodeChallengeMethod: "plain",
}
require.False(t, authData.VerifyPKCE("any_verifier"))
})
}
func TestValidatePKCEParameters(t *testing.T) {
validChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
validMethod := PKCECodeChallengeMethodS256
clientId := NewId()
t.Run("ValidParameters", func(t *testing.T) {
require.Nil(t, validatePKCEParameters(validChallenge, validMethod, clientId, "test"))
})
t.Run("EmptyChallenge", func(t *testing.T) {
require.NotNil(t, validatePKCEParameters("", validMethod, clientId, "test"))
})
t.Run("EmptyMethod", func(t *testing.T) {
require.NotNil(t, validatePKCEParameters(validChallenge, "", clientId, "test"))
})
t.Run("InvalidMethod", func(t *testing.T) {
require.NotNil(t, validatePKCEParameters(validChallenge, "plain", clientId, "test"))
})
t.Run("ChallengeTooShort", func(t *testing.T) {
require.NotNil(t, validatePKCEParameters("short", validMethod, clientId, "test"))
})
t.Run("ChallengeTooLong", func(t *testing.T) {
longChallenge := make([]byte, PKCECodeChallengeMaxLength+1)
for i := range longChallenge {
longChallenge[i] = 'a'
}
require.NotNil(t, validatePKCEParameters(string(longChallenge), validMethod, clientId, "test"))
})
}
func TestAuthorizeRequestPKCE(t *testing.T) {
t.Run("RequiredTogether", func(t *testing.T) {
authRequest := &AuthorizeRequest{
ResponseType: ResponseTypeCode,
ClientId: NewId(),
RedirectURI: "https://example.com/callback",
State: "test_state",
Scope: "user",
}
require.Nil(t, authRequest.IsValid())
authRequest.CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
require.NotNil(t, authRequest.IsValid())
authRequest.CodeChallengeMethod = PKCECodeChallengeMethodS256
require.Nil(t, authRequest.IsValid())
authRequest.CodeChallenge = ""
require.NotNil(t, authRequest.IsValid())
})
t.Run("ValidationDetails", func(t *testing.T) {
authRequest := &AuthorizeRequest{
ResponseType: ResponseTypeCode,
ClientId: NewId(),
RedirectURI: "https://example.com/callback",
State: "test_state",
Scope: "user",
CodeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
CodeChallengeMethod: PKCECodeChallengeMethodS256,
}
require.Nil(t, authRequest.IsValid())
authRequest.CodeChallengeMethod = "plain"
require.NotNil(t, authRequest.IsValid())
authRequest.CodeChallengeMethod = PKCECodeChallengeMethodS256
authRequest.CodeChallenge = "short"
require.NotNil(t, authRequest.IsValid())
longChallenge := make([]byte, PKCECodeChallengeMaxLength+1)
for i := range longChallenge {
longChallenge[i] = 'a'
}
authRequest.CodeChallenge = string(longChallenge)
require.NotNil(t, authRequest.IsValid())
})
}
func TestValidateResourceParameter(t *testing.T) {
clientId := NewId()
caller := "TestValidateResourceParameter"
t.Run("Valid resource URIs should pass", func(t *testing.T) {
validResources := []string{
"https://api.example.com",
"https://api.example.com/resource",
"https://api.example.com:8080/resource",
"http://localhost:3000/api",
"https://sub.domain.example.com/path",
"https://example.com/api/v1/resource?param=value",
}
for _, resource := range validResources {
require.Nil(t, ValidateResourceParameter(resource, clientId, caller),
"Expected valid resource '%s' to pass validation", resource)
}
})
t.Run("Invalid resource URIs should fail", func(t *testing.T) {
invalidResources := []string{
"not-a-uri", // Not a URI
"relative/path", // Relative URI
"/absolute/path", // Absolute path but not URI
"https://example.com/path#fragment", // Contains fragment
"malformed://[invalid", // Malformed URI
}
for _, resource := range invalidResources {
require.NotNil(t, ValidateResourceParameter(resource, clientId, caller),
"Expected invalid resource '%s' to fail validation", resource)
}
})
t.Run("Empty resource should pass", func(t *testing.T) {
// Empty resource parameter should be allowed (means no resource specified)
require.Nil(t, ValidateResourceParameter("", clientId, caller),
"Expected empty resource to pass validation")
})
t.Run("Resource URI too long should fail", func(t *testing.T) {
// Create a resource URI longer than 512 characters
longPath := make([]byte, 500) // Base path will push us over 512
for i := range longPath {
longPath[i] = 'a'
}
longResource := "https://example.com/" + string(longPath)
require.NotNil(t, ValidateResourceParameter(longResource, clientId, caller),
"Expected resource URI longer than 512 characters to fail validation")
})
t.Run("Fragment in URI should fail", func(t *testing.T) {
resourceWithFragment := "https://example.com/api#section1"
require.NotNil(t, ValidateResourceParameter(resourceWithFragment, clientId, caller),
"Expected resource URI with fragment to fail validation")
})
t.Run("Query parameters are allowed", func(t *testing.T) {
resourceWithQuery := "https://example.com/api?param1=value1&param2=value2"
require.Nil(t, ValidateResourceParameter(resourceWithQuery, clientId, caller),
"Expected resource URI with query parameters to pass validation")
})
t.Run("Different schemes should pass if valid", func(t *testing.T) {
validSchemes := []string{
"https://example.com/api",
"http://example.com/api",
}
for _, resource := range validSchemes {
require.Nil(t, ValidateResourceParameter(resource, clientId, caller),
"Expected resource with scheme '%s' to pass validation", resource)
}
})
}
func TestAuthData_ResourceValidation(t *testing.T) {
authData := &AuthData{
ClientId: NewId(),
UserId: NewId(),
Code: NewId(),
ExpiresIn: 300,
CreateAt: GetMillis(),
RedirectUri: "https://example.com/callback",
State: "test_state",
Scope: "user",
}
t.Run("Valid AuthData without resource should pass", func(t *testing.T) {
require.Nil(t, authData.IsValid())
})
t.Run("Valid AuthData with valid resource should pass", func(t *testing.T) {
authData.Resource = "https://api.example.com"
require.Nil(t, authData.IsValid())
})
t.Run("AuthData with invalid resource should fail", func(t *testing.T) {
authData.Resource = "invalid-uri"
require.NotNil(t, authData.IsValid())
})
t.Run("AuthData with resource containing fragment should fail", func(t *testing.T) {
authData.Resource = "https://api.example.com#fragment"
require.NotNil(t, authData.IsValid())
})
t.Run("AuthData with empty resource should pass", func(t *testing.T) {
authData.Resource = ""
require.Nil(t, authData.IsValid())
})
}
func TestAuthorizeRequest_ResourceValidation(t *testing.T) {
authRequest := &AuthorizeRequest{
ResponseType: ResponseTypeCode,
ClientId: NewId(),
RedirectURI: "https://example.com/callback",
State: "test_state",
Scope: "user",
}
t.Run("Valid AuthorizeRequest without resource should pass", func(t *testing.T) {
require.Nil(t, authRequest.IsValid())
})
t.Run("Valid AuthorizeRequest with valid resource should pass", func(t *testing.T) {
authRequest.Resource = "https://api.example.com"
require.Nil(t, authRequest.IsValid())
})
t.Run("AuthorizeRequest with invalid resource should fail", func(t *testing.T) {
authRequest.Resource = "not-a-valid-uri"
require.NotNil(t, authRequest.IsValid())
})
t.Run("AuthorizeRequest with resource too long should fail", func(t *testing.T) {
// Create a resource URI longer than 512 characters
longPath := make([]byte, 500)
for i := range longPath {
longPath[i] = 'a'
}
authRequest.Resource = "https://example.com/" + string(longPath)
require.NotNil(t, authRequest.IsValid())
})
t.Run("AuthorizeRequest with empty resource should pass", func(t *testing.T) {
authRequest.Resource = ""
require.Nil(t, authRequest.IsValid())
})
}

View File

@@ -0,0 +1,223 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"context"
"encoding/json"
"maps"
)
// TranslationObjectType identifies the type of object being translated
const (
TranslationObjectTypePost = "post"
)
// TranslationType indicates the type of translated content
type TranslationType string
const (
TranslationTypeString TranslationType = "string"
TranslationTypeObject TranslationType = "object"
)
// TranslationState represents the state of a translation
type TranslationState string
const (
TranslationStateReady TranslationState = "ready" // Translation completed successfully
TranslationStateSkipped TranslationState = "skipped" // Translation not needed (srcLang == dstLang or only masked content)
TranslationStateProcessing TranslationState = "processing" // Translation in progress
TranslationStateUnavailable TranslationState = "unavailable" // Translation failed or not configured
)
// Translation represents a single translation result
type Translation struct {
ObjectID string `json:"object_id"`
ObjectType string `json:"object_type"`
ChannelID string `json:"channel_id,omitempty"` // Channel ID for efficient queries
Lang string `json:"lang"`
Provider string `json:"provider"`
Type TranslationType `json:"type"`
Text string `json:"text"`
ObjectJSON json.RawMessage `json:"object_json,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
State TranslationState `json:"state"`
Meta map[string]any `json:"meta,omitempty"`
NormHash string `json:"norm_hash,omitempty"`
UpdateAt int64 `json:"update_at,omitempty"` // Timestamp in milliseconds
}
func (t *Translation) Clone() *Translation {
if t == nil {
return nil
}
var confidence *float64
if t.Confidence != nil {
val := *t.Confidence
confidence = &val
}
var meta map[string]any
if t.Meta != nil {
meta = make(map[string]any, len(t.Meta))
maps.Copy(meta, t.Meta)
}
var objectJSON json.RawMessage
if t.ObjectJSON != nil {
objectJSON = make([]byte, len(t.ObjectJSON))
copy(objectJSON, t.ObjectJSON)
}
return &Translation{
ObjectID: t.ObjectID,
ObjectType: t.ObjectType,
ChannelID: t.ChannelID,
Lang: t.Lang,
Provider: t.Provider,
Type: t.Type,
Text: t.Text,
ObjectJSON: objectJSON,
Confidence: confidence,
State: t.State,
Meta: meta,
NormHash: t.NormHash,
UpdateAt: t.UpdateAt,
}
}
// ToPostTranslation converts a Translation to a PostTranslation.
// This is the canonical conversion function used throughout the codebase
// to ensure consistent struct creation when populating post metadata.
func (t *Translation) ToPostTranslation() *PostTranslation {
if t == nil {
return nil
}
// Extract source language from meta if available
var sourceLang string
if srcLang, ok := t.Meta["src_lang"].(string); ok {
sourceLang = srcLang
}
pt := &PostTranslation{
State: string(t.State),
SourceLang: sourceLang,
}
if t.Type == TranslationTypeObject {
pt.Object = t.ObjectJSON
} else {
pt.Text = t.Text
}
return pt
}
func (t *Translation) IsValid() *AppError {
if t == nil {
return NewAppError("Translation.IsValid", "model.translation.is_valid.nil.app_error", nil, "", 400)
}
if t.ObjectID == "" || !IsValidId(t.ObjectID) {
return NewAppError("Translation.IsValid", "model.translation.is_valid.object_id.app_error", nil, "invalid object id", 400)
}
if t.ObjectType == "" {
return NewAppError("Translation.IsValid", "model.translation.is_valid.object_type.app_error", nil, "object type is empty", 400)
}
if t.Lang == "" {
return NewAppError("Translation.IsValid", "model.translation.is_valid.lang.app_error", nil, "lang is empty", 400)
}
// Text and provider are required only for ready state
if t.State == TranslationStateReady {
if t.Provider == "" {
return NewAppError("Translation.IsValid", "model.translation.is_valid.provider.app_error", nil, "provider is empty for ready state", 400)
}
if t.Type == "" {
return NewAppError("Translation.IsValid", "model.translation.is_valid.type.app_error", nil, "type is empty", 400)
}
if t.Type != TranslationTypeString && t.Type != TranslationTypeObject {
return NewAppError("Translation.IsValid", "model.translation.is_valid.type_invalid.app_error", nil, "invalid type", 400)
}
if t.Type == TranslationTypeString && t.Text == "" {
return NewAppError("Translation.IsValid", "model.translation.is_valid.text.app_error", nil, "text is empty", 400)
}
if t.Type == TranslationTypeObject && len(t.ObjectJSON) == 0 {
return NewAppError("Translation.IsValid", "model.translation.is_valid.object_json.app_error", nil, "object json is empty", 400)
}
}
// Provider required for unavailable state (to indicate why it failed)
if t.State == TranslationStateUnavailable && t.Provider == "" {
return NewAppError("Translation.IsValid", "model.translation.is_valid.provider.app_error", nil, "provider is empty for unavailable state", 400)
}
return nil
}
// Context keys for auto-translation path tracking
type AutoTranslationContextKey string
const (
ContextKeyAutoTranslationPath AutoTranslationContextKey = "autotranslation_path"
)
// ErrAutoTranslationNotAvailable is returned when the auto-translation feature is not available
// due to missing license, disabled feature flag, or disabled configuration.
// Callers can check for this specific error to handle unavailability gracefully.
type ErrAutoTranslationNotAvailable struct {
reason string
}
func (e *ErrAutoTranslationNotAvailable) Error() string {
if e.reason != "" {
return "auto-translation feature not available: " + e.reason
}
return "auto-translation feature not available"
}
// NewErrAutoTranslationNotAvailable creates a new ErrAutoTranslationNotAvailable error
func NewErrAutoTranslationNotAvailable(reason string) *ErrAutoTranslationNotAvailable {
return &ErrAutoTranslationNotAvailable{reason: reason}
}
// AutoTranslationPath represents the code path that initiated a translation.
// This enables observability (metrics) and path-specific behavior (timeouts).
type AutoTranslationPath string
// Auto-translation path values for metrics and behavior control.
// Paths follow pattern: <operation> for object operations, <channel> for delivery paths.
const (
AutoTranslationPathCreate AutoTranslationPath = "create" // Object creation (e.g., create post)
AutoTranslationPathEdit AutoTranslationPath = "edit" // Object edit (e.g., edit post)
AutoTranslationPathFetch AutoTranslationPath = "fetch" // API fetch (on-demand for older objects)
AutoTranslationPathWebSocket AutoTranslationPath = "websocket" // WebSocket event augmentation
AutoTranslationPathPushNotification AutoTranslationPath = "push_notification" // Push notification
AutoTranslationPathEmailNotification AutoTranslationPath = "email_notification" // Email notification
AutoTranslationPathUnknown AutoTranslationPath = "unknown" // Fallback
)
// WithAutoTranslationPath adds translation path to context for metrics and behavior control.
// This enables both observability (metrics tracking) and path-specific behavior
// (e.g., different timeouts for websocket vs notification paths).
//
// Usage in server (API layer):
//
// ctx = model.WithAutoTranslationPath(ctx, model.AutoTranslationPathCreate)
// translation, err := a.AutoTranslation().Translate(ctx, ...)
func WithAutoTranslationPath(ctx context.Context, path AutoTranslationPath) context.Context {
return context.WithValue(ctx, ContextKeyAutoTranslationPath, path)
}
// GetAutoTranslationPath extracts translation path from context.
// Returns AutoTranslationPathUnknown if no path is set.
//
// Usage in enterprise:
//
// path := model.GetAutoTranslationPath(ctx)
func GetAutoTranslationPath(ctx context.Context) AutoTranslationPath {
if path, ok := ctx.Value(ContextKeyAutoTranslationPath).(AutoTranslationPath); ok {
return path
}
return AutoTranslationPathUnknown
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestWithAutoTranslationPath(t *testing.T) {
tests := []struct {
name string
path AutoTranslationPath
expected AutoTranslationPath
}{
{
name: "create path",
path: AutoTranslationPathCreate,
expected: AutoTranslationPathCreate,
},
{
name: "edit path",
path: AutoTranslationPathEdit,
expected: AutoTranslationPathEdit,
},
{
name: "fetch path",
path: AutoTranslationPathFetch,
expected: AutoTranslationPathFetch,
},
{
name: "websocket path",
path: AutoTranslationPathWebSocket,
expected: AutoTranslationPathWebSocket,
},
{
name: "push notification path",
path: AutoTranslationPathPushNotification,
expected: AutoTranslationPathPushNotification,
},
{
name: "email notification path",
path: AutoTranslationPathEmailNotification,
expected: AutoTranslationPathEmailNotification,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
ctx = WithAutoTranslationPath(ctx, tt.path)
got := GetAutoTranslationPath(ctx)
assert.Equal(t, tt.expected, got)
})
}
}
func TestGetAutoTranslationPathUnknown(t *testing.T) {
t.Run("no path set returns unknown", func(t *testing.T) {
ctx := context.Background()
got := GetAutoTranslationPath(ctx)
assert.Equal(t, AutoTranslationPathUnknown, got)
})
t.Run("wrong type in context returns unknown", func(t *testing.T) {
ctx := context.WithValue(context.Background(), ContextKeyAutoTranslationPath, "string_not_type")
got := GetAutoTranslationPath(ctx)
assert.Equal(t, AutoTranslationPathUnknown, got)
})
}
func TestAutoTranslationPathConstants(t *testing.T) {
t.Run("all constants are defined", func(t *testing.T) {
assert.Equal(t, AutoTranslationPath("create"), AutoTranslationPathCreate)
assert.Equal(t, AutoTranslationPath("edit"), AutoTranslationPathEdit)
assert.Equal(t, AutoTranslationPath("fetch"), AutoTranslationPathFetch)
assert.Equal(t, AutoTranslationPath("websocket"), AutoTranslationPathWebSocket)
assert.Equal(t, AutoTranslationPath("push_notification"), AutoTranslationPathPushNotification)
assert.Equal(t, AutoTranslationPath("email_notification"), AutoTranslationPathEmailNotification)
assert.Equal(t, AutoTranslationPath("unknown"), AutoTranslationPathUnknown)
})
}

259
server/public/model/bot.go Normal file
View File

@@ -0,0 +1,259 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"unicode/utf8"
)
const (
BotDisplayNameMaxRunes = UserFirstNameMaxRunes
BotDescriptionMaxRunes = 1024
BotCreatorIdMaxRunes = KeyValuePluginIdMaxRunes // UserId or PluginId
BotSystemBotUsername = "system-bot"
)
// ProtectedBotUsernames is the set of system-owned bots that must not be
// disabled. These bots back core functionality (post reminders, reports,
// channel notifications, content review), so disabling them silently breaks
// features for the lifetime of the installation.
var ProtectedBotUsernames = map[string]struct{}{
BotSystemBotUsername: {},
ContentFlaggingBotUsername: {},
}
// Bot is a special type of User meant for programmatic interactions.
// Note that the primary key of a bot is the UserId, and matches the primary key of the
// corresponding user.
type Bot struct {
UserId string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name,omitempty"`
Description string `json:"description,omitempty"`
OwnerId string `json:"owner_id"`
LastIconUpdate int64 `json:"last_icon_update,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
}
// IsSystemOwned returns whether the bot is one of the protected, system-owned
// bots that must not be disabled. See ProtectedBotUsernames.
func (b *Bot) IsSystemOwned() bool {
_, ok := ProtectedBotUsernames[b.Username]
return ok
}
// MarshalJSON adds the computed system_owned field to the bot's JSON
// representation without persisting it as a stored field.
func (b *Bot) MarshalJSON() ([]byte, error) {
type Alias Bot
return json.Marshal(&struct {
SystemOwned bool `json:"system_owned"`
*Alias
}{
SystemOwned: b.IsSystemOwned(),
Alias: (*Alias)(b),
})
}
func (b *Bot) Auditable() map[string]any {
return map[string]any{
"user_id": b.UserId,
"username": b.Username,
"display_name": b.DisplayName,
"description": b.Description,
"owner_id": b.OwnerId,
"last_icon_update": b.LastIconUpdate,
"create_at": b.CreateAt,
"update_at": b.UpdateAt,
"delete_at": b.DeleteAt,
}
}
// BotPatch is a description of what fields to update on an existing bot.
type BotPatch struct {
Username *string `json:"username"`
DisplayName *string `json:"display_name"`
Description *string `json:"description"`
}
func (b *BotPatch) Auditable() map[string]any {
return map[string]any{
"username": b.Username,
"display_name": b.DisplayName,
"description": b.Description,
}
}
// BotGetOptions acts as a filter on bulk bot fetching queries.
type BotGetOptions struct {
OwnerId string
IncludeDeleted bool
OnlyOrphaned bool
Page int
PerPage int
}
// BotList is a list of bots.
type BotList []*Bot
// Trace describes the minimum information required to identify a bot for the purpose of logging.
func (b *Bot) Trace() map[string]any {
return map[string]any{"user_id": b.UserId}
}
// Clone returns a shallow copy of the bot.
func (b *Bot) Clone() *Bot {
bCopy := *b
return &bCopy
}
// IsValidCreate validates bot for Create call. This skips validations of fields that are auto-filled on Create
func (b *Bot) IsValidCreate() *AppError {
if !IsValidUsername(b.Username) {
return NewAppError("Bot.IsValid", "model.bot.is_valid.username.app_error", b.Trace(), "", http.StatusBadRequest)
}
if utf8.RuneCountInString(b.DisplayName) > BotDisplayNameMaxRunes {
return NewAppError("Bot.IsValid", "model.bot.is_valid.user_id.app_error", b.Trace(), "", http.StatusBadRequest)
}
if utf8.RuneCountInString(b.Description) > BotDescriptionMaxRunes {
return NewAppError("Bot.IsValid", "model.bot.is_valid.description.app_error", b.Trace(), "", http.StatusBadRequest)
}
if b.OwnerId == "" || utf8.RuneCountInString(b.OwnerId) > BotCreatorIdMaxRunes {
return NewAppError("Bot.IsValid", "model.bot.is_valid.creator_id.app_error", b.Trace(), "", http.StatusBadRequest)
}
return nil
}
// IsValid validates the bot and returns an error if it isn't configured correctly.
func (b *Bot) IsValid() *AppError {
if !IsValidId(b.UserId) {
return NewAppError("Bot.IsValid", "model.bot.is_valid.user_id.app_error", b.Trace(), "", http.StatusBadRequest)
}
if b.CreateAt == 0 {
return NewAppError("Bot.IsValid", "model.bot.is_valid.create_at.app_error", b.Trace(), "", http.StatusBadRequest)
}
if b.UpdateAt == 0 {
return NewAppError("Bot.IsValid", "model.bot.is_valid.update_at.app_error", b.Trace(), "", http.StatusBadRequest)
}
return b.IsValidCreate()
}
// PreSave should be run before saving a new bot to the database.
func (b *Bot) PreSave() {
b.CreateAt = GetMillis()
b.UpdateAt = b.CreateAt
b.DeleteAt = 0
b.Username = NormalizeUsername(b.Username)
}
// PreUpdate should be run before saving an updated bot to the database.
func (b *Bot) PreUpdate() {
b.UpdateAt = GetMillis()
}
// Etag generates an etag for caching.
func (b *Bot) Etag() string {
return Etag(b.UserId, b.UpdateAt)
}
// Patch modifies an existing bot with optional fields from the given patch.
// TODO 6.0: consider returning a boolean to indicate whether or not the patch
// applied any changes.
func (b *Bot) Patch(patch *BotPatch) {
if patch.Username != nil {
b.Username = *patch.Username
}
if patch.DisplayName != nil {
b.DisplayName = *patch.DisplayName
}
if patch.Description != nil {
b.Description = *patch.Description
}
}
// WouldPatch returns whether or not the given patch would be applied or not.
func (b *Bot) WouldPatch(patch *BotPatch) bool {
if patch == nil {
return false
}
if patch.Username != nil && *patch.Username != b.Username {
return true
}
if patch.DisplayName != nil && *patch.DisplayName != b.DisplayName {
return true
}
if patch.Description != nil && *patch.Description != b.Description {
return true
}
return false
}
// UserFromBot returns a user model describing the bot fields stored in the User store.
func UserFromBot(b *Bot) *User {
return &User{
Id: b.UserId,
Username: b.Username,
Email: NormalizeEmail(fmt.Sprintf("%s@localhost", b.Username)),
FirstName: b.DisplayName,
Roles: SystemUserRoleId,
}
}
// BotFromUser returns a bot model given a user model
func BotFromUser(u *User) *Bot {
return &Bot{
OwnerId: u.Id,
UserId: u.Id,
Username: u.Username,
DisplayName: u.GetDisplayName(ShowUsername),
}
}
// Etag computes the etag for a list of bots.
func (l *BotList) Etag() string {
id := "0"
var t int64
var delta int64
for _, v := range *l {
if v.UpdateAt > t {
t = v.UpdateAt
id = v.UserId
}
}
return Etag(id, t, delta, len(*l))
}
// MakeBotNotFoundError creates the error returned when a bot does not exist, or when the user isn't allowed to query the bot.
// The errors must the same in both cases to avoid leaking that a user is a bot.
func MakeBotNotFoundError(where, userId string) *AppError {
return NewAppError(where, "store.sql_bot.get.missing.app_error", map[string]any{"user_id": userId}, "", http.StatusNotFound)
}
func IsBotDMChannel(channel *Channel, botUserID string) bool {
if channel.Type != ChannelTypeDirect {
return false
}
if !strings.HasPrefix(channel.Name, botUserID+"__") && !strings.HasSuffix(channel.Name, "__"+botUserID) {
return false
}
return true
}

View File

@@ -0,0 +1,740 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBotTrace(t *testing.T) {
bot := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
}
require.Equal(t, map[string]any{"user_id": bot.UserId}, bot.Trace())
}
func TestBotClone(t *testing.T) {
bot := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
}
clone := bot.Clone()
require.Equal(t, bot, bot.Clone())
require.False(t, bot == clone)
}
func TestBotIsValid(t *testing.T) {
testCases := []struct {
Description string
Bot *Bot
ExpectedIsValid bool
}{
{
"nil bot",
&Bot{},
false,
},
{
"bot with missing user id",
&Bot{
UserId: "",
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bot with invalid user id",
&Bot{
UserId: "invalid",
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bot with missing username",
&Bot{
UserId: NewId(),
Username: "",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bot with invalid username",
&Bot{
UserId: NewId(),
Username: "a@",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bot with long description",
&Bot{
UserId: "",
Username: "username",
DisplayName: "display name",
Description: strings.Repeat("x", 1025),
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bot with missing creator id",
&Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: "",
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bot without create at timestamp",
&Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 0,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bot without update at timestamp",
&Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 0,
DeleteAt: 4,
},
false,
},
{
"bot",
&Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 0,
},
true,
},
{
"bot without description",
&Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 0,
},
true,
},
{
"deleted bot",
&Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "a description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
if testCase.ExpectedIsValid {
require.Nil(t, testCase.Bot.IsValid())
} else {
require.NotNil(t, testCase.Bot.IsValid())
}
})
}
}
func TestBotPreSave(t *testing.T) {
bot := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 0,
DeleteAt: 0,
}
originalBot := &Bot{
UserId: bot.UserId,
Username: bot.Username,
DisplayName: bot.DisplayName,
Description: bot.Description,
OwnerId: bot.OwnerId,
LastIconUpdate: bot.LastIconUpdate,
DeleteAt: bot.DeleteAt,
}
bot.PreSave()
assert.NotEqual(t, 0, bot.CreateAt)
assert.NotEqual(t, 0, bot.UpdateAt)
originalBot.CreateAt = bot.CreateAt
originalBot.UpdateAt = bot.UpdateAt
assert.Equal(t, originalBot, bot)
}
func TestBotPreUpdate(t *testing.T) {
bot := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
DeleteAt: 0,
}
originalBot := &Bot{
UserId: bot.UserId,
Username: bot.Username,
DisplayName: bot.DisplayName,
Description: bot.Description,
OwnerId: bot.OwnerId,
LastIconUpdate: bot.LastIconUpdate,
DeleteAt: bot.DeleteAt,
}
bot.PreSave()
assert.NotEqual(t, 0, bot.UpdateAt)
originalBot.CreateAt = bot.CreateAt
originalBot.UpdateAt = bot.UpdateAt
assert.Equal(t, originalBot, bot)
}
func TestBotEtag(t *testing.T) {
t.Run("same etags", func(t *testing.T) {
bot1 := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
}
bot2 := bot1
assert.Equal(t, bot1.Etag(), bot2.Etag())
})
t.Run("different etags", func(t *testing.T) {
t.Run("different user id", func(t *testing.T) {
bot1 := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
}
bot2 := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: bot1.OwnerId,
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
}
assert.NotEqual(t, bot1.Etag(), bot2.Etag())
})
t.Run("different update at", func(t *testing.T) {
bot1 := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
}
bot2 := &Bot{
UserId: bot1.UserId,
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: bot1.OwnerId,
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 10,
DeleteAt: 4,
}
assert.NotEqual(t, bot1.Etag(), bot2.Etag())
})
})
}
func TestBotPatch(t *testing.T) {
userId1 := NewId()
creatorId1 := NewId()
testCases := []struct {
Description string
Bot *Bot
BotPatch *BotPatch
ExpectedBot *Bot
}{
{
"no update",
&Bot{
UserId: userId1,
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: creatorId1,
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
&BotPatch{},
&Bot{
UserId: userId1,
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: creatorId1,
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
},
{
"partial update",
&Bot{
UserId: userId1,
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: creatorId1,
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
&BotPatch{
Username: new("new_username"),
DisplayName: nil,
Description: new("new description"),
},
&Bot{
UserId: userId1,
Username: "new_username",
DisplayName: "display name",
Description: "new description",
OwnerId: creatorId1,
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
},
{
"full update",
&Bot{
UserId: userId1,
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: creatorId1,
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
&BotPatch{
Username: new("new_username"),
DisplayName: new("new display name"),
Description: new("new description"),
},
&Bot{
UserId: userId1,
Username: "new_username",
DisplayName: "new display name",
Description: "new description",
OwnerId: creatorId1,
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
testCase.Bot.Patch(testCase.BotPatch)
assert.Equal(t, testCase.ExpectedBot, testCase.Bot)
})
}
}
func TestBotWouldPatch(t *testing.T) {
b := &Bot{
UserId: NewId(),
}
t.Run("nil patch", func(t *testing.T) {
ok := b.WouldPatch(nil)
require.False(t, ok)
})
t.Run("nil patch fields", func(t *testing.T) {
patch := &BotPatch{}
ok := b.WouldPatch(patch)
require.False(t, ok)
})
t.Run("patch", func(t *testing.T) {
patch := &BotPatch{
DisplayName: new("BotName"),
}
ok := b.WouldPatch(patch)
require.True(t, ok)
})
t.Run("no patch", func(t *testing.T) {
patch := &BotPatch{
DisplayName: new("BotName"),
}
b.Patch(patch)
ok := b.WouldPatch(patch)
require.False(t, ok)
})
}
func TestUserFromBot(t *testing.T) {
bot1 := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
}
bot2 := &Bot{
UserId: NewId(),
Username: "username2",
DisplayName: "display name 2",
Description: "description 2",
OwnerId: NewId(),
LastIconUpdate: 5,
CreateAt: 6,
UpdateAt: 7,
DeleteAt: 8,
}
assert.Equal(t, &User{
Id: bot1.UserId,
Username: "username",
Email: "username@localhost",
FirstName: "display name",
Roles: "system_user",
}, UserFromBot(bot1))
assert.Equal(t, &User{
Id: bot2.UserId,
Username: "username2",
Email: "username2@localhost",
FirstName: "display name 2",
Roles: "system_user",
}, UserFromBot(bot2))
}
func TestBotFromUser(t *testing.T) {
user := &User{
Id: NewId(),
Username: "username",
CreateAt: 1,
UpdateAt: 2,
DeleteAt: 3,
}
assert.Equal(t, &Bot{
OwnerId: user.Id,
UserId: user.Id,
Username: "username",
DisplayName: "username",
}, BotFromUser(user))
}
func TestBotListEtag(t *testing.T) {
bot1 := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
}
bot1Updated := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 1,
CreateAt: 2,
UpdateAt: 10,
DeleteAt: 4,
}
bot2 := &Bot{
UserId: NewId(),
Username: "username",
DisplayName: "display name",
Description: "description",
OwnerId: NewId(),
LastIconUpdate: 5,
CreateAt: 6,
UpdateAt: 7,
DeleteAt: 8,
}
testCases := []struct {
Description string
BotListA BotList
BotListB BotList
ExpectedEqual bool
}{
{
"empty lists",
BotList{},
BotList{},
true,
},
{
"single item, same list",
BotList{bot1},
BotList{bot1},
true,
},
{
"single item, different update at",
BotList{bot1},
BotList{bot1Updated},
false,
},
{
"single item vs. multiple items",
BotList{bot1},
BotList{bot1, bot2},
false,
},
{
"multiple items, different update at",
BotList{bot1, bot2},
BotList{bot1Updated, bot2},
false,
},
{
"multiple items, same list",
BotList{bot1, bot2},
BotList{bot1, bot2},
true,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
if testCase.ExpectedEqual {
assert.Equal(t, testCase.BotListA.Etag(), testCase.BotListB.Etag())
} else {
assert.NotEqual(t, testCase.BotListA.Etag(), testCase.BotListB.Etag())
}
})
}
}
func TestIsBotChannel(t *testing.T) {
for _, test := range []struct {
Name string
Channel *Channel
Expected bool
}{
{
Name: "not a direct channel",
Channel: &Channel{Type: ChannelTypeOpen},
Expected: false,
},
{
Name: "a direct channel with another user",
Channel: &Channel{
Name: GetDMNameFromIds("user1", "user2"),
Type: ChannelTypeDirect,
},
Expected: false,
},
{
Name: "a direct channel with the name containing the bot's ID first",
Channel: &Channel{
Name: GetDMNameFromIds("botUserID", "user2"),
Type: ChannelTypeDirect,
},
Expected: true,
},
{
Name: "a direct channel with the name containing the bot's ID second",
Channel: &Channel{
Name: GetDMNameFromIds("user1", "botUserID"),
Type: ChannelTypeDirect,
},
Expected: true,
},
} {
t.Run(test.Name, func(t *testing.T) {
assert.Equal(t, test.Expected, IsBotDMChannel(test.Channel, "botUserID"))
})
}
}
func TestBotIsSystemOwned(t *testing.T) {
for username := range ProtectedBotUsernames {
t.Run(username, func(t *testing.T) {
bot := &Bot{Username: username}
assert.True(t, bot.IsSystemOwned())
})
}
t.Run("regular bot", func(t *testing.T) {
bot := &Bot{Username: "some-plugin-bot"}
assert.False(t, bot.IsSystemOwned())
})
}
func TestBotMarshalJSON(t *testing.T) {
t.Run("system-owned bot", func(t *testing.T) {
bot := &Bot{UserId: NewId(), Username: BotSystemBotUsername}
data, err := json.Marshal(bot)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(data, &decoded))
assert.Equal(t, true, decoded["system_owned"])
})
t.Run("regular bot", func(t *testing.T) {
bot := &Bot{UserId: NewId(), Username: "some-plugin-bot"}
data, err := json.Marshal(bot)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(data, &decoded))
assert.Equal(t, false, decoded["system_owned"])
})
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// NewPointer returns a pointer to the object passed.
func NewPointer[T any](t T) *T { return new(t) }
// SafeDereference returns the zero value of T if t is nil.
// Otherwise, it returns t dereferenced.
func SafeDereference[T any](t *T) T {
if t == nil {
var t T
return t
}
return *t
}

View File

@@ -0,0 +1,39 @@
package model
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSafeDereference(t *testing.T) {
t.Run("any", func(t *testing.T) {
s := SafeDereference[any](nil)
assert.Nil(t, s)
})
t.Run("struct", func(t *testing.T) {
s := SafeDereference[struct{}](nil)
assert.Equal(t, struct{}{}, s)
s = SafeDereference(&struct{}{})
assert.Equal(t, struct{}{}, s)
})
t.Run("string", func(t *testing.T) {
s := SafeDereference[string](nil)
assert.Equal(t, "", s)
s = SafeDereference(new("foo"))
assert.Equal(t, "foo", s)
})
t.Run("string pointer", func(t *testing.T) {
s := SafeDereference[*string](nil)
assert.Nil(t, s)
f := new("foo")
s = SafeDereference(&f)
assert.Equal(t, f, s)
})
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// ExportDataDir is the name of the directory were to store additional data
// included with the export (e.g. file attachments).
const ExportDataDir = "data"
type BulkExportOpts struct {
IncludeAttachments bool
IncludeProfilePictures bool
IncludeArchivedChannels bool
IncludeRolesAndSchemes bool
CreateArchive bool
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type BundleInfo struct {
Path string
Manifest *Manifest
ManifestPath string
ManifestError error
}
func (b *BundleInfo) WrapLogger(logger *mlog.Logger) *mlog.Logger {
if b.Manifest != nil {
return logger.With(mlog.String("plugin_id", b.Manifest.Id))
}
return logger.With(mlog.String("plugin_path", b.Path))
}
// Returns bundle info for the given path. The return value is never nil.
func BundleInfoForPath(path string) *BundleInfo {
m, mpath, err := FindManifest(path)
return &BundleInfo{
Path: path,
Manifest: m,
ManifestPath: mpath,
ManifestError: err,
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBundleInfoForPath(t *testing.T) {
dir, err := os.MkdirTemp("", "mm-plugin-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
path := filepath.Join(dir, "plugin.json")
f, err := os.Create(path)
require.NoError(t, err)
_, err = f.WriteString(`{"id": "foo"}`)
f.Close()
require.NoError(t, err)
info := BundleInfoForPath(dir)
assert.Equal(t, info.Path, dir)
assert.NotNil(t, info.Manifest)
assert.Equal(t, info.ManifestPath, path)
assert.NoError(t, info.ManifestError)
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// ValueType indicates whether a value is a literal or another attribute.
type ValueType int
const (
LiteralValue ValueType = iota
AttrValue
)
// Condition represents a single logical condition (e.g., user.attributes.Team == "Engineering").
type Condition struct {
// Left-hand side attribute selector (e.g., "user.attributes.Team").
Attribute string `json:"attribute"`
// The comparison operator.
Operator string `json:"operator"`
// Right-hand side value(s). Can be a single value or a slice for 'in'.
Value any `json:"value"`
// Type of the Value (LiteralValue or AttributeValue). Needed for comparisons like user.attr1 == user.attr2.
ValueType ValueType `json:"value_type"`
// Type of the Attribute (e.g., "text", "select", "multiselect").
AttributeType string `json:"attribute_type"`
// HasMaskedValues is true when non-held values were omitted from this condition.
HasMaskedValues bool `json:"has_masked_values,omitempty"`
}
// VisualExpression represents a series of conditions combined with logical AND.
type VisualExpression struct {
// Conditions is a list of individual conditions that will be ANDed together.
Conditions []Condition `json:"conditions"`
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConditionJSON_HasMaskedValuesOmitEmpty(t *testing.T) {
t.Run("has_masked_values omitted when false", func(t *testing.T) {
condition := Condition{
Attribute: "user.attributes.Program",
Operator: "==",
Value: "Alpha",
ValueType: LiteralValue,
AttributeType: "select",
HasMaskedValues: false,
}
data, err := json.Marshal(condition)
require.NoError(t, err)
// has_masked_values should NOT appear in JSON when false (omitempty)
assert.NotContains(t, string(data), "has_masked_values")
})
t.Run("has_masked_values present when true", func(t *testing.T) {
condition := Condition{
Attribute: "user.attributes.Program",
Operator: "in",
Value: []any{"Alpha"},
ValueType: LiteralValue,
AttributeType: "multiselect",
HasMaskedValues: true,
}
data, err := json.Marshal(condition)
require.NoError(t, err)
assert.Contains(t, string(data), `"has_masked_values":true`)
})
t.Run("has_masked_values defaults to false on unmarshal when absent", func(t *testing.T) {
// Simulate a response from an older server that doesn't include has_masked_values
jsonData := `{"attribute":"user.attributes.Team","operator":"==","value":"Engineering","value_type":0,"attribute_type":"select"}`
var condition Condition
err := json.Unmarshal([]byte(jsonData), &condition)
require.NoError(t, err)
assert.Equal(t, "user.attributes.Team", condition.Attribute)
assert.Equal(t, "==", condition.Operator)
assert.False(t, condition.HasMaskedValues)
})
t.Run("has_masked_values round-trips correctly when true", func(t *testing.T) {
original := Condition{
Attribute: "user.attributes.Program",
Operator: "in",
Value: []any{"Alpha", "Bravo"},
ValueType: LiteralValue,
AttributeType: "multiselect",
HasMaskedValues: true,
}
data, err := json.Marshal(original)
require.NoError(t, err)
var decoded Condition
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, original.Attribute, decoded.Attribute)
assert.Equal(t, original.Operator, decoded.Operator)
assert.Equal(t, original.AttributeType, decoded.AttributeType)
assert.True(t, decoded.HasMaskedValues)
})
}
func TestVisualExpressionJSON(t *testing.T) {
t.Run("visual expression with mixed masked and unmasked conditions", func(t *testing.T) {
ve := VisualExpression{
Conditions: []Condition{
{
Attribute: "user.attributes.Program",
Operator: "in",
Value: []any{"Alpha"},
ValueType: LiteralValue,
AttributeType: "multiselect",
HasMaskedValues: true,
},
{
Attribute: "user.attributes.Location",
Operator: "==",
Value: "Building 1",
ValueType: LiteralValue,
AttributeType: "select",
// HasMaskedValues defaults to false — no masking on this condition
},
},
}
data, err := json.Marshal(ve)
require.NoError(t, err)
jsonStr := string(data)
// First condition should have has_masked_values
assert.Contains(t, jsonStr, `"has_masked_values":true`)
var decoded VisualExpression
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
require.Len(t, decoded.Conditions, 2)
assert.True(t, decoded.Conditions[0].HasMaskedValues)
assert.False(t, decoded.Conditions[1].HasMaskedValues)
})
}

View File

@@ -0,0 +1,661 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"crypto/sha1"
"database/sql/driver"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strings"
"unicode/utf8"
)
var (
// Validates both 3-digit (#RGB) and 6-digit (#RRGGBB) hex colors
channelHexColorRegex = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
)
type ChannelType string
const (
ChannelTypeOpen ChannelType = "O"
ChannelTypePrivate ChannelType = "P"
ChannelTypeDirect ChannelType = "D"
ChannelTypeGroup ChannelType = "G"
ChannelTypeSpace ChannelType = "S"
ChannelTypeOpenBoard ChannelType = "BO"
ChannelTypePrivateBoard ChannelType = "BP"
ChannelPropsBoardLinkedProperties = "board:linked_properties"
ChannelGroupMaxUsers = 8
ChannelGroupMinUsers = 3
DefaultChannelName = "town-square"
ChannelDisplayNameMaxRunes = 64
ChannelNameMinLength = 1
ChannelNameMaxLength = 64
ChannelHeaderMaxRunes = 1024
ChannelPurposeMaxRunes = 250
ChannelCacheSize = 25000
ChannelBannerInfoMaxLength = 1024
ChannelSortByUsername = "username"
ChannelSortByStatus = "status"
)
type ChannelBannerInfo struct {
Enabled *bool `json:"enabled"`
Text *string `json:"text"`
BackgroundColor *string `json:"background_color"`
}
func (c *ChannelBannerInfo) Scan(value any) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return fmt.Errorf("expected []byte, got %T", value)
}
return json.Unmarshal(b, c)
}
func (c ChannelBannerInfo) Value() (driver.Value, error) {
if c == (ChannelBannerInfo{}) {
return nil, nil
}
j, err := json.Marshal(c)
if err != nil {
return nil, err
}
return string(j), nil
}
type Channel struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
TeamId string `json:"team_id"`
Type ChannelType `json:"type"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Header string `json:"header"`
Purpose string `json:"purpose"`
LastPostAt int64 `json:"last_post_at"`
TotalMsgCount int64 `json:"total_msg_count"`
ExtraUpdateAt int64 `json:"extra_update_at"`
CreatorId string `json:"creator_id"`
SchemeId *string `json:"scheme_id"`
Props map[string]any `json:"props"`
GroupConstrained *bool `json:"group_constrained"`
AutoTranslation bool `json:"autotranslation"`
Shared *bool `json:"shared"`
TotalMsgCountRoot int64 `json:"total_msg_count_root"`
PolicyID *string `json:"policy_id"`
LastRootPostAt int64 `json:"last_root_post_at"`
BannerInfo *ChannelBannerInfo `json:"banner_info"`
PolicyEnforced bool `json:"policy_enforced"`
// PolicyActions maps each action key declared by the channel's access
// control policy (and any imported parent policies) to true. It is
// populated lazily by App-layer hydrators and is therefore unset on
// channel reads that don't pass through one of those seams. Consumers
// that care about a specific action (e.g. "membership") should check
// PolicyActions[action] and fall back to PolicyEnforced only when the
// stronger meaning is acceptable. Empty/nil means either no policy or
// no hydration was performed.
PolicyActions map[string]bool `json:"policy_actions,omitempty"`
PolicyIsActive bool `json:"policy_is_active"`
DefaultCategoryName string `json:"default_category_name"`
ManagedCategoryName string `json:"managed_category_name"`
Discoverable bool `json:"discoverable"`
DisableJoinLeaveMessages bool `json:"disable_join_leave_messages"`
}
// HasPolicyAction reports whether the channel's policy declares the given
// action. Safe to call on a Channel whose PolicyActions map is nil
// (returns false in that case). Use this in preference to direct map
// indexing so consumers don't have to defend against nil maps.
func (o *Channel) HasPolicyAction(action string) bool {
if o == nil || len(o.PolicyActions) == 0 {
return false
}
return o.PolicyActions[action]
}
// HasMembershipPolicyAction is a convenience for the most common consumer
// pattern: "is this channel's membership controlled by ABAC?". Used by
// the invite picker, channel settings, members RHS, and the server-side
// gates (setChannelMembers, guest-invite, ChannelAccessControlled).
func (o *Channel) HasMembershipPolicyAction() bool {
return o.HasPolicyAction(AccessControlPolicyActionMembership)
}
func (o *Channel) Auditable() map[string]any {
return map[string]any{
"create_at": o.CreateAt,
"creator_id": o.CreatorId,
"delete_at": o.DeleteAt,
"extra_group_at": o.ExtraUpdateAt,
"group_constrained": o.GroupConstrained,
"id": o.Id,
"last_post_at": o.LastPostAt,
"last_root_post_at": o.LastRootPostAt,
"policy_id": o.PolicyID,
"props": o.Props,
"scheme_id": o.SchemeId,
"shared": o.Shared,
"team_id": o.TeamId,
"total_msg_count_root": o.TotalMsgCountRoot,
"type": o.Type,
"update_at": o.UpdateAt,
"policy_enforced": o.PolicyEnforced,
"policy_actions": o.PolicyActions, // hydrated lazily; only populated on selected read paths
"autotranslation": o.AutoTranslation,
"policy_is_active": o.PolicyIsActive, // this field is only for logging purposes
"discoverable": o.Discoverable,
"disable_join_leave_messages": o.DisableJoinLeaveMessages,
}
}
func (o *Channel) LogClone() any {
return o.Auditable()
}
type ChannelWithTeamData struct {
Channel
TeamDisplayName string `json:"team_display_name"`
TeamName string `json:"team_name"`
TeamUpdateAt int64 `json:"team_update_at"`
}
type ChannelsWithCount struct {
Channels ChannelListWithTeamData `json:"channels"`
TotalCount int64 `json:"total_count"`
}
// ChannelCreateRequest is the POST /channels body. The values ride the create
// request rather than a second call so the server can refuse a channel that
// would not satisfy its own required attributes; they are not part of Channel
// itself because Channel maps to columns.
type ChannelCreateRequest struct {
Channel
PropertyValues []PropertyValuePatchItem `json:"property_values"`
}
type ChannelPatch struct {
DisplayName *string `json:"display_name"`
Name *string `json:"name"`
Header *string `json:"header"`
Purpose *string `json:"purpose"`
GroupConstrained *bool `json:"group_constrained"`
BannerInfo *ChannelBannerInfo `json:"banner_info"`
AutoTranslation *bool `json:"autotranslation"`
ManagedCategoryName *string `json:"managed_category_name"`
DefaultCategoryName *string `json:"default_category_name"`
Discoverable *bool `json:"discoverable"`
DisableJoinLeaveMessages *bool `json:"disable_join_leave_messages"`
}
func (c *ChannelPatch) Auditable() map[string]any {
return map[string]any{
"header": c.Header,
"group_constrained": c.GroupConstrained,
"purpose": c.Purpose,
"default_category_name": c.DefaultCategoryName,
"managed_category_name": c.ManagedCategoryName,
"discoverable": c.Discoverable,
"disable_join_leave_messages": c.DisableJoinLeaveMessages,
}
}
type ChannelForExport struct {
Channel
TeamName string
SchemeName *string
}
type DirectChannelForExport struct {
Channel
Members []*ChannelMemberForExport
}
type ChannelModeration struct {
Name string `json:"name"`
Roles *ChannelModeratedRoles `json:"roles"`
}
type ChannelModeratedRoles struct {
Guests *ChannelModeratedRole `json:"guests"`
Members *ChannelModeratedRole `json:"members"`
}
type ChannelModeratedRole struct {
Value bool `json:"value"`
Enabled bool `json:"enabled"`
}
type ChannelModerationPatch struct {
Name *string `json:"name"`
Roles *ChannelModeratedRolesPatch `json:"roles"`
}
func (c *ChannelModerationPatch) Auditable() map[string]any {
return map[string]any{
"name": c.Name,
"roles": c.Roles,
}
}
type ChannelModeratedRolesPatch struct {
Guests *bool `json:"guests"`
Members *bool `json:"members"`
}
// ChannelSearchOpts contains options for searching channels.
//
// NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records.
// ExcludeDefaultChannels will exclude the configured default channels (ex 'town-square' and 'off-topic').
// IncludeDeleted will include channel records where DeleteAt != 0.
// ExcludeChannelNames will exclude channels from the results by name.
// IncludeSearchById will include searching matches against channel IDs in the results
// Paginate whether to paginate the results.
// Page page requested, if results are paginated.
// PerPage number of results per page, if paginated.
// ExcludeAccessPolicyEnforced will exclude channels that are enforced by an access policy.
type ChannelSearchOpts struct {
NotAssociatedToGroup string
ExcludeDefaultChannels bool
IncludeDeleted bool // If true, deleted channels will be included in the results.
Deleted bool
ExcludeChannelNames []string
TeamIds []string
GroupConstrained bool
ExcludeGroupConstrained bool
PolicyID string
ExcludePolicyConstrained bool
IncludePolicyID bool
IncludeSearchById bool
ExcludeRemote bool
Public bool
Private bool
Page *int
PerPage *int
LastDeleteAt int // When combined with IncludeDeleted, only channels deleted after this time will be returned.
LastUpdateAt int
AccessControlPolicyEnforced bool
ExcludeAccessControlPolicyEnforced bool
ParentAccessControlPolicyId string
}
type ChannelMemberCountByGroup struct {
GroupId string `json:"group_id"`
ChannelMemberCount int64 `json:"channel_member_count"`
ChannelMemberTimezonesCount int64 `json:"channel_member_timezones_count"`
}
type ChannelOption func(channel *Channel)
var gmNameRegex = regexp.MustCompile("^[a-f0-9]{40}$")
func WithID(ID string) ChannelOption {
return func(channel *Channel) {
channel.Id = ID
}
}
func (o *Channel) DeepCopy() *Channel {
cCopy := *o
if cCopy.SchemeId != nil {
cCopy.SchemeId = new(*o.SchemeId)
}
return &cCopy
}
func (o *Channel) IsValid() *AppError {
if !IsValidId(o.Id) {
return NewAppError("Channel.IsValid", "model.channel.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if o.CreateAt == 0 {
return NewAppError("Channel.IsValid", "model.channel.is_valid.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.UpdateAt == 0 {
return NewAppError("Channel.IsValid", "model.channel.is_valid.update_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if utf8.RuneCountInString(o.DisplayName) > ChannelDisplayNameMaxRunes {
return NewAppError("Channel.IsValid", "model.channel.is_valid.display_name.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if !IsValidChannelIdentifier(o.Name) {
return NewAppError("Channel.IsValid", "model.channel.is_valid.1_or_more.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if !(o.Type == ChannelTypeOpen || o.Type == ChannelTypePrivate || o.Type == ChannelTypeDirect || o.Type == ChannelTypeGroup || o.Type == ChannelTypeSpace || o.Type == ChannelTypeOpenBoard || o.Type == ChannelTypePrivateBoard) {
return NewAppError("Channel.IsValid", "model.channel.is_valid.type.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if utf8.RuneCountInString(o.Header) > ChannelHeaderMaxRunes {
return NewAppError("Channel.IsValid", "model.channel.is_valid.header.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if utf8.RuneCountInString(o.Purpose) > ChannelPurposeMaxRunes {
return NewAppError("Channel.IsValid", "model.channel.is_valid.purpose.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if len(o.CreatorId) > 26 {
return NewAppError("Channel.IsValid", "model.channel.is_valid.creator_id.app_error", nil, "", http.StatusBadRequest)
}
if o.Type != ChannelTypeDirect && o.Type != ChannelTypeGroup {
userIds := strings.Split(o.Name, "__")
if ok := gmNameRegex.MatchString(o.Name); ok || (o.Type != ChannelTypeDirect && len(userIds) == 2 && IsValidId(userIds[0]) && IsValidId(userIds[1])) {
return NewAppError("Channel.IsValid", "model.channel.is_valid.name.app_error", nil, "", http.StatusBadRequest)
}
}
if o.BannerInfo != nil && o.BannerInfo.Enabled != nil && *o.BannerInfo.Enabled {
if o.Type != ChannelTypeOpen && o.Type != ChannelTypePrivate {
return NewAppError("Channel.IsValid", "model.channel.is_valid.banner_info.channel_type.app_error", nil, "", http.StatusBadRequest)
}
if o.BannerInfo.Text == nil || len(*o.BannerInfo.Text) == 0 {
return NewAppError("Channel.IsValid", "model.channel.is_valid.banner_info.text.empty.app_error", nil, "", http.StatusBadRequest)
} else if len(*o.BannerInfo.Text) > ChannelBannerInfoMaxLength {
return NewAppError("Channel.IsValid", "model.channel.is_valid.banner_info.text.invalid_length.app_error", map[string]any{"maxLength": ChannelBannerInfoMaxLength}, "", http.StatusBadRequest)
}
if o.BannerInfo.BackgroundColor == nil || len(*o.BannerInfo.BackgroundColor) == 0 {
return NewAppError("Channel.IsValid", "model.channel.is_valid.banner_info.background_color.empty.app_error", nil, "", http.StatusBadRequest)
}
if !channelHexColorRegex.MatchString(*o.BannerInfo.BackgroundColor) {
return NewAppError("Channel.IsValid", "model.channel.is_valid.banner_info.background_color.invalid.app_error", nil, "", http.StatusBadRequest)
}
}
if o.Discoverable && o.Type != ChannelTypePrivate {
return NewAppError("Channel.IsValid", "model.channel.is_valid.discoverable.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.IsGroupConstrained() && !o.SupportsGroupSync() {
return NewAppError("Channel.IsValid", "model.channel.is_valid.group_constrained.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
return nil
}
// IsValidBoard performs the input-validation checks specific to board channels:
// the channel type must be BO/BP, a TeamId must be set, and DisplayName must be
// non-empty. Callers are expected to TrimSpace DisplayName before calling.
func (o *Channel) IsValidBoard() *AppError {
if !o.IsBoard() {
return NewAppError("Channel.IsValidBoard", "model.channel.is_valid_board.type.app_error", nil, "", http.StatusBadRequest)
}
if o.TeamId == "" {
return NewAppError("Channel.IsValidBoard", "model.channel.is_valid_board.team_id.app_error", nil, "", http.StatusBadRequest)
}
if o.DisplayName == "" {
return NewAppError("Channel.IsValidBoard", "model.channel.is_valid_board.display_name.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
func (o *Channel) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
o.Name = SanitizeUnicode(o.Name)
o.DisplayName = SanitizeUnicode(o.DisplayName)
if o.CreateAt == 0 {
o.CreateAt = GetMillis()
}
o.UpdateAt = o.CreateAt
o.ExtraUpdateAt = 0
}
func (o *Channel) PreUpdate() {
o.UpdateAt = GetMillis()
o.Name = SanitizeUnicode(o.Name)
o.DisplayName = SanitizeUnicode(o.DisplayName)
}
func (o *Channel) IsGroupOrDirect() bool {
return o.Type == ChannelTypeDirect || o.Type == ChannelTypeGroup
}
// SupportsGroupSync reports whether group_constrained is meaningful for the channel type.
func (o *Channel) SupportsGroupSync() bool {
return o.Type == ChannelTypeOpen || o.Type == ChannelTypePrivate
}
func (o *Channel) IsOpen() bool {
return o.Type == ChannelTypeOpen
}
func (o *Channel) IsBoard() bool {
return o.Type == ChannelTypeOpenBoard || o.Type == ChannelTypePrivateBoard
}
func (o *Channel) IsSpace() bool {
return o.Type == ChannelTypeSpace
}
// IsMessageChannel reports whether the channel is one of the message-bearing
// types (open, private, direct, or group). Returns false for boards and any
// future non-message channel types.
func (o *Channel) IsMessageChannel() bool {
switch o.Type {
case ChannelTypeOpen, ChannelTypePrivate, ChannelTypeDirect, ChannelTypeGroup:
return true
}
return false
}
func (o *Channel) IsOpenBoard() bool {
return o.Type == ChannelTypeOpenBoard
}
func (o *Channel) IsPrivateBoard() bool {
return o.Type == ChannelTypePrivateBoard
}
func (o *Channel) Patch(patch *ChannelPatch) {
if patch.DisplayName != nil {
o.DisplayName = strings.TrimSpace(*patch.DisplayName)
}
if patch.Name != nil {
o.Name = *patch.Name
}
if patch.Header != nil {
o.Header = *patch.Header
}
if patch.Purpose != nil {
o.Purpose = *patch.Purpose
}
if patch.GroupConstrained != nil {
o.GroupConstrained = patch.GroupConstrained
}
// patching channel banner info
if patch.BannerInfo != nil {
if o.BannerInfo == nil {
o.BannerInfo = &ChannelBannerInfo{}
}
if patch.BannerInfo.Enabled != nil {
o.BannerInfo.Enabled = patch.BannerInfo.Enabled
}
if patch.BannerInfo.Text != nil {
o.BannerInfo.Text = patch.BannerInfo.Text
}
if patch.BannerInfo.BackgroundColor != nil {
o.BannerInfo.BackgroundColor = patch.BannerInfo.BackgroundColor
}
}
if patch.AutoTranslation != nil {
o.AutoTranslation = *patch.AutoTranslation
}
if patch.DefaultCategoryName != nil {
o.DefaultCategoryName = strings.TrimSpace(*patch.DefaultCategoryName)
}
if patch.Discoverable != nil {
o.Discoverable = *patch.Discoverable
}
if patch.DisableJoinLeaveMessages != nil {
o.DisableJoinLeaveMessages = *patch.DisableJoinLeaveMessages
}
}
// ShouldChannelExcludeMembershipSystemPosts reports whether membership system posts should be
// hidden on end-user read paths for channel.
func ShouldChannelExcludeMembershipSystemPosts(channel *Channel) bool {
if channel == nil || channel.IsGroupOrDirect() {
return false
}
return channel.DisableJoinLeaveMessages
}
func (o *Channel) MakeNonNil() {
if o.Props == nil {
o.Props = make(map[string]any)
}
}
func (o *Channel) AddProp(key string, value any) {
o.MakeNonNil()
o.Props[key] = value
}
func (o *Channel) IsGroupConstrained() bool {
return o.GroupConstrained != nil && *o.GroupConstrained
}
func (o *Channel) IsShared() bool {
return o.Shared != nil && *o.Shared
}
func (o *Channel) GetOtherUserIdForDM(userId string) string {
user1, user2 := o.GetBothUsersForDM()
if user2 == "" {
return ""
}
if user1 == userId {
return user2
}
return user1
}
func (o *Channel) GetBothUsersForDM() (string, string) {
if o.Type != ChannelTypeDirect {
return "", ""
}
userIds := strings.Split(o.Name, "__")
if len(userIds) != 2 {
return "", ""
}
if userIds[0] == userIds[1] {
return userIds[0], ""
}
return userIds[0], userIds[1]
}
func (o *Channel) Sanitize() Channel {
return Channel{
Id: o.Id,
TeamId: o.TeamId,
Type: o.Type,
DisplayName: o.DisplayName,
}
}
func (t ChannelType) MarshalJSON() ([]byte, error) {
return json.Marshal(string(t))
}
func GetDMNameFromIds(userId1, userId2 string) string {
if userId1 > userId2 {
return userId2 + "__" + userId1
}
return userId1 + "__" + userId2
}
func GetGroupDisplayNameFromUsers(users []*User, truncate bool) string {
usernames := make([]string, len(users))
for index, user := range users {
usernames[index] = user.Username
}
slices.Sort(usernames)
name := strings.Join(usernames, ", ")
if truncate && len(name) > ChannelNameMaxLength {
name = name[:ChannelNameMaxLength]
}
return name
}
func GetGroupNameFromUserIds(userIds []string) string {
slices.Sort(userIds)
h := sha1.New()
for _, id := range userIds {
io.WriteString(h, id)
}
return hex.EncodeToString(h.Sum(nil))
}
type GroupMessageConversionRequestBody struct {
ChannelID string `json:"channel_id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
}
// ChannelMembersGetOptions provides parameters for getting channel members
type ChannelMembersGetOptions struct {
// ChannelID specifies which channel to get members for
ChannelID string
// Offset for pagination
Offset int
// Limit for pagination (maximum number of results to return)
Limit int
// UpdatedAfter filters members updated after the given timestamp (cursor-based pagination)
UpdatedAfter int64
}

View File

@@ -0,0 +1,389 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"slices"
"strings"
"unicode/utf8"
)
type ChannelBookmarkType string
const (
ChannelBookmarkLink ChannelBookmarkType = "link"
ChannelBookmarkFile ChannelBookmarkType = "file"
ChannelBookmarkBoard ChannelBookmarkType = "board"
BookmarkFileOwner = "bookmark"
MaxBookmarksPerChannel = 50
DisplayNameMaxRunes = 64
LinkMaxRunes = 1024
)
var validChannelBookmarkTypes = []ChannelBookmarkType{
ChannelBookmarkLink,
ChannelBookmarkFile,
ChannelBookmarkBoard,
}
// channelBookmarkTypesWithoutTargetID are bookmark kinds that must leave TargetId empty.
var channelBookmarkTypesWithoutTargetID = []ChannelBookmarkType{
ChannelBookmarkLink,
ChannelBookmarkFile,
}
func isValidChannelBookmarkType(t ChannelBookmarkType) bool {
return slices.Contains(validChannelBookmarkTypes, t)
}
// channelBookmarkTypesExternallyManaged lists bookmark kinds whose create/update/delete
// are not performed through the channel bookmarks API (other subsystems own them).
var channelBookmarkTypesExternallyManaged = []ChannelBookmarkType{
ChannelBookmarkBoard,
}
// IsExternallyManagedChannelBookmarkType reports whether the bookmark type is owned outside the channel bookmarks API.
func IsExternallyManagedChannelBookmarkType(t ChannelBookmarkType) bool {
return slices.Contains(channelBookmarkTypesExternallyManaged, t)
}
type ChannelBookmark struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
ChannelId string `json:"channel_id"`
OwnerId string `json:"owner_id"`
FileId string `json:"file_id"`
DisplayName string `json:"display_name"`
SortOrder int64 `json:"sort_order"`
LinkUrl string `json:"link_url,omitempty"`
ImageUrl string `json:"image_url,omitempty"`
Emoji string `json:"emoji,omitempty"`
Type ChannelBookmarkType `json:"type"`
TargetId string `json:"target_id,omitempty"`
OriginalId string `json:"original_id,omitempty"`
ParentId string `json:"parent_id,omitempty"`
}
func (o *ChannelBookmark) Auditable() map[string]any {
return map[string]any{
"id": o.Id,
"create_at": o.CreateAt,
"update_at": o.UpdateAt,
"delete_at": o.DeleteAt,
"channel_id": o.ChannelId,
"owner_id": o.OwnerId,
"file_id": o.FileId,
"type": o.Type,
"target_id": o.TargetId,
"original_id": o.OriginalId,
"parent_id": o.ParentId,
}
}
// Clone returns a shallow copy of the channel bookmark.
func (o *ChannelBookmark) Clone() *ChannelBookmark {
bCopy := *o
return &bCopy
}
// SetOriginal generates a new bookmark copying the data of the
// receiver bookmark, resets its timestamps and main ID, updates its
// OriginalId and sets the owner to the ID passed as a parameter
func (o *ChannelBookmark) SetOriginal(newOwnerId string) *ChannelBookmark {
bCopy := *o
bCopy.Id = ""
bCopy.CreateAt = 0
bCopy.DeleteAt = 0
bCopy.UpdateAt = 0
bCopy.OriginalId = o.Id
bCopy.OwnerId = newOwnerId
return &bCopy
}
func (o *ChannelBookmark) IsValid() *AppError {
if !IsValidId(o.Id) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if o.CreateAt == 0 {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.UpdateAt == 0 {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.update_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if !IsValidId(o.ChannelId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.OwnerId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.owner_id.app_error", nil, "", http.StatusBadRequest)
}
if o.DisplayName == "" || utf8.RuneCountInString(o.DisplayName) > DisplayNameMaxRunes {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.display_name.app_error", nil, "", http.StatusBadRequest)
}
if !isValidChannelBookmarkType(o.Type) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.type.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if slices.Contains(channelBookmarkTypesWithoutTargetID, o.Type) && o.TargetId != "" {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.target_id.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.Type == ChannelBookmarkBoard {
if o.TargetId == "" || !IsValidId(o.TargetId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.board.target_id.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.LinkUrl == "" || !strings.HasPrefix(o.LinkUrl, "/") || strings.HasPrefix(o.LinkUrl, "//") || strings.Contains(o.LinkUrl, "://") || utf8.RuneCountInString(o.LinkUrl) > LinkMaxRunes {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.board.link_url.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.FileId != "" {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.board.file_id.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.ImageUrl != "" {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.board.image_url.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
}
if o.Type == ChannelBookmarkLink && o.FileId != "" {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.file_id.missing_or_invalid.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.Type == ChannelBookmarkFile && o.LinkUrl != "" {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.link_url.missing_or_invalid.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.Type == ChannelBookmarkLink && (o.LinkUrl == "" || !IsValidHTTPURL(o.LinkUrl) || utf8.RuneCountInString(o.LinkUrl) > LinkMaxRunes) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.link_url.missing_or_invalid.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.Type == ChannelBookmarkLink && o.ImageUrl != "" && (!IsValidHTTPURL(o.ImageUrl) || utf8.RuneCountInString(o.ImageUrl) > LinkMaxRunes) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.image_url.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.Type == ChannelBookmarkFile && (o.FileId == "" || !IsValidId(o.FileId)) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.file_id.missing_or_invalid.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.ImageUrl != "" && o.FileId != "" {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.link_file.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.OriginalId != "" && !IsValidId(o.OriginalId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.original_id.app_error", nil, "", http.StatusBadRequest)
}
if o.ParentId != "" && !IsValidId(o.ParentId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.parent_id.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
func (o *ChannelBookmark) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
o.DisplayName = SanitizeUnicode(o.DisplayName)
o.Emoji = strings.Trim(o.Emoji, ":")
if o.CreateAt == 0 {
o.CreateAt = GetMillis()
}
o.UpdateAt = o.CreateAt
}
func (o *ChannelBookmark) PreUpdate() {
o.UpdateAt = GetMillis()
o.DisplayName = SanitizeUnicode(o.DisplayName)
o.Emoji = strings.Trim(o.Emoji, ":")
}
func (o *ChannelBookmark) ToBookmarkWithFileInfo(f *FileInfo) *ChannelBookmarkWithFileInfo {
bwf := ChannelBookmarkWithFileInfo{
ChannelBookmark: &ChannelBookmark{
Id: o.Id,
CreateAt: o.CreateAt,
UpdateAt: o.UpdateAt,
DeleteAt: o.DeleteAt,
ChannelId: o.ChannelId,
OwnerId: o.OwnerId,
FileId: o.FileId,
DisplayName: o.DisplayName,
SortOrder: o.SortOrder,
LinkUrl: o.LinkUrl,
ImageUrl: o.ImageUrl,
Emoji: strings.Trim(o.Emoji, ":"),
Type: o.Type,
TargetId: o.TargetId,
OriginalId: o.OriginalId,
ParentId: o.ParentId,
},
}
if f != nil && f.Id != "" {
bwf.FileInfo = f
}
return &bwf
}
type ChannelBookmarkPatch struct {
FileId *string `json:"file_id"`
DisplayName *string `json:"display_name"`
SortOrder *int64 `json:"sort_order"`
LinkUrl *string `json:"link_url,omitempty"`
ImageUrl *string `json:"image_url,omitempty"`
Emoji *string `json:"emoji,omitempty"`
}
func (o *ChannelBookmarkPatch) Auditable() map[string]any {
return map[string]any{
"file_id": o.FileId,
}
}
func (o *ChannelBookmark) Patch(patch *ChannelBookmarkPatch) {
if patch.FileId != nil {
o.FileId = *patch.FileId
}
if patch.DisplayName != nil {
o.DisplayName = *patch.DisplayName
}
if patch.SortOrder != nil {
o.SortOrder = *patch.SortOrder
}
if patch.LinkUrl != nil {
o.LinkUrl = *patch.LinkUrl
}
if patch.ImageUrl != nil {
o.ImageUrl = *patch.ImageUrl
}
if patch.Emoji != nil {
o.Emoji = *patch.Emoji
}
}
type ChannelBookmarkWithFileInfo struct {
*ChannelBookmark
FileInfo *FileInfo `json:"file,omitempty"`
}
func (o *ChannelBookmarkWithFileInfo) Auditable() map[string]any {
a := o.ChannelBookmark.Auditable()
if o.FileInfo != nil {
a["file"] = o.FileInfo.Auditable()
}
return a
}
// Clone returns a shallow copy of the channel bookmark with file info.
func (o *ChannelBookmarkWithFileInfo) Clone() *ChannelBookmarkWithFileInfo {
bCopy := *o
return &bCopy
}
type ChannelWithBookmarks struct {
*Channel
Bookmarks []*ChannelBookmarkWithFileInfo `json:"bookmarks,omitempty"`
}
type ChannelWithTeamDataAndBookmarks struct {
*ChannelWithTeamData
Bookmarks []*ChannelBookmarkWithFileInfo `json:"bookmarks,omitempty"`
}
type UpdateChannelBookmarkResponse struct {
Updated *ChannelBookmarkWithFileInfo `json:"updated,omitempty"`
Deleted *ChannelBookmarkWithFileInfo `json:"deleted,omitempty"`
}
func (o *UpdateChannelBookmarkResponse) Auditable() map[string]any {
a := map[string]any{}
if o.Updated != nil {
a["updated"] = o.Updated.Auditable()
}
if o.Deleted != nil {
a["updated"] = o.Deleted.Auditable()
}
return a
}
type ChannelBookmarkAndFileInfo struct {
Id string
CreateAt int64
UpdateAt int64
DeleteAt int64
ChannelId string
OwnerId string
FileInfoId string
DisplayName string
SortOrder int64
LinkUrl string
ImageUrl string
Emoji string
Type ChannelBookmarkType
TargetId string
OriginalId string
ParentId string
FileId string
FileName string
Extension string
Size int64
MimeType string
Width int
Height int
HasPreviewImage bool
MiniPreview *[]byte
}
func (o *ChannelBookmarkAndFileInfo) ToChannelBookmarkWithFileInfo() *ChannelBookmarkWithFileInfo {
bwf := &ChannelBookmarkWithFileInfo{
ChannelBookmark: &ChannelBookmark{
Id: o.Id,
CreateAt: o.CreateAt,
UpdateAt: o.UpdateAt,
DeleteAt: o.DeleteAt,
ChannelId: o.ChannelId,
OwnerId: o.OwnerId,
FileId: o.FileInfoId,
DisplayName: o.DisplayName,
SortOrder: o.SortOrder,
LinkUrl: o.LinkUrl,
ImageUrl: o.ImageUrl,
Emoji: o.Emoji,
Type: o.Type,
TargetId: o.TargetId,
OriginalId: o.OriginalId,
ParentId: o.ParentId,
},
}
if o.FileInfoId != "" && o.FileId != "" {
miniPreview := o.MiniPreview
if len(*miniPreview) == 0 {
miniPreview = nil
}
bwf.FileInfo = &FileInfo{
Id: o.FileId,
Name: o.FileName,
Extension: o.Extension,
Size: o.Size,
MimeType: o.MimeType,
Width: o.Width,
Height: o.Height,
HasPreviewImage: o.HasPreviewImage,
MiniPreview: miniPreview,
}
}
return bwf
}

View File

@@ -0,0 +1,793 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelBookmarkIsValid(t *testing.T) {
testCases := []struct {
Description string
Bookmark *ChannelBookmark
ExpectedIsValid bool
}{
{
"nil bookmark",
&ChannelBookmark{},
false,
},
{
"bookmark without create at timestamp",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 0,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark without update at timestamp",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 0,
DeleteAt: 4,
},
false,
},
{
"bookmark with missing channel id",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with invalid channel id",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "invalid",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with missing owner id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: "",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with invalid user id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: "invalid",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with missing displayname",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with missing type",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: "",
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with invalid type",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: "invalid",
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type link with missing link url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type link with invalid link url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "invalid",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type link with valid link url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
{
"bookmark of type link with empty image url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
{
"bookmark of type link with invalid image url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
ImageUrl: "invalid",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type link with valid image url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
ImageUrl: "https://mattermost.com/some-image-without-extension", // we don't care if the URL is an actual image as the client should handle the error
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
{
"bookmark of type file with missing file id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type file with invalid file id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "invalid",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type file with valid file id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
{
"bookmark of type file with invalid original id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
OriginalId: "invalid",
},
false,
},
{
"bookmark of type file with invalid parent id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
ParentId: "invalid",
},
false,
},
{
"bookmark of type link with a file Id attached",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: NewId(),
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "http://somelink",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 0,
},
false,
},
{
"bookmark of type file with a url",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: NewId(),
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "http://somelink",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 0,
},
false,
},
{
"bookmark with long display name > limit",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: NewId(),
FileId: "",
DisplayName: strings.Repeat("1", 65),
SortOrder: 0,
LinkUrl: "http://somelink",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 3,
UpdateAt: 3,
DeleteAt: 0,
},
false,
},
{
"bookmark with long display name < limit",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: NewId(),
FileId: "",
DisplayName: strings.Repeat("1", 64),
SortOrder: 0,
LinkUrl: "http://somelink",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 3,
UpdateAt: 3,
DeleteAt: 0,
},
true,
},
{
"bookmark with link url > limit",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: NewId(),
FileId: "",
DisplayName: "not last test",
SortOrder: 0,
LinkUrl: "http://somelink?" + strings.Repeat("h", 1024),
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 3,
UpdateAt: 3,
DeleteAt: 0,
},
false,
},
{
"bookmark with image url > limit",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: NewId(),
FileId: "",
DisplayName: "last test",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "http://somelink?" + strings.Repeat("h", 1024),
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 3,
UpdateAt: 3,
DeleteAt: 0,
},
false,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
if testCase.ExpectedIsValid {
require.Nil(t, testCase.Bookmark.IsValid())
} else {
require.NotNil(t, testCase.Bookmark.IsValid())
}
})
}
}
func TestChannelBookmarkPreSave(t *testing.T) {
bookmark := &ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
Type: ChannelBookmarkLink,
DeleteAt: 0,
}
originalBookmark := &ChannelBookmark{
Id: bookmark.Id,
ChannelId: bookmark.ChannelId,
OwnerId: bookmark.OwnerId,
DisplayName: bookmark.DisplayName,
SortOrder: bookmark.SortOrder,
LinkUrl: bookmark.LinkUrl,
Type: bookmark.Type,
DeleteAt: bookmark.DeleteAt,
}
bookmark.PreSave()
assert.NotEqual(t, 0, bookmark.CreateAt)
assert.NotEqual(t, 0, bookmark.UpdateAt)
originalBookmark.CreateAt = bookmark.CreateAt
originalBookmark.UpdateAt = bookmark.UpdateAt
assert.Equal(t, originalBookmark, bookmark)
}
func TestChannelBookmarkPreUpdate(t *testing.T) {
bookmark := &ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
Type: ChannelBookmarkLink,
CreateAt: 2,
DeleteAt: 0,
}
originalBookmark := &ChannelBookmark{
Id: bookmark.Id,
ChannelId: bookmark.ChannelId,
OwnerId: bookmark.OwnerId,
DisplayName: bookmark.DisplayName,
SortOrder: bookmark.SortOrder,
LinkUrl: bookmark.LinkUrl,
Type: bookmark.Type,
DeleteAt: bookmark.DeleteAt,
}
bookmark.PreSave()
assert.NotEqual(t, 0, bookmark.UpdateAt)
originalBookmark.CreateAt = bookmark.CreateAt
originalBookmark.UpdateAt = bookmark.UpdateAt
assert.Equal(t, originalBookmark, bookmark)
bookmark.PreUpdate()
assert.Greater(t, bookmark.UpdateAt, originalBookmark.UpdateAt)
}
func TestToBookmarkWithFileInfo(t *testing.T) {
testCases := []struct {
name string
bookmark *ChannelBookmark
fileInfo *FileInfo
expectedEmoji string
}{
{
name: "emoji with colons",
bookmark: &ChannelBookmark{
Id: NewId(),
DisplayName: "test bookmark",
Emoji: ":smile:",
Type: ChannelBookmarkLink,
},
fileInfo: nil,
expectedEmoji: "smile",
},
{
name: "emoji without colons",
bookmark: &ChannelBookmark{
Id: NewId(),
DisplayName: "test bookmark",
Emoji: "smile",
Type: ChannelBookmarkLink,
},
fileInfo: nil,
expectedEmoji: "smile",
},
{
name: "empty emoji",
bookmark: &ChannelBookmark{
Id: NewId(),
DisplayName: "test bookmark",
Emoji: "",
Type: ChannelBookmarkLink,
},
fileInfo: nil,
expectedEmoji: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.bookmark.ToBookmarkWithFileInfo(tc.fileInfo)
assert.Equal(t, tc.expectedEmoji, result.Emoji)
})
}
}
func TestChannelBookmarkPatch(t *testing.T) {
p := &ChannelBookmarkPatch{
DisplayName: new(NewId()),
SortOrder: new(int64(1)),
LinkUrl: new(NewId()),
}
b := ChannelBookmark{
Id: NewId(),
DisplayName: NewId(),
Type: ChannelBookmarkLink, // should not update
LinkUrl: NewId(),
}
b.Patch(p)
require.Empty(t, b.FileId)
require.Equal(t, *p.DisplayName, b.DisplayName)
require.Equal(t, *p.SortOrder, b.SortOrder)
require.Equal(t, *p.LinkUrl, b.LinkUrl)
require.Equal(t, ChannelBookmarkLink, b.Type)
}
func validBoardChannelBookmark() *ChannelBookmark {
boardID := NewId()
return &ChannelBookmark{
Id: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
DeleteAt: 0,
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "Board tab",
SortOrder: 0,
LinkUrl: "/teamname/boards/" + boardID,
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkBoard,
TargetId: boardID,
}
}
func TestChannelBookmarkIsValidBoard(t *testing.T) {
t.Run("valid board bookmark", func(t *testing.T) {
require.Nil(t, validBoardChannelBookmark().IsValid())
})
t.Run("missing target id", func(t *testing.T) {
b := validBoardChannelBookmark()
b.TargetId = ""
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.board.target_id.app_error", err.Id)
})
t.Run("invalid target id", func(t *testing.T) {
b := validBoardChannelBookmark()
b.TargetId = "notaulid"
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.board.target_id.app_error", err.Id)
})
t.Run("link url without leading slash", func(t *testing.T) {
b := validBoardChannelBookmark()
b.LinkUrl = "team/boards/" + b.TargetId
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.board.link_url.app_error", err.Id)
})
t.Run("link url contains scheme", func(t *testing.T) {
b := validBoardChannelBookmark()
b.LinkUrl = "http://x/" + b.TargetId
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.board.link_url.app_error", err.Id)
})
t.Run("link url is protocol-relative", func(t *testing.T) {
b := validBoardChannelBookmark()
b.LinkUrl = "//evil.com/" + b.TargetId
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.board.link_url.app_error", err.Id)
})
t.Run("link url exceeds max runes", func(t *testing.T) {
b := validBoardChannelBookmark()
b.LinkUrl = "/" + strings.Repeat("a", LinkMaxRunes)
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.board.link_url.app_error", err.Id)
})
t.Run("non-empty file id", func(t *testing.T) {
b := validBoardChannelBookmark()
b.FileId = NewId()
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.board.file_id.app_error", err.Id)
})
t.Run("non-empty image url", func(t *testing.T) {
b := validBoardChannelBookmark()
b.ImageUrl = "https://example.com/x.png"
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.board.image_url.app_error", err.Id)
})
t.Run("link type with target id", func(t *testing.T) {
b := &ChannelBookmark{
Id: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
ChannelId: NewId(),
OwnerId: NewId(),
DisplayName: "x",
LinkUrl: "https://mattermost.com",
Type: ChannelBookmarkLink,
TargetId: NewId(),
}
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.target_id.app_error", err.Id)
})
t.Run("file type with target id", func(t *testing.T) {
b := &ChannelBookmark{
Id: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
ChannelId: NewId(),
OwnerId: NewId(),
DisplayName: "x",
FileId: NewId(),
Type: ChannelBookmarkFile,
TargetId: NewId(),
}
err := b.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.channel_bookmark.is_valid.target_id.app_error", err.Id)
})
}
func TestIsExternallyManagedChannelBookmarkType(t *testing.T) {
assert.True(t, IsExternallyManagedChannelBookmarkType(ChannelBookmarkBoard))
assert.False(t, IsExternallyManagedChannelBookmarkType(ChannelBookmarkLink))
assert.False(t, IsExternallyManagedChannelBookmarkType(ChannelBookmarkFile))
assert.False(t, IsExternallyManagedChannelBookmarkType(ChannelBookmarkType("unknown")))
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ChannelData struct {
Channel *Channel `json:"channel"`
Member *ChannelMember `json:"member"`
}
func (o *ChannelData) Etag() string {
var mt int64
if o.Member != nil {
mt = o.Member.LastUpdateAt
}
return Etag(o.Channel.Id, o.Channel.UpdateAt, o.Channel.LastPostAt, mt)
}

View File

@@ -0,0 +1,165 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"unicode/utf8"
)
const (
ChannelJoinRequestStatusPending = "pending"
ChannelJoinRequestStatusApproved = "approved"
ChannelJoinRequestStatusDenied = "denied"
ChannelJoinRequestStatusWithdrawn = "withdrawn"
ChannelJoinRequestMessageMaxRunes = 500
ChannelJoinRequestDenialReasonMaxRunes = 500
)
// ChannelJoinRequest records a user's request to join a discoverable private channel.
//
// Rows are append-only / status-mutating: a request transitions through
// pending → approved | denied | withdrawn. Rows are never deleted so the full
// audit history is preserved. A partial unique index in Postgres enforces at
// most one active pending row per (ChannelId, UserId).
type ChannelJoinRequest struct {
Id string `json:"id"`
ChannelId string `json:"channel_id"`
UserId string `json:"user_id"`
Message string `json:"message"`
Status string `json:"status"`
DenialReason string `json:"denial_reason"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
ReviewedBy string `json:"reviewed_by"`
ReviewedAt int64 `json:"reviewed_at"`
}
// ChannelJoinRequestList is the paginated response shape returned by list endpoints.
type ChannelJoinRequestList struct {
Requests []*ChannelJoinRequest `json:"requests"`
TotalCount int64 `json:"total_count"`
}
// ChannelJoinRequestPatch represents the admin review action: approve or deny,
// with an optional denial reason that is surfaced to the requester.
type ChannelJoinRequestPatch struct {
Status string `json:"status"`
DenialReason *string `json:"denial_reason,omitempty"`
}
// GetChannelJoinRequestsOpts filters and paginates list queries on the store.
// An empty Status means "pending".
type GetChannelJoinRequestsOpts struct {
Status string
Page int
PerPage int
}
// IsValidChannelJoinRequestStatus reports whether the given status string is a
// recognized lifecycle value for a ChannelJoinRequest.
func IsValidChannelJoinRequestStatus(s string) bool {
switch s {
case ChannelJoinRequestStatusPending,
ChannelJoinRequestStatusApproved,
ChannelJoinRequestStatusDenied,
ChannelJoinRequestStatusWithdrawn:
return true
}
return false
}
func (r *ChannelJoinRequest) Auditable() map[string]any {
return map[string]any{
"id": r.Id,
"channel_id": r.ChannelId,
"user_id": r.UserId,
"status": r.Status,
"create_at": r.CreateAt,
"update_at": r.UpdateAt,
"reviewed_by": r.ReviewedBy,
"reviewed_at": r.ReviewedAt,
"has_message": r.Message != "",
"has_denial_reason": r.DenialReason != "",
}
}
func (r *ChannelJoinRequest) LogClone() any {
return r.Auditable()
}
func (r *ChannelJoinRequest) IsValid() *AppError {
if !IsValidId(r.Id) {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(r.ChannelId) {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.channel_id.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
if !IsValidId(r.UserId) {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.user_id.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
if r.CreateAt == 0 {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.create_at.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
if r.UpdateAt == 0 {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.update_at.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
if !IsValidChannelJoinRequestStatus(r.Status) {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.status.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
if utf8.RuneCountInString(r.Message) > ChannelJoinRequestMessageMaxRunes {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.message.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
if utf8.RuneCountInString(r.DenialReason) > ChannelJoinRequestDenialReasonMaxRunes {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.denial_reason.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
// A denial reason is only meaningful on a denied request.
if r.DenialReason != "" && r.Status != ChannelJoinRequestStatusDenied {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.denial_reason_status.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
if r.ReviewedBy != "" && !IsValidId(r.ReviewedBy) {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.reviewed_by.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
// Reviewer and reviewed-at must accompany a terminal review action.
switch r.Status {
case ChannelJoinRequestStatusApproved, ChannelJoinRequestStatusDenied:
if r.ReviewedBy == "" || r.ReviewedAt == 0 {
return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.reviewer.app_error", nil, "id="+r.Id, http.StatusBadRequest)
}
}
return nil
}
func (r *ChannelJoinRequest) PreSave() {
if r.Id == "" {
r.Id = NewId()
}
if r.Status == "" {
r.Status = ChannelJoinRequestStatusPending
}
if r.CreateAt == 0 {
r.CreateAt = GetMillis()
}
r.UpdateAt = r.CreateAt
r.Message = SanitizeUnicode(r.Message)
r.DenialReason = SanitizeUnicode(r.DenialReason)
}
func (r *ChannelJoinRequest) PreUpdate() {
r.UpdateAt = GetMillis()
r.Message = SanitizeUnicode(r.Message)
r.DenialReason = SanitizeUnicode(r.DenialReason)
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validRequest() *ChannelJoinRequest {
return &ChannelJoinRequest{
Id: NewId(),
ChannelId: NewId(),
UserId: NewId(),
Status: ChannelJoinRequestStatusPending,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
}
func TestChannelJoinRequestPreSaveDefaults(t *testing.T) {
r := &ChannelJoinRequest{
ChannelId: NewId(),
UserId: NewId(),
}
r.PreSave()
assert.NotEmpty(t, r.Id, "PreSave must assign an Id when missing")
assert.Equal(t, ChannelJoinRequestStatusPending, r.Status, "PreSave must default Status to pending")
assert.NotZero(t, r.CreateAt)
assert.Equal(t, r.CreateAt, r.UpdateAt, "PreSave must align UpdateAt with CreateAt")
}
func TestChannelJoinRequestPreUpdateAdvancesUpdateAt(t *testing.T) {
r := validRequest()
originalCreate := r.CreateAt
// Seed UpdateAt to a known-old value so we can prove PreUpdate actually
// advanced it (the validRequest factory sets UpdateAt = GetMillis(), so
// a no-op PreUpdate could otherwise still pass a GreaterOrEqual check).
r.UpdateAt = 1
r.PreUpdate()
assert.Greater(t, r.UpdateAt, int64(1))
assert.Equal(t, originalCreate, r.CreateAt, "PreUpdate must not mutate CreateAt")
}
func TestChannelJoinRequestIsValid(t *testing.T) {
t.Run("happy path pending", func(t *testing.T) {
require.Nil(t, validRequest().IsValid())
})
t.Run("invalid id", func(t *testing.T) {
r := validRequest()
r.Id = "not-an-id"
err := r.IsValid()
require.NotNil(t, err)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("rejects unknown status", func(t *testing.T) {
r := validRequest()
r.Status = "weird"
require.NotNil(t, r.IsValid())
})
t.Run("rejects message over rune limit", func(t *testing.T) {
r := validRequest()
r.Message = strings.Repeat("a", ChannelJoinRequestMessageMaxRunes+1)
require.NotNil(t, r.IsValid())
})
t.Run("rejects denial reason on non-denied request", func(t *testing.T) {
r := validRequest()
r.Status = ChannelJoinRequestStatusApproved
r.ReviewedBy = NewId()
r.ReviewedAt = GetMillis()
r.DenialReason = "nope"
require.NotNil(t, r.IsValid(), "denial reason must only be set on denied rows")
})
t.Run("requires reviewer info for terminal review", func(t *testing.T) {
r := validRequest()
r.Status = ChannelJoinRequestStatusApproved
require.NotNil(t, r.IsValid(), "approved without reviewer must be invalid")
r.ReviewedBy = NewId()
r.ReviewedAt = GetMillis()
require.Nil(t, r.IsValid())
})
t.Run("withdrawn does not require reviewer", func(t *testing.T) {
r := validRequest()
r.Status = ChannelJoinRequestStatusWithdrawn
require.Nil(t, r.IsValid(), "withdrawn is a self-service action, not a review")
})
}
func TestIsValidChannelJoinRequestStatus(t *testing.T) {
for _, s := range []string{
ChannelJoinRequestStatusPending,
ChannelJoinRequestStatusApproved,
ChannelJoinRequestStatusDenied,
ChannelJoinRequestStatusWithdrawn,
} {
assert.True(t, IsValidChannelJoinRequestStatus(s), "%q should be a valid status", s)
}
assert.False(t, IsValidChannelJoinRequestStatus(""))
assert.False(t, IsValidChannelJoinRequestStatus("approved "))
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ChannelList []*Channel
func (o *ChannelList) Etag() string {
id := "0"
var t int64
var delta int64
for _, v := range *o {
if v.LastPostAt > t {
t = v.LastPostAt
id = v.Id
}
if v.UpdateAt > t {
t = v.UpdateAt
id = v.Id
}
}
return Etag(id, t, delta, len(*o))
}
type ChannelListWithTeamData []*ChannelWithTeamData
func (o *ChannelListWithTeamData) Etag() string {
id := "0"
var t int64
var delta int64
for _, v := range *o {
if v.LastPostAt > t {
t = v.LastPostAt
id = v.Id
}
if v.UpdateAt > t {
t = v.UpdateAt
id = v.Id
}
if v.TeamUpdateAt > t {
t = v.TeamUpdateAt
id = v.Id
}
}
return Etag(id, t, delta, len(*o))
}

View File

@@ -0,0 +1,357 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"unicode/utf8"
)
const (
ChannelNotifyDefault = "default"
ChannelNotifyAll = "all"
ChannelNotifyMention = "mention"
ChannelNotifyNone = "none"
ChannelMarkUnreadAll = "all"
ChannelMarkUnreadMention = "mention"
IgnoreChannelMentionsDefault = "default"
IgnoreChannelMentionsOff = "off"
IgnoreChannelMentionsOn = "on"
IgnoreChannelMentionsNotifyProp = "ignore_channel_mentions"
ChannelAutoFollowThreadsOff = "off"
ChannelAutoFollowThreadsOn = "on"
ChannelAutoFollowThreads = "channel_auto_follow_threads"
ChannelMemberNotifyPropsMaxRunes = 800000
)
type ChannelUnread struct {
TeamId string `json:"team_id"`
ChannelId string `json:"channel_id"`
MsgCount int64 `json:"msg_count"`
MentionCount int64 `json:"mention_count"`
MentionCountRoot int64 `json:"mention_count_root"`
UrgentMentionCount int64 `json:"urgent_mention_count"`
MsgCountRoot int64 `json:"msg_count_root"`
NotifyProps StringMap `json:"-"`
}
type ChannelUnreadAt struct {
TeamId string `json:"team_id"`
UserId string `json:"user_id"`
ChannelId string `json:"channel_id"`
MsgCount int64 `json:"msg_count"`
MentionCount int64 `json:"mention_count"`
MentionCountRoot int64 `json:"mention_count_root"`
UrgentMentionCount int64 `json:"urgent_mention_count"`
MsgCountRoot int64 `json:"msg_count_root"`
LastViewedAt int64 `json:"last_viewed_at"`
NotifyProps StringMap `json:"-"`
}
type ChannelMember struct {
ChannelId string `json:"channel_id"`
UserId string `json:"user_id"`
Roles string `json:"roles"`
LastViewedAt int64 `json:"last_viewed_at"`
MsgCount int64 `json:"msg_count"`
MentionCount int64 `json:"mention_count"`
MentionCountRoot int64 `json:"mention_count_root"`
UrgentMentionCount int64 `json:"urgent_mention_count"`
MsgCountRoot int64 `json:"msg_count_root"`
NotifyProps StringMap `json:"notify_props"`
LastUpdateAt int64 `json:"last_update_at"`
SchemeGuest bool `json:"scheme_guest"`
SchemeUser bool `json:"scheme_user"`
SchemeAdmin bool `json:"scheme_admin"`
ExplicitRoles string `json:"explicit_roles"`
AutoTranslationDisabled bool `json:"autotranslation_disabled"`
}
func (o *ChannelMember) Auditable() map[string]any {
return map[string]any{
"channel_id": o.ChannelId,
"user_id": o.UserId,
"roles": o.Roles,
"last_viewed_at": o.LastViewedAt,
"msg_count": o.MsgCount,
"mention_count": o.MentionCount,
"mention_count_root": o.MentionCountRoot,
"urgent_mention_count": o.UrgentMentionCount,
"msg_count_root": o.MsgCountRoot,
"notify_props": o.NotifyProps,
"last_update_at": o.LastUpdateAt,
"scheme_guest": o.SchemeGuest,
"scheme_user": o.SchemeUser,
"scheme_admin": o.SchemeAdmin,
"explicit_roles": o.ExplicitRoles,
}
}
// sanitizedTimestamp marks a LastViewedAt/LastUpdateAt field that belongs to
// another user and must be hidden. MarshalJSON omits any field holding this
// sentinel rather than serializing an invalid value
const sanitizedTimestamp int64 = -1
// SanitizeForCurrentUser hides another user's private timestamp fields by
// marking them with the sanitized sentinel, which MarshalJSON then omits from
// API responses. The requester's own values are left untouched.
func (o *ChannelMember) SanitizeForCurrentUser(currentUserId string) {
if o.UserId != currentUserId {
o.LastViewedAt = sanitizedTimestamp
o.LastUpdateAt = sanitizedTimestamp
}
}
// timestampOrNil returns nil for the sanitized sentinel so that the omitempty
// tag drops the field, and a pointer to the real value otherwise (including a
// legitimate 0).
func timestampOrNil(ts int64) *int64 {
if ts == sanitizedTimestamp {
return nil
}
return &ts
}
// MarshalJSON serializes the channel member in a single pass, omitting
// last_viewed_at and/or last_update_at when they hold the sanitized sentinel
// written by SanitizeForCurrentUser. The shadowing pointer fields allow for a
// direct marshal with the sanitized values removed if needed.
func (o ChannelMember) MarshalJSON() ([]byte, error) {
type alias ChannelMember
return json.Marshal(&struct {
*alias
LastViewedAt *int64 `json:"last_viewed_at,omitempty"`
LastUpdateAt *int64 `json:"last_update_at,omitempty"`
}{
alias: (*alias)(&o),
LastViewedAt: timestampOrNil(o.LastViewedAt),
LastUpdateAt: timestampOrNil(o.LastUpdateAt),
})
}
// ChannelMemberWithTeamData contains ChannelMember appended with extra team information
// as well.
//
// Any new non-embedded field added here must also be added to MarshalJSON below,
// otherwise it will be silently dropped from the JSON output.
type ChannelMemberWithTeamData struct {
ChannelMember
TeamDisplayName string `json:"team_display_name"`
TeamName string `json:"team_name"`
TeamUpdateAt int64 `json:"team_update_at"`
}
// MarshalJSON flattens the embedded ChannelMember together with the team fields
// in a single pass. It is required because ChannelMember's MarshalJSON would
// otherwise be promoted and drop the team fields entirely.
func (o ChannelMemberWithTeamData) MarshalJSON() ([]byte, error) {
type alias ChannelMember
return json.Marshal(&struct {
*alias
LastViewedAt *int64 `json:"last_viewed_at,omitempty"`
LastUpdateAt *int64 `json:"last_update_at,omitempty"`
TeamDisplayName string `json:"team_display_name"`
TeamName string `json:"team_name"`
TeamUpdateAt int64 `json:"team_update_at"`
}{
alias: (*alias)(&o.ChannelMember),
LastViewedAt: timestampOrNil(o.LastViewedAt),
LastUpdateAt: timestampOrNil(o.LastUpdateAt),
TeamDisplayName: o.TeamDisplayName,
TeamName: o.TeamName,
TeamUpdateAt: o.TeamUpdateAt,
})
}
type ChannelMembers []ChannelMember
type ChannelMembersWithTeamData []ChannelMemberWithTeamData
// ChannelMemberForExport is only converted field-by-field for export and is
// never JSON-marshaled. If that changes, it must define its own MarshalJSON;
// otherwise ChannelMember's promoted MarshalJSON drops ChannelName and Username.
type ChannelMemberForExport struct {
ChannelMember
ChannelName string
Username string
}
type ChannelMemberCursor struct {
Page int // If page is -1, then FromChannelID is used as a cursor.
PerPage int
FromChannelID string
}
func (o *ChannelMember) IsValid() *AppError {
if !IsValidId(o.ChannelId) {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.UserId) {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.user_id.app_error", nil, "", http.StatusBadRequest)
}
if appErr := IsChannelMemberNotifyPropsValid(o.NotifyProps, false); appErr != nil {
return appErr
}
if len(o.Roles) > UserRolesMaxLength {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.roles_limit.app_error",
map[string]any{"Limit": UserRolesMaxLength}, "", http.StatusBadRequest)
}
return nil
}
func IsChannelMemberNotifyPropsValid(notifyProps map[string]string, allowMissingFields bool) *AppError {
if notifyLevel, ok := notifyProps[DesktopNotifyProp]; ok || !allowMissingFields {
if len(notifyLevel) > 20 || !IsChannelNotifyLevelValid(notifyLevel) {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.notify_level.app_error", nil, "notify_level="+notifyLevel, http.StatusBadRequest)
}
}
if markUnreadLevel, ok := notifyProps[MarkUnreadNotifyProp]; ok || !allowMissingFields {
if len(markUnreadLevel) > 20 || !IsChannelMarkUnreadLevelValid(markUnreadLevel) {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.unread_level.app_error", nil, "mark_unread_level="+markUnreadLevel, http.StatusBadRequest)
}
}
if pushLevel, ok := notifyProps[PushNotifyProp]; ok {
if len(pushLevel) > 20 || !IsChannelNotifyLevelValid(pushLevel) {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.push_level.app_error", nil, "push_notification_level="+pushLevel, http.StatusBadRequest)
}
}
if sendEmail, ok := notifyProps[EmailNotifyProp]; ok {
if len(sendEmail) > 20 || !IsSendEmailValid(sendEmail) {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.email_value.app_error", nil, "push_notification_level="+sendEmail, http.StatusBadRequest)
}
}
if ignoreChannelMentions, ok := notifyProps[IgnoreChannelMentionsNotifyProp]; ok {
if len(ignoreChannelMentions) > 40 || !IsIgnoreChannelMentionsValid(ignoreChannelMentions) {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.ignore_channel_mentions_value.app_error", nil, "ignore_channel_mentions="+ignoreChannelMentions, http.StatusBadRequest)
}
}
if channelAutoFollowThreads, ok := notifyProps[ChannelAutoFollowThreads]; ok {
if len(channelAutoFollowThreads) > 3 || !IsChannelAutoFollowThreadsValid(channelAutoFollowThreads) {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.channel_auto_follow_threads_value.app_error", nil, "channel_auto_follow_threads="+channelAutoFollowThreads, http.StatusBadRequest)
}
}
jsonStringNotifyProps := string(ToJSON(notifyProps))
if utf8.RuneCountInString(jsonStringNotifyProps) > ChannelMemberNotifyPropsMaxRunes {
return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.notify_props.app_error", nil, fmt.Sprint("length=", utf8.RuneCountInString(jsonStringNotifyProps)), http.StatusBadRequest)
}
return nil
}
func (o *ChannelMember) PreSave() {
o.LastUpdateAt = GetMillis()
}
func (o *ChannelMember) PreUpdate() {
o.LastUpdateAt = GetMillis()
}
func (o *ChannelMember) GetRoles() []string {
return strings.Fields(o.Roles)
}
func (o *ChannelMember) SetChannelMuted(muted bool) {
if o.IsChannelMuted() {
o.NotifyProps[MarkUnreadNotifyProp] = ChannelMarkUnreadAll
} else {
o.NotifyProps[MarkUnreadNotifyProp] = ChannelMarkUnreadMention
}
}
func (o *ChannelMember) IsChannelMuted() bool {
return o.NotifyProps[MarkUnreadNotifyProp] == ChannelMarkUnreadMention
}
func IsChannelNotifyLevelValid(notifyLevel string) bool {
return notifyLevel == ChannelNotifyDefault ||
notifyLevel == ChannelNotifyAll ||
notifyLevel == ChannelNotifyMention ||
notifyLevel == ChannelNotifyNone
}
func IsChannelMarkUnreadLevelValid(markUnreadLevel string) bool {
return markUnreadLevel == ChannelMarkUnreadAll || markUnreadLevel == ChannelMarkUnreadMention
}
func IsSendEmailValid(sendEmail string) bool {
return sendEmail == ChannelNotifyDefault || sendEmail == "true" || sendEmail == "false"
}
func IsIgnoreChannelMentionsValid(ignoreChannelMentions string) bool {
return ignoreChannelMentions == IgnoreChannelMentionsOn || ignoreChannelMentions == IgnoreChannelMentionsOff || ignoreChannelMentions == IgnoreChannelMentionsDefault
}
func IsChannelAutoFollowThreadsValid(channelAutoFollowThreads string) bool {
return channelAutoFollowThreads == ChannelAutoFollowThreadsOn || channelAutoFollowThreads == ChannelAutoFollowThreadsOff
}
func GetDefaultChannelNotifyProps() StringMap {
return StringMap{
DesktopNotifyProp: ChannelNotifyDefault,
MarkUnreadNotifyProp: ChannelMarkUnreadAll,
PushNotifyProp: ChannelNotifyDefault,
EmailNotifyProp: ChannelNotifyDefault,
IgnoreChannelMentionsNotifyProp: IgnoreChannelMentionsDefault,
ChannelAutoFollowThreads: ChannelAutoFollowThreadsOff,
}
}
type ChannelMemberIdentifier struct {
ChannelId string `json:"channel_id"`
UserId string `json:"user_id"`
}
// SetChannelMembersRequest is the request body for the bulk set channel members endpoint.
type SetChannelMembersRequest struct {
// Members is the complete desired membership list. Users in this list
// (and in ChannelAdmins) will be the final set of channel members.
Members []string `json:"members"`
// ChannelAdmins is an optional list of user IDs that should have the
// channel admin role. Users in this list are automatically included in
// the desired membership (they do not need to also appear in Members).
// When nil, existing admin roles are preserved for members who remain
// in the channel. When non-nil (including empty slice), admin roles
// are set declaratively: listed users become admins, all others lose
// the admin role.
ChannelAdmins *[]string `json:"channel_admins"`
}
// SetChannelMembersResponse is one batch of results from a bulk set channel members operation.
// Multiple responses may be streamed as NDJSON lines.
type SetChannelMembersResponse struct {
Added []string `json:"added"`
Removed []string `json:"removed"`
Promoted []string `json:"promoted,omitempty"`
Demoted []string `json:"demoted,omitempty"`
Errors []SetChannelMembersError `json:"errors,omitempty"`
}
func (o *SetChannelMembersResponse) Auditable() map[string]any {
return map[string]any{
"added": o.Added,
"removed": o.Removed,
"promoted": o.Promoted,
"demoted": o.Demoted,
"errors": o.Errors,
}
}
type SetChannelMembersError struct {
UserID string `json:"user_id"`
ID string `json:"id"`
Error string `json:"error"`
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ChannelMemberHistory struct {
ChannelId string
UserId string
JoinTime int64
LeaveTime *int64
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ChannelMemberHistoryResult struct {
ChannelId string
UserId string
JoinTime int64
LeaveTime *int64
// these two fields are never set in the database - when we SELECT, we join on Users to get them
UserEmail string `db:"Email"`
Username string
IsBot bool
UserDeleteAt int64
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const ChannelMemberLastViewedMaxPerPage = 1000
type ChannelMemberLastViewed struct {
UserId string
LastViewedAt int64
}

View File

@@ -0,0 +1,253 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelMemberIsValid(t *testing.T) {
o := ChannelMember{}
require.NotNil(t, o.IsValid(), "should be invalid")
o.ChannelId = NewId()
require.NotNil(t, o.IsValid(), "should be invalid")
o.UserId = NewId()
require.NotNil(t, o.IsValid(), "should be invalid because of missing notify props")
o.NotifyProps = GetDefaultChannelNotifyProps()
require.Nil(t, o.IsValid(), "should be valid")
o.NotifyProps["desktop"] = "junk"
require.NotNil(t, o.IsValid(), "should be invalid")
o.NotifyProps["desktop"] = "123456789012345678901"
require.NotNil(t, o.IsValid(), "should be invalid")
o.NotifyProps["desktop"] = ChannelNotifyAll
require.Nil(t, o.IsValid(), "should be valid")
o.NotifyProps["mark_unread"] = "123456789012345678901"
require.NotNil(t, o.IsValid(), "should be invalid")
o.NotifyProps["mark_unread"] = ChannelMarkUnreadAll
require.Nil(t, o.IsValid(), "should be valid")
o.Roles = ""
require.Nil(t, o.IsValid(), "should be invalid")
o.NotifyProps["property"] = strings.Repeat("Z", ChannelMemberNotifyPropsMaxRunes)
require.NotNil(t, o.IsValid(), "should be invalid")
}
func TestIsChannelMemberNotifyPropsValid(t *testing.T) {
t.Run("should require certain fields unless allowMissingFields is true", func(t *testing.T) {
notifyProps := map[string]string{}
err := IsChannelMemberNotifyPropsValid(notifyProps, false)
assert.NotNil(t, err)
err = IsChannelMemberNotifyPropsValid(notifyProps, true)
assert.Nil(t, err)
})
}
func TestChannelMemberSanitizeForCurrentUser(t *testing.T) {
currentUserId := NewId()
otherUserId := NewId()
channelId := NewId()
t.Run("should not sanitize current user's own membership", func(t *testing.T) {
member := &ChannelMember{
ChannelId: channelId,
UserId: currentUserId,
LastViewedAt: 1234567890000,
LastUpdateAt: 1234567890000,
NotifyProps: GetDefaultChannelNotifyProps(),
}
originalLastViewedAt := member.LastViewedAt
originalLastUpdateAt := member.LastUpdateAt
member.SanitizeForCurrentUser(currentUserId)
assert.Equal(t, originalLastViewedAt, member.LastViewedAt, "LastViewedAt should not be sanitized for current user")
assert.Equal(t, originalLastUpdateAt, member.LastUpdateAt, "LastUpdateAt should not be sanitized for current user")
})
t.Run("should sanitize other users' membership data", func(t *testing.T) {
member := &ChannelMember{
ChannelId: channelId,
UserId: otherUserId,
LastViewedAt: 1234567890000,
LastUpdateAt: 1234567890000,
NotifyProps: GetDefaultChannelNotifyProps(),
}
member.SanitizeForCurrentUser(currentUserId)
assert.Equal(t, sanitizedTimestamp, member.LastViewedAt, "LastViewedAt should be marked sanitized for other users")
assert.Equal(t, sanitizedTimestamp, member.LastUpdateAt, "LastUpdateAt should be marked sanitized for other users")
})
t.Run("should preserve other fields when sanitizing", func(t *testing.T) {
member := &ChannelMember{
ChannelId: channelId,
UserId: otherUserId,
Roles: "channel_user",
LastViewedAt: 1234567890000,
LastUpdateAt: 1234567890000,
MsgCount: 100,
MentionCount: 5,
NotifyProps: GetDefaultChannelNotifyProps(),
SchemeUser: true,
SchemeAdmin: false,
ExplicitRoles: "",
}
originalRoles := member.Roles
originalMsgCount := member.MsgCount
originalMentionCount := member.MentionCount
originalSchemeUser := member.SchemeUser
originalSchemeAdmin := member.SchemeAdmin
member.SanitizeForCurrentUser(currentUserId)
assert.Equal(t, sanitizedTimestamp, member.LastViewedAt, "LastViewedAt should be marked sanitized")
assert.Equal(t, sanitizedTimestamp, member.LastUpdateAt, "LastUpdateAt should be marked sanitized")
assert.Equal(t, originalRoles, member.Roles, "Roles should be preserved")
assert.Equal(t, originalMsgCount, member.MsgCount, "MsgCount should be preserved")
assert.Equal(t, originalMentionCount, member.MentionCount, "MentionCount should be preserved")
assert.Equal(t, originalSchemeUser, member.SchemeUser, "SchemeUser should be preserved")
assert.Equal(t, originalSchemeAdmin, member.SchemeAdmin, "SchemeAdmin should be preserved")
})
}
func TestChannelMemberMarshalJSON(t *testing.T) {
currentUserId := NewId()
otherUserId := NewId()
newMember := func(userId string) ChannelMember {
return ChannelMember{
ChannelId: NewId(),
UserId: userId,
Roles: "channel_user",
LastViewedAt: 1234567890000,
MsgCount: 100,
LastUpdateAt: 1234567890000,
NotifyProps: GetDefaultChannelNotifyProps(),
}
}
decode := func(t *testing.T, member ChannelMember) map[string]any {
t.Helper()
data, err := json.Marshal(member)
require.NoError(t, err)
fields := map[string]any{}
require.NoError(t, json.Unmarshal(data, &fields))
return fields
}
t.Run("keeps timestamps for the current user's own membership", func(t *testing.T) {
member := newMember(currentUserId)
member.SanitizeForCurrentUser(currentUserId)
fields := decode(t, member)
assert.EqualValues(t, 1234567890000, fields["last_viewed_at"])
assert.EqualValues(t, 1234567890000, fields["last_update_at"])
})
t.Run("keeps a legitimate zero timestamp for the requester", func(t *testing.T) {
member := newMember(currentUserId)
member.LastViewedAt = 0
member.LastUpdateAt = 0
member.SanitizeForCurrentUser(currentUserId)
fields := decode(t, member)
assert.Contains(t, fields, "last_viewed_at", "the requester's own last_viewed_at of 0 (never viewed) must be serialized")
assert.EqualValues(t, 0, fields["last_viewed_at"])
assert.Contains(t, fields, "last_update_at", "the requester's own last_update_at of 0 must be serialized")
assert.EqualValues(t, 0, fields["last_update_at"])
})
t.Run("omits sanitized timestamps for another user's membership", func(t *testing.T) {
member := newMember(otherUserId)
member.SanitizeForCurrentUser(currentUserId)
fields := decode(t, member)
assert.NotContains(t, fields, "last_viewed_at", "sanitized last_viewed_at must be omitted")
assert.NotContains(t, fields, "last_update_at", "sanitized last_update_at must be omitted")
assert.Equal(t, member.ChannelId, fields["channel_id"])
assert.Equal(t, otherUserId, fields["user_id"])
assert.Equal(t, "channel_user", fields["roles"])
assert.EqualValues(t, 100, fields["msg_count"])
assert.Contains(t, fields, "notify_props")
})
}
func TestChannelMemberWithTeamDataMarshalJSON(t *testing.T) {
currentUserId := NewId()
otherUserId := NewId()
newMember := func(userId string) ChannelMemberWithTeamData {
return ChannelMemberWithTeamData{
ChannelMember: ChannelMember{
ChannelId: NewId(),
UserId: userId,
Roles: "channel_user",
LastViewedAt: 1234567890000,
LastUpdateAt: 1234567890000,
NotifyProps: GetDefaultChannelNotifyProps(),
},
TeamDisplayName: "Test Team",
TeamName: "test-team",
TeamUpdateAt: 987654321,
}
}
decode := func(t *testing.T, member ChannelMemberWithTeamData) map[string]any {
t.Helper()
data, err := json.Marshal(member)
require.NoError(t, err)
fields := map[string]any{}
require.NoError(t, json.Unmarshal(data, &fields))
return fields
}
t.Run("preserves team data and timestamps for the current user", func(t *testing.T) {
member := newMember(currentUserId)
member.SanitizeForCurrentUser(currentUserId)
fields := decode(t, member)
assert.EqualValues(t, 1234567890000, fields["last_viewed_at"])
assert.EqualValues(t, 1234567890000, fields["last_update_at"])
assert.Equal(t, "Test Team", fields["team_display_name"])
assert.Equal(t, "test-team", fields["team_name"])
assert.EqualValues(t, 987654321, fields["team_update_at"])
})
t.Run("omits sanitized timestamps but keeps team data for another user", func(t *testing.T) {
member := newMember(otherUserId)
member.SanitizeForCurrentUser(currentUserId)
fields := decode(t, member)
assert.NotContains(t, fields, "last_viewed_at", "sanitized last_viewed_at must be omitted")
assert.NotContains(t, fields, "last_update_at", "sanitized last_update_at must be omitted")
assert.Equal(t, "Test Team", fields["team_display_name"])
assert.Equal(t, "test-team", fields["team_name"])
assert.EqualValues(t, 987654321, fields["team_update_at"])
assert.Equal(t, otherUserId, fields["user_id"])
})
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"regexp"
"strings"
)
var channelMentionRegexp = regexp.MustCompile(`\B~[a-zA-Z0-9\-_]+`)
func ChannelMentions(message string) []string {
return ChannelMentionsFromStrings([]string{message})
}
// ChannelMentionsFromStrings extracts ~channel-name mentions from each string, deduplicating across
// all inputs. Callers typically pass post.AllStrings() so mentions are found in the message, message
// attachments, and interactive payloads (mm_blocks, blocks, cards) consistently with other post text.
func ChannelMentionsFromStrings(strs []string) []string {
alreadyMentioned := make(map[string]bool)
var names []string
for _, s := range strs {
if !strings.Contains(s, "~") {
continue
}
for _, match := range channelMentionRegexp.FindAllString(s, -1) {
name := match[1:]
if !alreadyMentioned[name] {
names = append(names, name)
alreadyMentioned[name] = true
}
}
}
return names
}
// ChannelMentionsFromAttachments extracts channel mentions from attachment fields.
// It scans pretext, text, and field values (but not titles, as titles are labels).
func ChannelMentionsFromAttachments(attachments []*MessageAttachment) []string {
alreadyMentioned := make(map[string]bool)
var names []string
for _, attachment := range attachments {
if attachment == nil {
continue
}
// Scan pretext
for _, match := range channelMentionRegexp.FindAllString(attachment.Pretext, -1) {
name := match[1:]
if !alreadyMentioned[name] {
names = append(names, name)
alreadyMentioned[name] = true
}
}
// Scan text
for _, match := range channelMentionRegexp.FindAllString(attachment.Text, -1) {
name := match[1:]
if !alreadyMentioned[name] {
names = append(names, name)
alreadyMentioned[name] = true
}
}
// Scan field values (not titles - titles are labels)
for _, field := range attachment.Fields {
if field == nil {
continue
}
// Field value can be any type, convert to string
var valueStr string
switch v := field.Value.(type) {
case string:
valueStr = v
default:
// For non-string values, we don't scan for mentions
continue
}
for _, match := range channelMentionRegexp.FindAllString(valueStr, -1) {
name := match[1:]
if !alreadyMentioned[name] {
names = append(names, name)
alreadyMentioned[name] = true
}
}
}
}
return names
}

View File

@@ -0,0 +1,456 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestChannelMentions(t *testing.T) {
tests := []struct {
name string
message string
expected []string
}{
{
name: "single channel mention",
message: "Check out ~engineering",
expected: []string{"engineering"},
},
{
name: "multiple channel mentions",
message: "Deploy to ~engineering and ~qa-team",
expected: []string{"engineering", "qa-team"},
},
{
name: "duplicate channel mentions",
message: "~engineering and ~engineering again",
expected: []string{"engineering"},
},
{
name: "no channel mentions",
message: "No mentions here",
expected: nil,
},
{
name: "channel mention with hyphens and underscores",
message: "~engineering-team and ~qa_team",
expected: []string{"engineering-team", "qa_team"},
},
{
name: "channel mention with dots stops at dot",
message: "~team.sub.channel",
expected: []string{"team"}, // Regex doesn't include dots
},
{
name: "channel mention with numbers",
message: "~team123 and ~abc456def",
expected: []string{"team123", "abc456def"},
},
{
name: "channel mention with uppercase",
message: "~UPPERCASE and ~Mixed-Case",
expected: []string{"UPPERCASE", "Mixed-Case"}, // Regex allows uppercase
},
{
name: "channel mention at start",
message: "~engineering is the best",
expected: []string{"engineering"},
},
{
name: "channel mention at end",
message: "Check ~engineering",
expected: []string{"engineering"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ChannelMentions(tt.message)
assert.Equal(t, tt.expected, result)
})
}
}
func TestChannelMentionsFromStrings(t *testing.T) {
t.Run("nil slice", func(t *testing.T) {
assert.Nil(t, ChannelMentionsFromStrings(nil))
})
t.Run("empty slice", func(t *testing.T) {
assert.Nil(t, ChannelMentionsFromStrings([]string{}))
})
t.Run("single segment with mention", func(t *testing.T) {
assert.Equal(t, []string{"engineering"}, ChannelMentionsFromStrings([]string{"Check out ~engineering"}))
})
t.Run("multiple segments order", func(t *testing.T) {
assert.Equal(t, []string{"engineering", "qa-team"}, ChannelMentionsFromStrings([]string{
"Check ~engineering",
"Deploy to ~qa-team",
}))
})
t.Run("deduplicates across segments", func(t *testing.T) {
assert.Equal(t, []string{"engineering"}, ChannelMentionsFromStrings([]string{
"~engineering",
"~engineering again",
}))
})
t.Run("skips segments without tilde", func(t *testing.T) {
assert.Equal(t, []string{"eng"}, ChannelMentionsFromStrings([]string{"plain", "see ~eng"}))
})
t.Run("numeric segment without tilde", func(t *testing.T) {
assert.Nil(t, ChannelMentionsFromStrings([]string{"123"}))
})
t.Run("numeric segment with mention in another segment", func(t *testing.T) {
assert.Equal(t, []string{"engineering"}, ChannelMentionsFromStrings([]string{"123", "ref ~engineering"}))
})
}
func TestChannelMentionsFromAttachments(t *testing.T) {
t.Run("nil attachments", func(t *testing.T) {
result := ChannelMentionsFromAttachments(nil)
assert.Empty(t, result)
})
t.Run("empty attachments", func(t *testing.T) {
result := ChannelMentionsFromAttachments([]*MessageAttachment{})
assert.Empty(t, result)
})
t.Run("attachment with pretext", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Pretext: "Check out ~engineering",
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("attachment with text", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Text: "Deploy to ~qa-team",
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Equal(t, []string{"qa-team"}, result)
})
t.Run("attachment with fields", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Fields: []*MessageAttachmentField{
{
Title: "Channel",
Value: "~engineering",
},
},
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("attachment with title should not extract", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Title: "~engineering-channel",
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Empty(t, result)
})
t.Run("field title should not extract", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Fields: []*MessageAttachmentField{
{
Title: "~engineering",
Value: "some value",
},
},
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Empty(t, result)
})
t.Run("multiple attachments with multiple mentions", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Pretext: "Check ~engineering",
Text: "Deploy to ~qa-team",
},
{
Text: "Also notify ~devops",
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Equal(t, []string{"engineering", "qa-team", "devops"}, result)
})
t.Run("deduplicates mentions across attachments", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Pretext: "~engineering",
},
{
Text: "~engineering again",
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("handles non-string field values", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Fields: []*MessageAttachmentField{
{
Title: "Count",
Value: 123, // Non-string value
},
{
Title: "Channel",
Value: "~engineering",
},
},
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("handles nil attachment in array", func(t *testing.T) {
attachments := []*MessageAttachment{
nil,
{
Text: "~engineering",
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("handles nil field in fields array", func(t *testing.T) {
attachments := []*MessageAttachment{
{
Fields: []*MessageAttachmentField{
nil,
{
Value: "~engineering",
},
},
},
}
result := ChannelMentionsFromAttachments(attachments)
assert.Equal(t, []string{"engineering"}, result)
})
}
func TestPostChannelMentionsAll(t *testing.T) {
t.Run("message only", func(t *testing.T) {
post := &Post{
Message: "Check ~engineering",
}
result := post.ChannelMentionsAll()
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("attachments only", func(t *testing.T) {
post := &Post{
Message: "No mentions here",
}
post.AddProp("attachments", []*MessageAttachment{
{
Text: "Deploy to ~qa-team",
},
})
result := post.ChannelMentionsAll()
assert.Equal(t, []string{"qa-team"}, result)
})
t.Run("message and attachments combined", func(t *testing.T) {
post := &Post{
Message: "Check ~engineering",
}
post.AddProp("attachments", []*MessageAttachment{
{
Text: "Deploy to ~qa-team",
},
})
result := post.ChannelMentionsAll()
assert.Equal(t, []string{"engineering", "qa-team"}, result)
})
t.Run("deduplicates between message and attachments", func(t *testing.T) {
post := &Post{
Message: "Check ~engineering",
}
post.AddProp("attachments", []*MessageAttachment{
{
Text: "~engineering is great",
},
})
result := post.ChannelMentionsAll()
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("no mentions", func(t *testing.T) {
post := &Post{
Message: "No mentions",
}
result := post.ChannelMentionsAll()
assert.Empty(t, result)
})
t.Run("post without attachments", func(t *testing.T) {
post := &Post{
Message: "Check ~engineering",
}
result := post.ChannelMentionsAll()
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("post with invalid attachments prop", func(t *testing.T) {
post := &Post{
Message: "Check ~engineering",
}
post.AddProp("attachments", "not an array")
result := post.ChannelMentionsAll()
assert.Equal(t, []string{"engineering"}, result)
})
t.Run("interactive mm_blocks, blocks, and cards collect mentions from every block", func(t *testing.T) {
post := &Post{
Message: "hello ~from-message",
Props: StringInterface{
PostPropsMmBlocks: []any{
map[string]any{"type": "text", "text": "mm block one ~mm-a"},
map[string]any{"type": "text", "text": "mm block two ~mm-b"},
map[string]any{
"type": "container",
"content": []any{
map[string]any{"type": "text", "text": "nested ~mm-c"},
map[string]any{"type": "text", "text": "nested ~mm-d"},
},
},
},
PostPropsBlockKitBlocks: []any{
map[string]any{
"type": "section",
"text": map[string]any{
"type": "mrkdwn",
"text": "bk first ~bk-a",
},
},
map[string]any{
"type": "section",
"text": map[string]any{
"type": "mrkdwn",
"text": "bk second ~bk-b",
},
},
map[string]any{
"type": "section",
"text": map[string]any{
"type": "plain_text",
"text": "bk plain ~bk-c",
},
},
},
PostPropsAdaptiveCards: []any{
map[string]any{
"type": "AdaptiveCard",
"body": []any{
map[string]any{"type": "TextBlock", "text": "card1 ~ac-a"},
map[string]any{"type": "TextBlock", "text": "card1b ~ac-b"},
},
},
map[string]any{
"type": "AdaptiveCard",
"body": []any{
map[string]any{"type": "TextBlock", "text": "card2 ~ac-c"},
},
},
},
},
}
result := post.ChannelMentionsAll()
assert.Equal(t, []string{
"from-message",
"mm-a", "mm-b", "mm-c", "mm-d",
"bk-a", "bk-b", "bk-c",
"ac-a", "ac-b", "ac-c",
}, result)
})
t.Run("deduplicates channel mentions across message, mm_blocks, blocks, and cards", func(t *testing.T) {
post := &Post{
Message: "root ~shared-name",
Props: StringInterface{
PostPropsMmBlocks: []any{
map[string]any{"type": "text", "text": "again ~shared-name"},
map[string]any{"type": "text", "text": "other ~mm-only"},
},
PostPropsBlockKitBlocks: []any{
map[string]any{
"type": "section",
"text": map[string]any{
"type": "mrkdwn",
"text": "~shared-name and ~bk-only",
},
},
},
PostPropsAdaptiveCards: []any{
map[string]any{
"type": "AdaptiveCard",
"body": []any{
map[string]any{"type": "TextBlock", "text": "~shared-name ~ac-only"},
},
},
},
},
}
result := post.ChannelMentionsAll()
assert.Equal(t, []string{"shared-name", "mm-only", "bk-only", "ac-only"}, result)
})
}
func TestPostCurrentTeamIdHandling(t *testing.T) {
t.Run("preserves current_team_id before FillInPostProps", func(t *testing.T) {
post := &Post{
Message: "Check ~engineering",
}
post.AddProp(PostPropsCurrentTeamId, "team123")
// Verify it's set
teamId, ok := post.GetProp(PostPropsCurrentTeamId).(string)
assert.True(t, ok)
assert.Equal(t, "team123", teamId)
})
t.Run("current_team_id should be removed after processing", func(t *testing.T) {
// This test is more of a documentation test
// FillInPostProps should call DelProp(PostPropsCurrentTeamId) after using it
post := &Post{
Message: "Check ~engineering",
}
post.AddProp(PostPropsCurrentTeamId, "team123")
// After FillInPostProps processes, current_team_id should be removed
// (actual verification happens in app layer integration tests)
assert.NotNil(t, post.GetProp(PostPropsCurrentTeamId))
})
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const ChannelSearchDefaultLimit = 50
type ChannelSearch struct {
Term string `json:"term"`
ExcludeDefaultChannels bool `json:"exclude_default_channels"`
NotAssociatedToGroup string `json:"not_associated_to_group"`
TeamIds []string `json:"team_ids"`
GroupConstrained bool `json:"group_constrained"`
ExcludeGroupConstrained bool `json:"exclude_group_constrained"`
ExcludePolicyConstrained bool `json:"exclude_policy_constrained"`
Public bool `json:"public"`
Private bool `json:"private"`
IncludeDeleted bool `json:"include_deleted"`
IncludeSearchById bool `json:"include_search_by_id"`
ExcludeRemote bool `json:"exclude_remote"`
Deleted bool `json:"deleted"`
Page *int `json:"page,omitempty"`
PerPage *int `json:"per_page,omitempty"`
AccessControlPolicyEnforced bool `json:"access_control_policy_enforced"`
ExcludeAccessControlPolicyEnforced bool `json:"exclude_access_control_policy_enforced"`
ParentAccessControlPolicyId string `json:"parent_access_control_policy_id"`
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"regexp"
)
type SidebarCategoryType string
type SidebarCategorySorting string
const (
// Each sidebar category has a 'type'. System categories are Channels, Favorites and DMs
// All user-created categories will have type Custom
SidebarCategoryChannels SidebarCategoryType = "channels"
SidebarCategoryDirectMessages SidebarCategoryType = "direct_messages"
SidebarCategoryFavorites SidebarCategoryType = "favorites"
SidebarCategoryCustom SidebarCategoryType = "custom"
SidebarCategoryManaged SidebarCategoryType = "managed"
// Increment to use when adding/reordering things in the sidebar
MinimalSidebarSortDistance = 10
// Default Sort Orders for categories
DefaultSidebarSortOrderFavorites = 0
DefaultSidebarSortOrderChannels = DefaultSidebarSortOrderFavorites + MinimalSidebarSortDistance
DefaultSidebarSortOrderDMs = DefaultSidebarSortOrderChannels + MinimalSidebarSortDistance
// Sorting modes
// default for all categories except DMs (behaves like manual)
SidebarCategorySortDefault SidebarCategorySorting = ""
// sort manually
SidebarCategorySortManual SidebarCategorySorting = "manual"
// sort by recency (default for DMs)
SidebarCategorySortRecent SidebarCategorySorting = "recent"
// sort by display name alphabetically
SidebarCategorySortAlphabetical SidebarCategorySorting = "alpha"
ManagedCategoryPropertyGroupName = "managed_channel_categories"
ManagedCategoryPropertyFieldName = "category_name"
)
// SidebarCategory represents the corresponding DB table
type SidebarCategory struct {
Id string `json:"id"`
UserId string `json:"user_id"`
TeamId string `json:"team_id"`
SortOrder int64 `json:"sort_order"`
Sorting SidebarCategorySorting `json:"sorting"`
Type SidebarCategoryType `json:"type"`
DisplayName string `json:"display_name"`
Muted bool `json:"muted"`
Collapsed bool `json:"collapsed"`
}
// SidebarCategoryWithChannels combines data from SidebarCategory table with the Channel IDs that belong to that category
type SidebarCategoryWithChannels struct {
SidebarCategory
Channels []string `json:"channel_ids"`
}
func (sc SidebarCategoryWithChannels) ChannelIds() []string {
return sc.Channels
}
type SidebarCategoryOrder []string
// OrderedSidebarCategories combines categories, their channel IDs and an array of Category IDs, sorted
type OrderedSidebarCategories struct {
Categories SidebarCategoriesWithChannels `json:"categories"`
Order SidebarCategoryOrder `json:"order"`
}
type SidebarChannel struct {
ChannelId string `json:"channel_id"`
UserId string `json:"user_id"`
CategoryId string `json:"category_id"`
SortOrder int64 `json:"-"`
}
type SidebarChannels []*SidebarChannel
type SidebarCategoriesWithChannels []*SidebarCategoryWithChannels
var categoryIdPattern = regexp.MustCompile("(favorites|channels|direct_messages)_[a-z0-9]{26}_[a-z0-9]{26}")
func IsValidCategoryId(s string) bool {
// Category IDs can either be regular IDs
if IsValidId(s) {
return true
}
// Or default categories can follow the pattern {type}_{userID}_{teamID}
return categoryIdPattern.MatchString(s)
}
func (t SidebarCategoryType) MarshalJSON() ([]byte, error) {
return json.Marshal(string(t))
}
func (t SidebarCategorySorting) MarshalJSON() ([]byte, error) {
return json.Marshal(string(t))
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsValidCategoryId(t *testing.T) {
for _, test := range []struct {
Name string
Input string
Expected bool
}{
{
Name: "should accept a regular ID",
Input: NewId(),
Expected: true,
},
{
Name: "should accept a favorites ID",
Input: fmt.Sprintf("favorites_%s_%s", NewId(), NewId()),
Expected: true,
},
{
Name: "should accept a channels ID",
Input: fmt.Sprintf("channels_%s_%s", NewId(), NewId()),
Expected: true,
},
{
Name: "should accept a direct messages ID",
Input: fmt.Sprintf("direct_messages_%s_%s", NewId(), NewId()),
Expected: true,
},
{
Name: "should reject a garbage ID",
Input: "a garbage ID",
Expected: false,
},
} {
t.Run(test.Name, func(t *testing.T) {
assert.Equal(t, test.Expected, IsValidCategoryId(test.Input))
})
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ChannelStats struct {
ChannelId string `json:"channel_id"`
MemberCount int64 `json:"member_count"`
GuestCount int64 `json:"guest_count"`
PinnedPostCount int64 `json:"pinnedpost_count"`
FilesCount int64 `json:"files_count"`
}
func (o *ChannelStats) MemberCount_() float64 {
return float64(o.MemberCount)
}
func (o *ChannelStats) GuestCount_() float64 {
return float64(o.GuestCount)
}
func (o *ChannelStats) PinnedPostCount_() float64 {
return float64(o.PinnedPostCount)
}

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestChannelCopy(t *testing.T) {
o := Channel{Id: NewId(), Name: NewId()}
ro := o.DeepCopy()
require.Equal(t, o.Id, ro.Id, "Ids do not match")
}
func TestChannelPatch(t *testing.T) {
p := &ChannelPatch{Name: new(string), DisplayName: new(string), Header: new(string), Purpose: new(string), GroupConstrained: new(bool)}
*p.Name = NewId()
*p.DisplayName = NewId()
*p.Header = NewId()
*p.Purpose = NewId()
*p.GroupConstrained = true
o := Channel{Id: NewId(), Name: NewId()}
o.Patch(p)
require.Equal(t, *p.Name, o.Name)
require.Equal(t, *p.DisplayName, o.DisplayName)
require.Equal(t, *p.Header, o.Header)
require.Equal(t, *p.Purpose, o.Purpose)
require.Equal(t, *p.GroupConstrained, *o.GroupConstrained)
}
func TestChannelPatchDiscoverable(t *testing.T) {
t.Run("applies discoverable when set", func(t *testing.T) {
on := true
p := &ChannelPatch{Discoverable: &on}
o := Channel{Id: NewId(), Name: NewId(), Type: ChannelTypePrivate}
o.Patch(p)
require.True(t, o.Discoverable)
})
t.Run("clears discoverable when set to false", func(t *testing.T) {
off := false
p := &ChannelPatch{Discoverable: &off}
o := Channel{Id: NewId(), Name: NewId(), Type: ChannelTypePrivate, Discoverable: true}
o.Patch(p)
require.False(t, o.Discoverable)
})
t.Run("nil discoverable leaves channel untouched", func(t *testing.T) {
o := Channel{Id: NewId(), Name: NewId(), Type: ChannelTypePrivate, Discoverable: true}
o.Patch(&ChannelPatch{})
require.True(t, o.Discoverable)
})
}
func TestChannelIsValidDiscoverable(t *testing.T) {
base := Channel{
Id: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
DisplayName: "x",
Name: "valid-name",
Header: "h",
Purpose: "p",
}
t.Run("discoverable=false is valid on any type", func(t *testing.T) {
c := base
c.Type = ChannelTypeOpen
require.Nil(t, c.IsValid())
})
t.Run("discoverable=true requires private channel", func(t *testing.T) {
c := base
c.Type = ChannelTypeOpen
c.Discoverable = true
require.NotNil(t, c.IsValid(), "discoverable=true on public channel must be rejected")
c.Type = ChannelTypeDirect
require.NotNil(t, c.IsValid())
c.Type = ChannelTypeGroup
require.NotNil(t, c.IsValid())
c.Type = ChannelTypePrivate
require.Nil(t, c.IsValid())
})
}
func TestChannelSupportsGroupSync(t *testing.T) {
require.True(t, (&Channel{Type: ChannelTypeOpen}).SupportsGroupSync())
require.True(t, (&Channel{Type: ChannelTypePrivate}).SupportsGroupSync())
require.False(t, (&Channel{Type: ChannelTypeDirect}).SupportsGroupSync())
require.False(t, (&Channel{Type: ChannelTypeGroup}).SupportsGroupSync())
require.False(t, (&Channel{Type: ChannelTypeOpenBoard}).SupportsGroupSync())
require.False(t, (&Channel{Type: ChannelTypePrivateBoard}).SupportsGroupSync())
}
func TestChannelIsValidGroupConstrained(t *testing.T) {
base := Channel{
Id: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
DisplayName: "x",
Name: "valid-name",
Header: "h",
Purpose: "p",
}
t.Run("group_constrained is allowed on public and private channels", func(t *testing.T) {
c := base
c.GroupConstrained = NewPointer(true)
c.Type = ChannelTypeOpen
require.Nil(t, c.IsValid())
c.Type = ChannelTypePrivate
require.Nil(t, c.IsValid())
})
t.Run("group_constrained is rejected on direct, group, and board channels", func(t *testing.T) {
c := base
c.GroupConstrained = NewPointer(true)
c.Type = ChannelTypeDirect
require.NotNil(t, c.IsValid())
c.Type = ChannelTypeGroup
require.NotNil(t, c.IsValid())
c.Type = ChannelTypeOpenBoard
require.NotNil(t, c.IsValid())
c.Type = ChannelTypePrivateBoard
require.NotNil(t, c.IsValid())
})
}
func TestChannelIsValid(t *testing.T) {
o := Channel{}
require.NotNil(t, o.IsValid())
o.Id = NewId()
require.NotNil(t, o.IsValid())
o.CreateAt = GetMillis()
require.NotNil(t, o.IsValid())
o.UpdateAt = GetMillis()
require.NotNil(t, o.IsValid())
o.DisplayName = strings.Repeat("01234567890", 20)
require.NotNil(t, o.IsValid())
o.DisplayName = "1234"
o.Name = "ZZZZZZZ"
require.NotNil(t, o.IsValid())
o.Name = "zzzzz"
require.NotNil(t, o.IsValid())
o.Type = "U"
require.NotNil(t, o.IsValid())
o.Type = ChannelTypePrivate
require.Nil(t, o.IsValid())
o.Header = strings.Repeat("01234567890", 100)
require.NotNil(t, o.IsValid())
o.Header = "1234"
require.Nil(t, o.IsValid())
o.Purpose = strings.Repeat("01234567890", 30)
require.NotNil(t, o.IsValid())
o.Purpose = "1234"
require.Nil(t, o.IsValid())
o.Purpose = strings.Repeat("0123456789", 25)
require.Nil(t, o.IsValid())
o.Name = "beu8cc6b3jnxfe9r4na9baooma__36atajbs87dqmpym6o8eiy9saa"
require.NotNil(t, o.IsValid())
o.Name = "71b03afcbb2d503d49f87f057549c43db4e19f92"
require.NotNil(t, o.IsValid())
}
func TestChannelTypeSpace(t *testing.T) {
t.Run("IsValid accepts a space backing channel", func(t *testing.T) {
o := Channel{
Id: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
TeamId: NewId(),
DisplayName: "Space",
Name: "space-" + NewId(),
Type: ChannelTypeSpace,
}
require.Nil(t, o.IsValid())
})
}
func TestChannelIsValidBoard(t *testing.T) {
t.Run("rejects non-board type", func(t *testing.T) {
c := &Channel{Type: ChannelTypeOpen, TeamId: NewId(), DisplayName: "Board"}
err := c.IsValidBoard()
require.NotNil(t, err)
require.Equal(t, "model.channel.is_valid_board.type.app_error", err.Id)
})
t.Run("rejects missing team_id", func(t *testing.T) {
c := &Channel{Type: ChannelTypeOpenBoard, DisplayName: "Board"}
err := c.IsValidBoard()
require.NotNil(t, err)
require.Equal(t, "model.channel.is_valid_board.team_id.app_error", err.Id)
})
t.Run("rejects empty display name", func(t *testing.T) {
c := &Channel{Type: ChannelTypeOpenBoard, TeamId: NewId()}
err := c.IsValidBoard()
require.NotNil(t, err)
require.Equal(t, "model.channel.is_valid_board.display_name.app_error", err.Id)
})
t.Run("accepts valid open board", func(t *testing.T) {
c := &Channel{Type: ChannelTypeOpenBoard, TeamId: NewId(), DisplayName: "Board"}
require.Nil(t, c.IsValidBoard())
})
t.Run("accepts valid private board", func(t *testing.T) {
c := &Channel{Type: ChannelTypePrivateBoard, TeamId: NewId(), DisplayName: "Board"}
require.Nil(t, c.IsValidBoard())
})
}
func TestChannelBannerBackgroundColorValidation(t *testing.T) {
o := Channel{
Id: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
Name: "valid-name",
Type: ChannelTypeOpen,
Header: "valid-header",
Purpose: "valid-purpose",
BannerInfo: &ChannelBannerInfo{
Enabled: new(true),
Text: new("Banner Text"),
},
}
// Test with nil background color
o.BannerInfo.BackgroundColor = nil
require.NotNil(t, o.IsValid())
require.Equal(t, "model.channel.is_valid.banner_info.background_color.empty.app_error", o.IsValid().Id)
// Test with empty background color
o.BannerInfo.BackgroundColor = new("")
require.NotNil(t, o.IsValid())
require.Equal(t, "model.channel.is_valid.banner_info.background_color.empty.app_error", o.IsValid().Id)
// Test with invalid background color (no # prefix)
o.BannerInfo.BackgroundColor = new("FF0000")
require.NotNil(t, o.IsValid())
require.Equal(t, "model.channel.is_valid.banner_info.background_color.invalid.app_error", o.IsValid().Id)
// Test with invalid background color (invalid characters)
o.BannerInfo.BackgroundColor = new("#GGGGGG")
require.NotNil(t, o.IsValid())
require.Equal(t, "model.channel.is_valid.banner_info.background_color.invalid.app_error", o.IsValid().Id)
// Test with invalid background color (wrong length)
o.BannerInfo.BackgroundColor = new("#FF00")
require.NotNil(t, o.IsValid())
require.Equal(t, "model.channel.is_valid.banner_info.background_color.invalid.app_error", o.IsValid().Id)
// Test with invalid background color (wrong length)
o.BannerInfo.BackgroundColor = new("#FF00000")
require.NotNil(t, o.IsValid())
require.Equal(t, "model.channel.is_valid.banner_info.background_color.invalid.app_error", o.IsValid().Id)
// Test with valid 6-digit hex color
o.BannerInfo.BackgroundColor = new("#FF0000")
require.Nil(t, o.IsValid())
// Test with valid 6-digit hex color (lowercase)
o.BannerInfo.BackgroundColor = new("#ff0000")
require.Nil(t, o.IsValid())
// Test with valid 3-digit hex color
o.BannerInfo.BackgroundColor = new("#F00")
require.Nil(t, o.IsValid())
// Test with valid 3-digit hex color (lowercase)
o.BannerInfo.BackgroundColor = new("#f00")
require.Nil(t, o.IsValid())
}
func TestChannelPreSave(t *testing.T) {
o := Channel{Name: "test"}
o.PreSave()
}
func TestChannelPreUpdate(t *testing.T) {
o := Channel{Name: "test"}
o.PreUpdate()
}
func TestGetGroupDisplayNameFromUsers(t *testing.T) {
users := make([]*User, 4)
users[0] = &User{Username: NewId()}
users[1] = &User{Username: NewId()}
users[2] = &User{Username: NewId()}
users[3] = &User{Username: NewId()}
name := GetGroupDisplayNameFromUsers(users, true)
require.LessOrEqual(t, len(name), ChannelNameMaxLength)
}
func TestGetGroupNameFromUserIds(t *testing.T) {
name := GetGroupNameFromUserIds([]string{NewId(), NewId(), NewId(), NewId(), NewId()})
require.LessOrEqual(t, len(name), ChannelNameMaxLength)
}
func TestSanitize(t *testing.T) {
schemaId := NewId()
o := Channel{
Id: NewId(),
CreateAt: 1,
UpdateAt: 1,
DeleteAt: 1,
Name: NewId(),
DisplayName: NewId(),
Header: NewId(),
Purpose: NewId(),
LastPostAt: 1,
TotalMsgCount: 1,
ExtraUpdateAt: 1,
CreatorId: NewId(),
SchemeId: &schemaId,
Props: make(map[string]any),
GroupConstrained: new(true),
Shared: new(true),
TotalMsgCountRoot: 1,
PolicyID: &schemaId,
LastRootPostAt: 1,
}
s := o.Sanitize()
require.NotEqual(t, "", s.Id)
require.Equal(t, int64(0), s.CreateAt)
require.Equal(t, int64(0), s.UpdateAt)
require.Equal(t, int64(0), s.DeleteAt)
require.Equal(t, "", s.Name)
require.NotEqual(t, "", s.DisplayName)
require.Equal(t, "", s.Header)
require.Equal(t, "", s.Purpose)
require.Equal(t, int64(0), s.LastPostAt)
require.Equal(t, int64(0), s.TotalMsgCount)
require.Equal(t, int64(0), s.ExtraUpdateAt)
require.Equal(t, "", s.CreatorId)
require.Nil(t, s.SchemeId)
require.Nil(t, s.Props)
require.Nil(t, s.GroupConstrained)
require.Nil(t, s.Shared)
require.Equal(t, int64(0), s.TotalMsgCountRoot)
require.Nil(t, s.PolicyID)
require.Equal(t, int64(0), s.LastRootPostAt)
}
func TestShouldChannelExcludeMembershipSystemPosts(t *testing.T) {
t.Run("nil channel returns false", func(t *testing.T) {
require.False(t, ShouldChannelExcludeMembershipSystemPosts(nil))
})
t.Run("DM channel returns false even when flag is set", func(t *testing.T) {
ch := &Channel{Type: ChannelTypeDirect, DisableJoinLeaveMessages: true}
require.False(t, ShouldChannelExcludeMembershipSystemPosts(ch))
})
t.Run("GM channel returns false even when flag is set", func(t *testing.T) {
ch := &Channel{Type: ChannelTypeGroup, DisableJoinLeaveMessages: true}
require.False(t, ShouldChannelExcludeMembershipSystemPosts(ch))
})
t.Run("open channel returns false when flag is off", func(t *testing.T) {
ch := &Channel{Type: ChannelTypeOpen, DisableJoinLeaveMessages: false}
require.False(t, ShouldChannelExcludeMembershipSystemPosts(ch))
})
t.Run("open channel returns true when flag is on", func(t *testing.T) {
ch := &Channel{Type: ChannelTypeOpen, DisableJoinLeaveMessages: true}
require.True(t, ShouldChannelExcludeMembershipSystemPosts(ch))
})
t.Run("private channel returns true when flag is on", func(t *testing.T) {
ch := &Channel{Type: ChannelTypePrivate, DisableJoinLeaveMessages: true}
require.True(t, ShouldChannelExcludeMembershipSystemPosts(ch))
})
}
func TestChannelPatchDisableJoinLeaveMessages(t *testing.T) {
t.Run("nil patch field leaves value unchanged", func(t *testing.T) {
ch := &Channel{DisableJoinLeaveMessages: true}
ch.Patch(&ChannelPatch{})
require.True(t, ch.DisableJoinLeaveMessages)
})
t.Run("patch applies true", func(t *testing.T) {
ch := &Channel{DisableJoinLeaveMessages: false}
val := true
ch.Patch(&ChannelPatch{DisableJoinLeaveMessages: &val})
require.True(t, ch.DisableJoinLeaveMessages)
})
t.Run("patch applies false", func(t *testing.T) {
ch := &Channel{DisableJoinLeaveMessages: true}
val := false
ch.Patch(&ChannelPatch{DisableJoinLeaveMessages: &val})
require.False(t, ch.DisableJoinLeaveMessages)
})
}
func TestChannelAuditableIncludesDisableJoinLeaveMessages(t *testing.T) {
ch := &Channel{DisableJoinLeaveMessages: true}
m := ch.Auditable()
val, ok := m["disable_join_leave_messages"]
require.True(t, ok)
require.Equal(t, true, val)
}
func TestChannelPatchAuditableIncludesDisableJoinLeaveMessages(t *testing.T) {
val := true
cp := &ChannelPatch{DisableJoinLeaveMessages: &val}
m := cp.Auditable()
_, ok := m["disable_join_leave_messages"]
require.True(t, ok)
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ChannelView struct {
ChannelId string `json:"channel_id"`
PrevChannelId string `json:"prev_channel_id"`
CollapsedThreadsSupported bool `json:"collapsed_threads_supported"`
}
type ChannelViewResponse struct {
Status string `json:"status"`
LastViewedAtTimes map[string]int64 `json:"last_viewed_at_times"`
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
package model
import (
"net/url"
"strings"
)
type clientRoute struct {
segments []string
}
func cleanSegment(segment string) string {
s := strings.ReplaceAll(segment, "..", "")
return url.PathEscape(s)
}
func newClientRoute(segment string) clientRoute {
return clientRoute{}.Join(segment)
}
func (r clientRoute) Join(segments ...any) clientRoute {
for _, segment := range segments {
if s, ok := segment.(string); ok {
r.segments = append(r.segments, cleanSegment(s))
}
if s, ok := segment.(clientRoute); ok {
r.segments = append(r.segments, s.segments...)
}
}
return r
}
func (r clientRoute) URL() (*url.URL, error) {
path, err := r.String()
if err != nil {
return nil, err
}
return &url.URL{Path: path}, nil
}
func (r clientRoute) String() (string, error) {
// Make sure that there is a leading slash
return url.JoinPath("/", strings.Join(r.segments, "/"))
}

View File

@@ -0,0 +1,399 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNewClientRoute(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "simple path",
input: "api",
expected: "/api",
},
{
name: "path with special characters",
input: "hello world",
expected: "/hello%20world",
},
{
name: "path with slashes",
input: "api/v4",
expected: "/api%2Fv4",
},
{
name: "empty string",
input: "",
expected: "/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
route := newClientRoute(tt.input)
result, err := route.String()
require.NoError(t, err)
require.Equal(t, tt.expected, result)
})
}
}
func TestClientRouteJoinRoute(t *testing.T) {
tests := []struct {
name string
base clientRoute
join clientRoute
expected string
wantErr bool
}{
{
name: "join two valid routes",
base: newClientRoute("api"),
join: newClientRoute("v4"),
expected: "/api/v4",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.base.Join(tt.join)
str, err := result.String()
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, str)
}
})
}
}
func TestClientRouteJoinSegment(t *testing.T) {
tests := []struct {
name string
base clientRoute
segment string
expected string
wantErr bool
}{
{
name: "valid segment",
base: newClientRoute("api"),
segment: "v4",
expected: "/api/v4",
wantErr: false,
},
{
name: "segment with spaces",
base: newClientRoute("api"),
segment: "hello world",
expected: "/api/hello%20world",
wantErr: false,
},
{
name: "segment with slash - escaped",
base: newClientRoute("api"),
segment: "v4/users",
expected: "/api/v4%2Fusers",
wantErr: false,
},
{
name: "empty segment",
base: newClientRoute("api"),
segment: "",
expected: "/api/",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.base.Join(tt.segment)
str, err := result.String()
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, str)
}
})
}
}
func TestClientRouteJoin(t *testing.T) {
tests := []struct {
name string
base clientRoute
segments []any
expected string
wantErr bool
}{
{
name: "multiple valid segments",
base: newClientRoute("api"),
segments: []any{"v4", "users", "me"},
expected: "/api/v4/users/me",
wantErr: false,
},
{
name: "no segments",
base: newClientRoute("api"),
segments: []any{},
expected: "/api",
wantErr: false,
},
{
name: "segment with slash - escaped",
base: newClientRoute("api"),
segments: []any{"v4", "users/me"},
expected: "/api/v4/users%2Fme",
wantErr: false,
},
{
name: "empty segment",
base: newClientRoute("api"),
segments: []any{"v4", "users", "", "me"},
expected: "/api/v4/users/me",
wantErr: false,
},
{
name: "empty segment at the end",
base: newClientRoute("api"),
segments: []any{"v4", "users", ""},
expected: "/api/v4/users/",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.base.Join(tt.segments...)
str, err := result.String()
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, str)
}
})
}
}
func TestClientRouteURL(t *testing.T) {
tests := []struct {
name string
route clientRoute
expectedURL string
wantErr bool
}{
{
name: "simple route",
route: newClientRoute("api").Join(newClientRoute("v4")),
expectedURL: "/api/v4",
wantErr: false,
},
{
name: "empty route",
route: clientRoute{},
expectedURL: "/",
wantErr: false,
},
{
name: "complex route",
route: newClientRoute("api").Join(newClientRoute("v4"), "users"),
expectedURL: "/api/v4/users",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := tt.route.URL()
if tt.wantErr {
require.Error(t, err)
require.Nil(t, result)
} else {
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, tt.expectedURL, result.Path)
}
})
}
}
func TestClientRouteString(t *testing.T) {
tests := []struct {
name string
route clientRoute
expected string
wantErr bool
}{
{
name: "simple route",
route: newClientRoute("api"),
expected: "/api",
wantErr: false,
},
{
name: "empty route",
route: clientRoute{},
expected: "/",
wantErr: false,
},
{
name: "complex route",
route: newClientRoute("api").Join(newClientRoute("v4"), "users", "me"),
expected: "/api/v4/users/me",
wantErr: false,
},
{
name: "route with special characters",
route: newClientRoute("api").Join("hello world"),
expected: "/api/hello%20world",
wantErr: false,
},
{
name: "route with special characters",
route: newClientRoute("").Join(".."),
expected: "/",
wantErr: false,
},
{
name: "route with control characters, which make url.JoinPath to error out",
route: newClientRoute("[fe80::1%en0]"),
expected: "/%5Bfe80::1%25en0%5D",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := tt.route.String()
if tt.wantErr {
require.Error(t, err)
require.Equal(t, tt.expected, result)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, result)
}
})
}
}
func TestClientRouteLeadingSlash(t *testing.T) {
tests := []struct {
name string
route clientRoute
}{
{
name: "single segment",
route: newClientRoute("api"),
},
{
name: "multiple segments",
route: newClientRoute("api").Join("v4"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test String() has leading slash
str, err := tt.route.String()
require.NoError(t, err)
require.True(t, len(str) > 0 && str[0] == '/', "String() result should have leading slash")
// Test URL() has leading slash
u, err := tt.route.URL()
require.NoError(t, err)
require.True(t, len(u.Path) > 0 && u.Path[0] == '/', "URL() result path should have leading slash")
})
}
}
func TestClean(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "simple string",
input: "api",
expected: "api",
},
{
name: "string with spaces",
input: "hello world",
expected: "hello%20world",
},
{
name: "string with slashes",
input: "api/v4",
expected: "api%2Fv4",
},
{
name: "just two dots",
input: "..",
expected: "",
},
{
name: "two dots at start",
input: "../api",
expected: "%2Fapi",
},
{
name: "two dots at end",
input: "api/..",
expected: "api%2F",
},
{
name: "two dots in middle",
input: "api/../v4",
expected: "api%2F%2Fv4",
},
{
name: "multiple two dots",
input: "../../api",
expected: "%2F%2Fapi",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "special characters",
input: "user@example.com",
expected: "user@example.com",
},
{
name: "single dot",
input: ".",
expected: ".",
},
{
name: "three dots",
input: "...",
expected: ".",
},
{
name: "four dots",
input: "....",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleanSegment(tt.input)
require.Equal(t, tt.expected, result)
})
}
}

View File

@@ -0,0 +1,442 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model_test
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// https://github.com/mattermost/mattermost-plugin-starter-template/issues/115
func TestClient4TrimTrailingSlash(t *testing.T) {
slashes := []int{0, 1, 5}
baseURL := "https://foo.com:1234"
for _, s := range slashes {
testURL := baseURL + strings.Repeat("/", s)
client := model.NewAPIv4Client(testURL)
assert.Equal(t, baseURL, client.URL)
assert.Equal(t, baseURL+model.APIURLSuffix, client.APIURL)
}
}
// https://github.com/mattermost/mattermost/server/v8/channels/issues/8205
func TestClient4CreatePost(t *testing.T) {
post := &model.Post{
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Integration: &model.PostActionIntegration{
Context: map[string]any{
"foo": "bar",
},
URL: "http://foo.com",
},
Name: "Foo",
},
},
},
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var post model.Post
err := json.NewDecoder(r.Body).Decode(&post)
assert.NoError(t, err)
attachments := post.Attachments()
assert.Equal(t, []*model.MessageAttachment{
{
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Integration: &model.PostActionIntegration{
Context: map[string]any{
"foo": "bar",
},
URL: "http://foo.com",
},
Name: "Foo",
},
},
},
}, attachments)
err = json.NewEncoder(w).Encode(&post)
assert.NoError(t, err)
}))
client := model.NewAPIv4Client(server.URL)
_, resp, err := client.CreatePost(context.Background(), post)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestClient4SetToken(t *testing.T) {
expected := model.NewId()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get(model.HeaderAuth)
token := strings.Split(authHeader, model.HeaderBearer)
if len(token) < 2 {
t.Errorf("wrong authorization header format, got %s, expected: %s %s", authHeader, model.HeaderBearer, expected)
}
assert.Equal(t, expected, strings.TrimSpace(token[1]))
var user model.User
err := json.NewEncoder(w).Encode(&user)
assert.NoError(t, err)
}))
client := model.NewAPIv4Client(server.URL)
client.SetToken(expected)
_, resp, err := client.GetMe(context.Background(), "")
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestClient4RequestCancellation(t *testing.T) {
t.Run("cancel before making the reqeust", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not hit the server")
}))
client := model.NewAPIv4Client(server.URL)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, resp, err := client.GetMe(ctx, "")
assert.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
assert.Nil(t, resp)
})
t.Run("cancel after making the reqeust", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
t.Fatal("request should not hit the server")
}))
client := model.NewAPIv4Client(server.URL)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_, resp, err := client.GetMe(ctx, "")
assert.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
assert.Nil(t, resp)
done <- struct{}{}
}()
cancel()
<-done
})
}
func TestClient4LookupInteractiveDialog(t *testing.T) {
expectedResponse := model.LookupDialogResponse{
Items: []model.DialogSelectOption{
{Text: "Option 1", Value: "value1"},
{Text: "Option 2", Value: "value2"},
{Text: "Option 3", Value: "value3"},
},
}
t.Run("should successfully perform lookup request", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request method and endpoint
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v4/actions/dialogs/lookup", r.URL.Path)
// Decode and verify request body
var submission model.SubmitDialogRequest
err := json.NewDecoder(r.Body).Decode(&submission)
assert.NoError(t, err)
assert.Equal(t, "test_callback", submission.CallbackId)
assert.Equal(t, "https://example.com/lookup", submission.URL)
assert.Equal(t, "test_state", submission.State)
// Send response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(expectedResponse)
assert.NoError(t, err)
}))
defer server.Close()
client := model.NewAPIv4Client(server.URL)
submission := &model.SubmitDialogRequest{
CallbackId: "test_callback",
URL: "https://example.com/lookup",
State: "test_state",
UserId: "test_user_id",
ChannelId: "test_channel_id",
TeamId: "test_team_id",
Submission: map[string]any{
"selected_field": "dynamic_field",
"query": "search_term",
},
}
response, resp, err := client.LookupInteractiveDialog(context.Background(), *submission)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, expectedResponse, *response)
assert.Len(t, response.Items, 3)
assert.Equal(t, "Option 1", response.Items[0].Text)
assert.Equal(t, "value1", response.Items[0].Value)
})
t.Run("should handle empty response", func(t *testing.T) {
emptyResponse := model.LookupDialogResponse{Items: []model.DialogSelectOption{}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(emptyResponse)
assert.NoError(t, err)
}))
defer server.Close()
client := model.NewAPIv4Client(server.URL)
submission := &model.SubmitDialogRequest{
CallbackId: "test_callback",
URL: "https://example.com/lookup",
}
response, resp, err := client.LookupInteractiveDialog(context.Background(), *submission)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, emptyResponse, *response)
assert.Empty(t, response.Items)
})
t.Run("should handle server error response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
errorResponse := model.AppError{
Id: "api.dialog.lookup.bad_request",
Message: "Invalid request parameters",
}
err := json.NewEncoder(w).Encode(errorResponse)
assert.NoError(t, err)
}))
defer server.Close()
client := model.NewAPIv4Client(server.URL)
submission := &model.SubmitDialogRequest{
CallbackId: "invalid_callback",
URL: "invalid_url",
}
response, resp, err := client.LookupInteractiveDialog(context.Background(), *submission)
assert.Error(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Nil(t, response)
// Verify error is an AppError
appError, ok := err.(*model.AppError)
assert.True(t, ok)
assert.Equal(t, "api.dialog.lookup.bad_request", appError.Id)
})
t.Run("should handle network connectivity issues", func(t *testing.T) {
// Use an invalid URL to simulate network failure
client := model.NewAPIv4Client("http://invalid-server-url:9999")
submission := &model.SubmitDialogRequest{
CallbackId: "test_callback",
URL: "https://example.com/lookup",
}
response, resp, err := client.LookupInteractiveDialog(context.Background(), *submission)
assert.Error(t, err)
assert.Nil(t, resp)
assert.Nil(t, response)
})
t.Run("should handle request cancellation", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate slow response
time.Sleep(200 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := model.NewAPIv4Client(server.URL)
submission := &model.SubmitDialogRequest{
CallbackId: "test_callback",
URL: "https://example.com/lookup",
}
ctx, cancel := context.WithCancel(context.Background())
// Cancel the request immediately
cancel()
response, resp, err := client.LookupInteractiveDialog(ctx, *submission)
assert.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
assert.Nil(t, resp)
assert.Nil(t, response)
})
t.Run("should handle invalid JSON response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Send invalid JSON
_, err := w.Write([]byte(`{"items": [invalid json}`))
assert.NoError(t, err)
}))
defer server.Close()
client := model.NewAPIv4Client(server.URL)
submission := &model.SubmitDialogRequest{
CallbackId: "test_callback",
URL: "https://example.com/lookup",
}
response, resp, err := client.LookupInteractiveDialog(context.Background(), *submission)
assert.Error(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Nil(t, response)
})
t.Run("should properly set authorization header when token is provided", func(t *testing.T) {
expectedToken := model.NewId()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get(model.HeaderAuth)
expectedAuthHeader := model.HeaderBearer + " " + expectedToken
assert.Equal(t, expectedAuthHeader, authHeader)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(expectedResponse)
assert.NoError(t, err)
}))
defer server.Close()
client := model.NewAPIv4Client(server.URL)
client.SetToken(expectedToken)
submission := &model.SubmitDialogRequest{
CallbackId: "test_callback",
URL: "https://example.com/lookup",
}
response, resp, err := client.LookupInteractiveDialog(context.Background(), *submission)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, expectedResponse, *response)
})
}
func ExampleClient4_GetUsers() {
client := model.NewAPIv4Client("http://localhost:8065")
client.SetToken(os.Getenv("MM_TOKEN"))
const perPage = 100
var page int
for {
users, _, err := client.GetUsers(context.TODO(), page, perPage, "")
if err != nil {
log.Printf("error fetching users: %v", err)
return
}
for _, u := range users {
fmt.Printf("%s\n", u.Username)
}
if len(users) < perPage {
break
}
page++
}
}
func TestBuildResponse(t *testing.T) {
t.Run("handles nil http.Response", func(t *testing.T) {
response := model.BuildResponse(nil)
assert.Nil(t, response)
})
t.Run("builds response from http.Response", func(t *testing.T) {
httpResp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
}
httpResp.Header.Set(model.HeaderRequestId, "test-request-id")
httpResp.Header.Set(model.HeaderEtagServer, "test-etag")
httpResp.Header.Set(model.HeaderVersionId, "test-version")
response := model.BuildResponse(httpResp)
require.NotNil(t, response)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.Equal(t, "test-request-id", response.RequestId)
assert.Equal(t, "test-etag", response.Etag)
assert.Equal(t, "test-version", response.ServerVersion)
assert.Equal(t, httpResp.Header, response.Header)
})
t.Run("handles response with empty headers", func(t *testing.T) {
httpResp := &http.Response{
StatusCode: http.StatusNoContent,
Header: http.Header{},
}
response := model.BuildResponse(httpResp)
require.NotNil(t, response)
assert.Equal(t, http.StatusNoContent, response.StatusCode)
assert.Empty(t, response.RequestId)
assert.Empty(t, response.Etag)
assert.Empty(t, response.ServerVersion)
assert.Equal(t, httpResp.Header, response.Header)
})
}
// TestGetUsersNotInChannelWithOptions_NilOptions is a lightweight regression
// test for a nil-pointer dereference in GetUsersNotInChannelWithOptions.
// Before the fix, options.Etag was accessed outside the nil guard, so passing
// nil options caused a panic. This test does not need a real server — the
// panic happens in the client before any HTTP request is made.
func TestGetUsersNotInChannelWithOptions_NilOptions(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, "[]")
}))
defer server.Close()
client := model.NewAPIv4Client(server.URL)
require.NotPanics(t, func() {
_, _, _ = client.GetUsersNotInChannelWithOptions(context.Background(), "somechannelid", nil)
})
}

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"strings"
"time"
)
const (
EventTypeFailedPayment = "failed-payment"
EventTypeFailedPaymentNoCard = "failed-payment-no-card"
EventTypeSendAdminWelcomeEmail = "send-admin-welcome-email"
EventTypeSendUpgradeConfirmationEmail = "send-upgrade-confirmation-email"
EventTypeSubscriptionChanged = "subscription-changed"
EventTypeTriggerDelinquencyEmail = "trigger-delinquency-email"
)
const UpcomingInvoice = "upcoming"
var MockCWS string
type BillingScheme string
const (
BillingSchemePerSeat = BillingScheme("per_seat")
BillingSchemeFlatFee = BillingScheme("flat_fee")
BillingSchemeSalesServe = BillingScheme("sales_serve")
)
type BillingType string
const (
BillingTypeLicensed = BillingType("licensed")
BillingTypeInternal = BillingType("internal")
)
type RecurringInterval string
const (
RecurringIntervalYearly = RecurringInterval("year")
RecurringIntervalMonthly = RecurringInterval("month")
)
type SubscriptionFamily string
const (
SubscriptionFamilyCloud = SubscriptionFamily("cloud")
SubscriptionFamilyOnPrem = SubscriptionFamily("on-prem")
)
type ProductSku string
const (
SkuStarterGov = ProductSku("starter-gov")
SkuProfessionalGov = ProductSku("professional-gov")
SkuEnterpriseGov = ProductSku("enterprise-gov")
SkuStarter = ProductSku("starter")
SkuProfessional = ProductSku("professional")
SkuEnterprise = ProductSku("enterprise")
SkuCloudStarter = ProductSku("cloud-starter")
SkuCloudProfessional = ProductSku("cloud-professional")
SkuCloudEnterprise = ProductSku("cloud-enterprise")
)
// Product model represents a product on the cloud system.
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
PricePerSeat float64 `json:"price_per_seat"`
AddOns []*AddOn `json:"add_ons"`
SKU string `json:"sku"`
PriceID string `json:"price_id"`
Family SubscriptionFamily `json:"product_family"`
RecurringInterval RecurringInterval `json:"recurring_interval"`
BillingScheme BillingScheme `json:"billing_scheme"`
CrossSellsTo string `json:"cross_sells_to"`
}
type UserFacingProduct struct {
ID string `json:"id"`
Name string `json:"name"`
SKU string `json:"sku"`
PricePerSeat float64 `json:"price_per_seat"`
RecurringInterval RecurringInterval `json:"recurring_interval"`
CrossSellsTo string `json:"cross_sells_to"`
}
// AddOn represents an addon to a product.
type AddOn struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
PricePerSeat float64 `json:"price_per_seat"`
}
// StripeSetupIntent represents the SetupIntent model from Stripe for updating payment methods.
type StripeSetupIntent struct {
ID string `json:"id"`
ClientSecret string `json:"client_secret"`
}
// ConfirmPaymentMethodRequest contains the fields for the customer payment update API.
type ConfirmPaymentMethodRequest struct {
StripeSetupIntentID string `json:"stripe_setup_intent_id"`
SubscriptionID string `json:"subscription_id"`
}
// Customer model represents a customer on the system.
type CloudCustomer struct {
CloudCustomerInfo
ID string `json:"id"`
CreatorID string `json:"creator_id"`
CreateAt int64 `json:"create_at"`
BillingAddress *Address `json:"billing_address"`
CompanyAddress *Address `json:"company_address"`
PaymentMethod *PaymentMethod `json:"payment_method"`
}
type StartCloudTrialRequest struct {
Email string `json:"email"`
SubscriptionID string `json:"subscription_id"`
}
type ValidateBusinessEmailRequest struct {
Email string `json:"email"`
}
type ValidateBusinessEmailResponse struct {
IsValid bool `json:"is_valid"`
}
type SubscriptionLicenseSelfServeStatusResponse struct {
IsExpandable bool `json:"is_expandable"`
IsRenewable bool `json:"is_renewable"`
}
// CloudCustomerInfo represents editable info of a customer.
type CloudCustomerInfo struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
ContactFirstName string `json:"contact_first_name,omitempty"`
ContactLastName string `json:"contact_last_name,omitempty"`
NumEmployees int `json:"num_employees"`
CloudAltPaymentMethod string `json:"monthly_subscription_alt_payment_method"`
}
// Address model represents a customer's address.
type Address struct {
City string `json:"city"`
Country string `json:"country"`
Line1 string `json:"line1"`
Line2 string `json:"line2"`
PostalCode string `json:"postal_code"`
State string `json:"state"`
}
// PaymentMethod represents methods of payment for a customer.
type PaymentMethod struct {
Type string `json:"type"`
LastFour string `json:"last_four"`
ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"`
CardBrand string `json:"card_brand"`
Name string `json:"name"`
}
// Subscription model represents a subscription on the system.
type Subscription struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
ProductID string `json:"product_id"`
AddOns []string `json:"add_ons"`
StartAt int64 `json:"start_at"`
EndAt int64 `json:"end_at"`
CreateAt int64 `json:"create_at"`
Seats int `json:"seats"`
Status string `json:"status"`
DNS string `json:"dns"`
LastInvoice *Invoice `json:"last_invoice"`
UpcomingInvoice *Invoice `json:"upcoming_invoice"`
IsFreeTrial string `json:"is_free_trial"`
TrialEndAt int64 `json:"trial_end_at"`
DelinquentSince *int64 `json:"delinquent_since"`
OriginallyLicensedSeats int `json:"originally_licensed_seats"`
ComplianceBlocked string `json:"compliance_blocked"`
BillingType string `json:"billing_type"`
CancelAt *int64 `json:"cancel_at"`
WillRenew string `json:"will_renew"`
SimulatedCurrentTimeMs *int64 `json:"simulated_current_time_ms"`
IsCloudPreview bool `json:"is_cloud_preview"`
}
func (s *Subscription) DaysToExpiration() int64 {
now := time.Now().UnixMilli()
if GetServiceEnvironment() == ServiceEnvironmentTest {
// In the test environment we have test clocks. A test clock is a ms timestamp
// If it's not nil, we use it as the current time in all calculations
if s.SimulatedCurrentTimeMs != nil {
now = *s.SimulatedCurrentTimeMs
}
}
daysToExpiry := (s.EndAt - now) / (1000 * 60 * 60 * 24)
return daysToExpiry
}
// Subscription History model represents true up event in a yearly subscription
type SubscriptionHistory struct {
ID string `json:"id"`
SubscriptionID string `json:"subscription_id"`
Seats int `json:"seats"`
CreateAt int64 `json:"create_at"`
}
type SubscriptionHistoryChange struct {
SubscriptionID string `json:"subscription_id"`
Seats int `json:"seats"`
CreateAt int64 `json:"create_at"`
}
// GetWorkSpaceNameFromDNS returns the work space name. For example from test.mattermost.cloud.com, it returns test
func (s *Subscription) GetWorkSpaceNameFromDNS() string {
return strings.Split(s.DNS, ".")[0]
}
// Invoice model represents a cloud invoice
type Invoice struct {
ID string `json:"id"`
Number string `json:"number"`
CreateAt int64 `json:"create_at"`
Total int64 `json:"total"`
Tax int64 `json:"tax"`
Status string `json:"status"`
Description string `json:"description"`
PeriodStart int64 `json:"period_start"`
PeriodEnd int64 `json:"period_end"`
SubscriptionID string `json:"subscription_id"`
Items []*InvoiceLineItem `json:"line_items"`
CurrentProductName string `json:"current_product_name"`
}
// InvoiceLineItem model represents a cloud invoice lineitem tied to an invoice.
type InvoiceLineItem struct {
PriceID string `json:"price_id"`
Total int64 `json:"total"`
Quantity float64 `json:"quantity"`
PricePerUnit int64 `json:"price_per_unit"`
Description string `json:"description"`
Type string `json:"type"`
Metadata map[string]any `json:"metadata"`
PeriodStart int64 `json:"period_start"`
PeriodEnd int64 `json:"period_end"`
}
type DelinquencyEmailTrigger struct {
EmailToTrigger string `json:"email_to_send"`
}
type DelinquencyEmail string
const (
DelinquencyEmail7 DelinquencyEmail = "7"
DelinquencyEmail14 DelinquencyEmail = "14"
DelinquencyEmail30 DelinquencyEmail = "30"
DelinquencyEmail45 DelinquencyEmail = "45"
DelinquencyEmail60 DelinquencyEmail = "60"
DelinquencyEmail75 DelinquencyEmail = "75"
DelinquencyEmail90 DelinquencyEmail = "90"
)
type CWSWebhookPayload struct {
Event string `json:"event"`
FailedPayment *FailedPayment `json:"failed_payment"`
CloudWorkspaceOwner *CloudWorkspaceOwner `json:"cloud_workspace_owner"`
ProductLimits *ProductLimits `json:"product_limits"`
Subscription *Subscription `json:"subscription"`
SubscriptionTrialEndUnixTimeStamp int64 `json:"trial_end_time_stamp"`
DelinquencyEmail *DelinquencyEmailTrigger `json:"delinquency_email"`
}
type FailedPayment struct {
CardBrand string `json:"card_brand"`
LastFour string `json:"last_four"`
FailureMessage string `json:"failure_message"`
}
// CloudWorkspaceOwner is part of the CWS Webhook payload that contains information about the user that created the workspace from the CWS
type CloudWorkspaceOwner struct {
UserName string `json:"username"`
}
type SubscriptionChange struct {
ProductID string `json:"product_id"`
Seats int `json:"seats"`
Feedback *Feedback `json:"downgrade_feedback"`
ShippingAddress *Address `json:"shipping_address"`
Customer *CloudCustomerInfo `json:"customer"`
}
type FilesLimits struct {
TotalStorage *int64 `json:"total_storage"`
}
type MessagesLimits struct {
History *int `json:"history"`
}
type TeamsLimits struct {
Active *int `json:"active"`
}
type ProductLimits struct {
Files *FilesLimits `json:"files,omitempty"`
Messages *MessagesLimits `json:"messages,omitempty"`
Teams *TeamsLimits `json:"teams,omitempty"`
}
// CreateSubscriptionRequest is the parameters for the API request to create a subscription.
type CreateSubscriptionRequest struct {
ProductID string `json:"product_id"`
AddOns []string `json:"add_ons"`
Seats int `json:"seats"`
Total float64 `json:"total"`
InternalPurchaseOrder string `json:"internal_purchase_order"`
DiscountID string `json:"discount_id"`
}
type Installation struct {
ID string `json:"id"`
State string `json:"state"`
AllowedIPRanges *AllowedIPRanges `json:"allowed_ip_ranges"`
}
type Feedback struct {
Reason string `json:"reason"`
Comments string `json:"comments"`
}
type WorkspaceDeletionRequest struct {
SubscriptionID string `json:"subscription_id"`
Feedback *Feedback `json:"delete_feedback"`
}
// MessageDescriptor represents an i18n message descriptor
type MessageDescriptor struct {
ID string `json:"id"`
DefaultMessage string `json:"defaultMessage"`
Values map[string]any `json:"values,omitempty"`
}
// PreviewModalContentData represents the structure of modal content data from S3
type PreviewModalContentData struct {
SKULabel MessageDescriptor `json:"skuLabel"`
Title MessageDescriptor `json:"title"`
Subtitle MessageDescriptor `json:"subtitle"`
VideoURL string `json:"videoUrl"`
VideoPoster string `json:"videoPoster,omitempty"`
UseCase string `json:"useCase"`
}
func (p *Product) IsYearly() bool {
return p.RecurringInterval == RecurringIntervalYearly
}
func (p *Product) IsMonthly() bool {
return p.RecurringInterval == RecurringIntervalMonthly
}
func (df *Feedback) ToMap() map[string]any {
var res map[string]any
feedback, err := json.Marshal(df)
if err != nil {
return res
}
err = json.Unmarshal(feedback, &res)
if err != nil {
return res
}
return res
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"os"
)
const (
CDSOfflineAfterMillis = 1000 * 60 * 30 // 30 minutes
CDSTypeApp = "mattermost_app"
)
type ClusterDiscovery struct {
Id string `json:"id"`
Type string `json:"type"`
ClusterName string `json:"cluster_name"`
Hostname string `json:"hostname"`
GossipPort int32 `json:"gossip_port"`
Port int32 `json:"port"` // Deperacted: Port is unused. It's only kept for backwards compatibility.
CreateAt int64 `json:"create_at"`
LastPingAt int64 `json:"last_ping_at"`
}
func (o *ClusterDiscovery) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
if o.CreateAt == 0 {
o.CreateAt = GetMillis()
o.LastPingAt = o.CreateAt
}
}
func (o *ClusterDiscovery) AutoFillHostname() {
// attempt to set the hostname from the OS
if o.Hostname == "" {
if hn, err := os.Hostname(); err == nil {
o.Hostname = hn
}
}
}
func (o *ClusterDiscovery) AutoFillIPAddress(iface string, ipAddress string) {
// attempt to set the hostname to the first non-local IP address
if o.Hostname == "" {
if ipAddress != "" {
o.Hostname = ipAddress
} else {
o.Hostname = GetServerIPAddress(iface)
}
}
}
func (o *ClusterDiscovery) IsEqual(in *ClusterDiscovery) bool {
if in == nil {
return false
}
if o.Type != in.Type {
return false
}
if o.ClusterName != in.ClusterName {
return false
}
if o.Hostname != in.Hostname {
return false
}
return true
}
func FilterClusterDiscovery(vs []*ClusterDiscovery, f func(*ClusterDiscovery) bool) []*ClusterDiscovery {
cdCopy := make([]*ClusterDiscovery, 0)
for _, v := range vs {
if f(v) {
cdCopy = append(cdCopy, v)
}
}
return cdCopy
}
func (o *ClusterDiscovery) IsValid() *AppError {
if !IsValidId(o.Id) {
return NewAppError("ClusterDiscovery.IsValid", "model.cluster.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if o.ClusterName == "" {
return NewAppError("ClusterDiscovery.IsValid", "model.cluster.is_valid.name.app_error", nil, "", http.StatusBadRequest)
}
if o.Type == "" {
return NewAppError("ClusterDiscovery.IsValid", "model.cluster.is_valid.type.app_error", nil, "", http.StatusBadRequest)
}
if o.Hostname == "" {
return NewAppError("ClusterDiscovery.IsValid", "model.cluster.is_valid.hostname.app_error", nil, "", http.StatusBadRequest)
}
if o.CreateAt == 0 {
return NewAppError("ClusterDiscovery.IsValid", "model.cluster.is_valid.create_at.app_error", nil, "", http.StatusBadRequest)
}
if o.LastPingAt == 0 {
return NewAppError("ClusterDiscovery.IsValid", "model.cluster.is_valid.last_ping_at.app_error", nil, "", http.StatusBadRequest)
}
return nil
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestClusterDiscovery(t *testing.T) {
o := ClusterDiscovery{
Type: "test_type",
ClusterName: "cluster_name",
Hostname: "test_hostname",
}
result1 := o
result2 := o
result3 := o
o.Id = "0"
result1.Id = "1"
result2.Id = "2"
result3.Id = "3"
result3.Hostname = "something_diff"
assert.True(t, o.IsEqual(&result1))
list := make([]*ClusterDiscovery, 0)
list = append(list, &o)
list = append(list, &result1)
list = append(list, &result2)
list = append(list, &result3)
rlist := FilterClusterDiscovery(list, func(in *ClusterDiscovery) bool {
return !o.IsEqual(in)
})
assert.Len(t, rlist, 1)
o.AutoFillHostname()
o.Hostname = ""
o.AutoFillHostname()
o.AutoFillIPAddress("", "")
o.Hostname = ""
o.AutoFillIPAddress("", "")
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ClusterInfo struct {
Id string `json:"id"`
Version string `json:"version"`
SchemaVersion string `json:"schema_version"`
ConfigHash string `json:"config_hash"`
IPAddress string `json:"ipaddress"`
Hostname string `json:"hostname"`
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type ClusterEvent string
const (
ClusterEventNone ClusterEvent = "none"
ClusterEventPublish ClusterEvent = "publish"
ClusterEventUpdateStatus ClusterEvent = "update_status"
ClusterEventInvalidateAllCaches ClusterEvent = "inv_all_caches"
ClusterEventInvalidateCacheForReactions ClusterEvent = "inv_reactions"
ClusterEventInvalidateCacheForChannelMembersNotifyProps ClusterEvent = "inv_channel_members_notify_props"
ClusterEventInvalidateCacheForChannelByName ClusterEvent = "inv_channel_name"
ClusterEventInvalidateCacheForChannel ClusterEvent = "inv_channel"
ClusterEventInvalidateCacheForChannelGuestCount ClusterEvent = "inv_channel_guest_count"
ClusterEventInvalidateCacheForUser ClusterEvent = "inv_user"
ClusterEventInvalidateWebConnCacheForUser ClusterEvent = "inv_user_teams"
ClusterEventClearSessionCacheForUser ClusterEvent = "clear_session_user"
ClusterEventInvalidateCacheForRoles ClusterEvent = "inv_roles"
ClusterEventInvalidateCacheForRolePermissions ClusterEvent = "inv_role_permissions"
ClusterEventInvalidateCacheForProfileByIds ClusterEvent = "inv_profile_ids"
ClusterEventInvalidateCacheForAllProfiles ClusterEvent = "inv_all_profiles"
ClusterEventInvalidateCacheForProfileInChannel ClusterEvent = "inv_profile_in_channel"
ClusterEventInvalidateCacheForSchemes ClusterEvent = "inv_schemes"
ClusterEventInvalidateCacheForFileInfos ClusterEvent = "inv_file_infos"
ClusterEventInvalidateCacheForWebhooks ClusterEvent = "inv_webhooks"
ClusterEventInvalidateCacheForEmojisById ClusterEvent = "inv_emojis_by_id"
ClusterEventInvalidateCacheForEmojisIdByName ClusterEvent = "inv_emojis_id_by_name"
ClusterEventInvalidateCacheForChannelFileCount ClusterEvent = "inv_channel_file_count"
ClusterEventInvalidateCacheForChannelPinnedpostsCounts ClusterEvent = "inv_channel_pinnedposts_counts"
ClusterEventInvalidateCacheForChannelMemberCounts ClusterEvent = "inv_channel_member_counts"
ClusterEventInvalidateCacheForChannelsMemberCount ClusterEvent = "inv_channels_member_count"
ClusterEventInvalidateCacheForLastPosts ClusterEvent = "inv_last_posts"
ClusterEventInvalidateCacheForLastPostTime ClusterEvent = "inv_last_post_time"
ClusterEventInvalidateCacheForPostsUsage ClusterEvent = "inv_posts_usage"
ClusterEventInvalidateCacheForTeams ClusterEvent = "inv_teams"
ClusterEventInvalidateCacheForContentFlagging ClusterEvent = "inv_content_flagging"
ClusterEventInvalidateCacheForDeliveryTracking ClusterEvent = "inv_delivery_tracking"
ClusterEventInvalidateCacheForSessionAttributes ClusterEvent = "inv_session_attributes"
ClusterEventUpdateSessionAttributes ClusterEvent = "update_session_attributes"
ClusterEventInvalidateCacheForPropertyFields ClusterEvent = "inv_property_fields"
ClusterEventInvalidateCacheForAutoTranslation ClusterEvent = "inv_autotranslation"
ClusterEventInvalidateCacheForReadReceipts ClusterEvent = "inv_read_receipts"
ClusterEventInvalidateCacheForTemporaryPosts ClusterEvent = "inv_temporary_posts"
ClusterEventClearSessionCacheForAllUsers ClusterEvent = "inv_all_user_sessions"
ClusterEventInstallPlugin ClusterEvent = "install_plugin"
ClusterEventRemovePlugin ClusterEvent = "remove_plugin"
ClusterEventPluginEvent ClusterEvent = "plugin_event"
ClusterEventInvalidateCacheForTermsOfService ClusterEvent = "inv_terms_of_service"
ClusterEventInvalidateCacheForUserAutoTranslation ClusterEvent = "inv_user_autotranslation"
ClusterEventInvalidateCacheForPostTranslationEtag ClusterEvent = "inv_post_translation_etag"
ClusterEventInvalidateCacheForAccessControlPolicyEtag ClusterEvent = "inv_access_control_policy_etag"
ClusterEventInvalidateCacheForUserPropertyValuesEpoch ClusterEvent = "inv_user_property_values_epoch"
ClusterEventAutoTranslationTask ClusterEvent = "autotranslation_task"
ClusterEventBusyStateChanged ClusterEvent = "busy_state_change"
// Note: if you are adding a new event, please also add it in the slice of
// m.ClusterEventMap in metrics/metrics.go file.
// Gossip communication
ClusterGossipEventRequestGetLogs = "gossip_request_get_logs"
ClusterGossipEventResponseGetLogs = "gossip_response_get_logs"
ClusterGossipEventRequestGenerateSupportPacket = "gossip_request_generate_support_packet"
ClusterGossipEventResponseGenerateSupportPacket = "gossip_response_generate_support_packet"
ClusterGossipEventRequestGetClusterStats = "gossip_request_cluster_stats"
ClusterGossipEventResponseGetClusterStats = "gossip_response_cluster_stats"
ClusterGossipEventRequestGetPluginStatuses = "gossip_request_plugin_statuses"
ClusterGossipEventResponseGetPluginStatuses = "gossip_response_plugin_statuses"
ClusterGossipEventRequestSaveConfig = "gossip_request_save_config"
ClusterGossipEventResponseSaveConfig = "gossip_response_save_config"
ClusterGossipEventRequestWebConnCount = "gossip_request_webconn_count"
ClusterGossipEventResponseWebConnCount = "gossip_response_webconn_count"
ClusterGossipEventRequestWSQueues = "gossip_request_ws_queues"
ClusterGossipEventResponseWSQueues = "gossip_response_ws_queues"
// SendTypes for ClusterMessage.
ClusterSendBestEffort = "best_effort"
ClusterSendReliable = "reliable"
)
type ClusterMessage struct {
Event ClusterEvent `json:"event"`
SendType string `json:"-"`
WaitForAllToSend bool `json:"-"`
Data []byte `json:"data,omitempty"`
Props map[string]string `json:"props,omitempty"`
}
// LogFields returns structured log fields describing the message.
// For ClusterEventPublish, it partially unmarshals Data to extract WebSocket
// event context. This is intentionally called only on error paths.
func (m *ClusterMessage) LogFields() []mlog.Field {
fields := []mlog.Field{
mlog.String("event", string(m.Event)),
mlog.String("send_type", m.SendType),
mlog.Int("data_len", len(m.Data)),
}
switch m.Event {
case ClusterEventPublish:
var header struct {
Event string `json:"event"`
Broadcast struct {
ChannelId string `json:"channel_id"`
TeamId string `json:"team_id"`
OmitUsers map[string]bool `json:"omit_users"`
} `json:"broadcast"`
}
if err := json.Unmarshal(m.Data, &header); err == nil {
if header.Event != "" {
fields = append(fields, mlog.String("ws_event", header.Event))
}
if header.Broadcast.ChannelId != "" {
fields = append(fields, mlog.String("channel_id", header.Broadcast.ChannelId))
}
if header.Broadcast.TeamId != "" {
fields = append(fields, mlog.String("team_id", header.Broadcast.TeamId))
}
if n := len(header.Broadcast.OmitUsers); n > 0 {
fields = append(fields, mlog.Int("omit_users_len", n))
}
}
case ClusterEventPluginEvent:
if pluginID := m.Props["PluginID"]; pluginID != "" {
fields = append(fields, mlog.String("plugin_id", pluginID))
}
if eventID := m.Props["EventID"]; eventID != "" {
fields = append(fields, mlog.String("event_id", eventID))
}
}
return fields
}

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// assertLogField asserts that fields contains a field with the given key and value.
// For string fields pass a string value; for integer fields pass an int or int64.
func assertLogField(t *testing.T, fields []mlog.Field, key string, want any) {
t.Helper()
for _, f := range fields {
if f.Key != key {
continue
}
switch w := want.(type) {
case string:
assert.Equal(t, w, f.String, "field %q: wrong string value", key)
case int:
assert.Equal(t, int64(w), f.Integer, "field %q: wrong integer value", key)
case int64:
assert.Equal(t, w, f.Integer, "field %q: wrong integer value", key)
default:
t.Errorf("field %q: unsupported want type %T", key, want)
}
return
}
t.Errorf("field %q not found in log fields: %s", key, formatLogFields(fields))
}
// assertNoLogField asserts that fields does not contain a field with the given key.
func assertNoLogField(t *testing.T, fields []mlog.Field, key string) {
t.Helper()
for _, f := range fields {
if f.Key == key {
t.Errorf("field %q unexpectedly present with value %q / %d", key, f.String, f.Integer)
return
}
}
}
func formatLogFields(fields []mlog.Field) string {
keys := make([]string, len(fields))
for i, f := range fields {
if f.String != "" {
keys[i] = fmt.Sprintf("%s=%q", f.Key, f.String)
} else {
keys[i] = fmt.Sprintf("%s=%d", f.Key, f.Integer)
}
}
return fmt.Sprintf("%v", keys)
}
func TestClusterMessageLogFields(t *testing.T) {
t.Run("always includes base fields", func(t *testing.T) {
data := []byte(`{"user_id":"abc"}`)
fields := (&ClusterMessage{
Event: ClusterEventUpdateStatus,
SendType: ClusterSendReliable,
Data: data,
}).LogFields()
assertLogField(t, fields, "event", string(ClusterEventUpdateStatus))
assertLogField(t, fields, "send_type", ClusterSendReliable)
assertLogField(t, fields, "data_len", int64(len(data)))
})
t.Run("publish extracts ws_event channel_id team_id and omit_users_len", func(t *testing.T) {
data, err := json.Marshal(map[string]any{
"event": "status_change",
"broadcast": map[string]any{
"channel_id": "ch1",
"team_id": "tm1",
"omit_users": map[string]bool{"u1": true, "u2": true, "u3": true},
},
})
require.NoError(t, err)
fields := (&ClusterMessage{
Event: ClusterEventPublish,
SendType: ClusterSendBestEffort,
Data: data,
}).LogFields()
assertLogField(t, fields, "ws_event", "status_change")
assertLogField(t, fields, "channel_id", "ch1")
assertLogField(t, fields, "team_id", "tm1")
assertLogField(t, fields, "omit_users_len", int64(3))
})
t.Run("publish omits channel_id and team_id when empty", func(t *testing.T) {
data, err := json.Marshal(map[string]any{
"event": "typing",
"broadcast": map[string]any{},
})
require.NoError(t, err)
fields := (&ClusterMessage{
Event: ClusterEventPublish,
Data: data,
}).LogFields()
assertNoLogField(t, fields, "channel_id")
assertNoLogField(t, fields, "team_id")
})
t.Run("publish omits omit_users_len when zero", func(t *testing.T) {
data, err := json.Marshal(map[string]any{
"event": "typing",
"broadcast": map[string]any{"channel_id": "ch1"},
})
require.NoError(t, err)
fields := (&ClusterMessage{
Event: ClusterEventPublish,
Data: data,
}).LogFields()
assertNoLogField(t, fields, "omit_users_len")
})
t.Run("publish with invalid data returns only base fields", func(t *testing.T) {
fields := (&ClusterMessage{
Event: ClusterEventPublish,
SendType: ClusterSendBestEffort,
Data: []byte("not valid json"),
}).LogFields()
assert.Len(t, fields, 3)
assertNoLogField(t, fields, "ws_event")
})
t.Run("plugin event extracts plugin_id and event_id", func(t *testing.T) {
fields := (&ClusterMessage{
Event: ClusterEventPluginEvent,
SendType: ClusterSendReliable,
Props: map[string]string{
"PluginID": "com.example.plugin",
"EventID": "my-event",
},
}).LogFields()
assertLogField(t, fields, "plugin_id", "com.example.plugin")
assertLogField(t, fields, "event_id", "my-event")
})
t.Run("plugin event with no props omits plugin fields", func(t *testing.T) {
fields := (&ClusterMessage{
Event: ClusterEventPluginEvent,
SendType: ClusterSendReliable,
}).LogFields()
assertNoLogField(t, fields, "plugin_id")
assertNoLogField(t, fields, "event_id")
})
t.Run("unrelated event type returns only base fields", func(t *testing.T) {
fields := (&ClusterMessage{
Event: ClusterEventInvalidateCacheForChannel,
SendType: ClusterSendReliable,
Data: []byte(`"channel-id"`),
}).LogFields()
assert.Len(t, fields, 3)
})
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ClusterStats struct {
Id string `json:"id"`
TotalWebsocketConnections int `json:"total_websocket_connections"`
TotalReadDbConnections int `json:"total_read_db_connections"`
TotalMasterDbConnections int `json:"total_master_db_connections"`
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"regexp"
"strings"
)
const (
CommandMethodPost = "P"
CommandMethodGet = "G"
MinTriggerLength = 1
MaxTriggerLength = 128
)
var validCommandTriggerChars = regexp.MustCompile(`^[A-Za-z0-9_./-]+$`)
type Command struct {
Id string `json:"id"`
Token string `json:"token"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
CreatorId string `json:"creator_id"`
TeamId string `json:"team_id"`
Trigger string `json:"trigger"`
Method string `json:"method"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
AutoComplete bool `json:"auto_complete"`
AutoCompleteDesc string `json:"auto_complete_desc"`
AutoCompleteHint string `json:"auto_complete_hint"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
URL string `json:"url"`
// PluginId records the id of the plugin that created this Command. If it is blank, the Command
// was not created by a plugin.
PluginId string `json:"plugin_id"`
AutocompleteData *AutocompleteData `db:"-" json:"autocomplete_data,omitempty"`
// AutocompleteIconData is a base64 encoded svg
AutocompleteIconData string `db:"-" json:"autocomplete_icon_data,omitempty"`
}
func (o *Command) Auditable() map[string]any {
return map[string]any{
"id": o.Id,
"create_at": o.CreateAt,
"update_at": o.UpdateAt,
"delete_at": o.DeleteAt,
"creator_id": o.CreatorId,
"team_id": o.TeamId,
"trigger": o.Trigger,
"username": o.Username,
"icon_url": o.IconURL,
"auto_complete": o.AutoComplete,
"auto_complete_desc": o.AutoCompleteDesc,
"auto_complete_hint": o.AutoCompleteHint,
"display_name": o.DisplayName,
"description": o.Description,
"url": o.URL,
}
}
func (o *Command) IsValid() *AppError {
if !IsValidId(o.Id) {
return NewAppError("Command.IsValid", "model.command.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if len(o.Token) != 26 {
return NewAppError("Command.IsValid", "model.command.is_valid.token.app_error", nil, "", http.StatusBadRequest)
}
if o.CreateAt == 0 {
return NewAppError("Command.IsValid", "model.command.is_valid.create_at.app_error", nil, "", http.StatusBadRequest)
}
if o.UpdateAt == 0 {
return NewAppError("Command.IsValid", "model.command.is_valid.update_at.app_error", nil, "", http.StatusBadRequest)
}
// If the CreatorId is blank, this should be a command created by a plugin.
if o.CreatorId == "" && !IsValidPluginId(o.PluginId) {
return NewAppError("Command.IsValid", "model.command.is_valid.plugin_id.app_error", nil, "", http.StatusBadRequest)
}
// If the PluginId is blank, this should be a command associated with a userId.
if o.PluginId == "" && !IsValidId(o.CreatorId) {
return NewAppError("Command.IsValid", "model.command.is_valid.user_id.app_error", nil, "", http.StatusBadRequest)
}
if o.CreatorId != "" && o.PluginId != "" {
return NewAppError("Command.IsValid", "model.command.is_valid.plugin_id.app_error", nil, "command cannot have both a CreatorId and a PluginId", http.StatusBadRequest)
}
if !IsValidId(o.TeamId) {
return NewAppError("Command.IsValid", "model.command.is_valid.team_id.app_error", nil, "", http.StatusBadRequest)
}
if len(o.Trigger) < MinTriggerLength || len(o.Trigger) > MaxTriggerLength || strings.Index(o.Trigger, "/") == 0 || !validCommandTriggerChars.MatchString(o.Trigger) {
return NewAppError("Command.IsValid", "model.command.is_valid.trigger.app_error", nil, "", http.StatusBadRequest)
}
if o.URL == "" || len(o.URL) > 1024 {
return NewAppError("Command.IsValid", "model.command.is_valid.url.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidHTTPURL(o.URL) {
return NewAppError("Command.IsValid", "model.command.is_valid.url_http.app_error", nil, "", http.StatusBadRequest)
}
if !(o.Method == CommandMethodGet || o.Method == CommandMethodPost) {
return NewAppError("Command.IsValid", "model.command.is_valid.method.app_error", nil, "", http.StatusBadRequest)
}
if len(o.DisplayName) > 64 {
return NewAppError("Command.IsValid", "model.command.is_valid.display_name.app_error", nil, "", http.StatusBadRequest)
}
if len(o.Description) > 128 {
return NewAppError("Command.IsValid", "model.command.is_valid.description.app_error", nil, "", http.StatusBadRequest)
}
if o.AutocompleteData != nil {
if err := o.AutocompleteData.IsValid(); err != nil {
return NewAppError("Command.IsValid", "model.command.is_valid.autocomplete_data.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
}
return nil
}
func (o *Command) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
if o.Token == "" {
o.Token = NewId()
}
o.CreateAt = GetMillis()
o.UpdateAt = o.CreateAt
}
func (o *Command) PreUpdate() {
o.UpdateAt = GetMillis()
}
func (o *Command) Sanitize() {
o.Token = ""
o.CreatorId = ""
o.Method = ""
o.URL = ""
o.Username = ""
o.IconURL = ""
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
type CommandArgs struct {
UserId string `json:"user_id"`
ChannelId string `json:"channel_id"`
TeamId string `json:"team_id"`
RootId string `json:"root_id"`
ParentId string `json:"parent_id"`
TriggerId string `json:"trigger_id,omitempty"`
ConnectionId string `json:"connection_id,omitempty"`
Command string `json:"command"`
SiteURL string `json:"-"`
T i18n.TranslateFunc `json:"-"`
UserMentions UserMentionMap `json:"-"`
ChannelMentions ChannelMentionMap `json:"-"`
}
func (o *CommandArgs) Auditable() map[string]any {
return map[string]any{
"user_id": o.UserId,
"channel_id": o.ChannelId,
"team_id": o.TeamId,
"root_id": o.RootId,
"parent_id": o.ParentId,
"trigger_id": o.TriggerId,
"connection_id": o.ConnectionId,
"command": o.Command,
"site_url": o.SiteURL,
}
}
// AddUserMention adds or overrides an entry in UserMentions with name username
// and identifier userId
func (o *CommandArgs) AddUserMention(username, userId string) {
if o.UserMentions == nil {
o.UserMentions = make(UserMentionMap)
}
o.UserMentions[username] = userId
}
// AddChannelMention adds or overrides an entry in ChannelMentions with name
// channelName and identifier channelId
func (o *CommandArgs) AddChannelMention(channelName, channelId string) {
if o.ChannelMentions == nil {
o.ChannelMentions = make(ChannelMentionMap)
}
o.ChannelMentions[channelName] = channelId
}

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestCommandArgs_Auditable(t *testing.T) {
t.Run("includes connection_id in auditable output", func(t *testing.T) {
args := CommandArgs{
UserId: "user-id",
ChannelId: "channel-id",
TeamId: "team-id",
RootId: "root-id",
ParentId: "parent-id",
TriggerId: "trigger-id",
ConnectionId: "connection-id-123",
Command: "/test command",
SiteURL: "http://localhost:8065",
}
auditable := args.Auditable()
require.Equal(t, "user-id", auditable["user_id"])
require.Equal(t, "channel-id", auditable["channel_id"])
require.Equal(t, "team-id", auditable["team_id"])
require.Equal(t, "root-id", auditable["root_id"])
require.Equal(t, "parent-id", auditable["parent_id"])
require.Equal(t, "trigger-id", auditable["trigger_id"])
require.Equal(t, "connection-id-123", auditable["connection_id"])
require.Equal(t, "/test command", auditable["command"])
require.Equal(t, "http://localhost:8065", auditable["site_url"])
})
t.Run("includes empty connection_id when not set", func(t *testing.T) {
args := CommandArgs{
UserId: "user-id",
Command: "/test",
}
auditable := args.Auditable()
require.Equal(t, "", auditable["connection_id"])
})
}
func TestCommandArgs_AddUserMention(t *testing.T) {
fixture := []struct {
args CommandArgs
mentions map[string]string
expected CommandArgs
}{
{
CommandArgs{},
map[string]string{"one": "1"},
CommandArgs{
UserMentions: map[string]string{"one": "1"},
},
},
{
CommandArgs{
ChannelMentions: map[string]string{"channel": "1"},
},
map[string]string{"one": "1"},
CommandArgs{
UserMentions: map[string]string{"one": "1"},
ChannelMentions: map[string]string{"channel": "1"},
},
},
{
CommandArgs{
UserMentions: map[string]string{"one": "1"},
},
map[string]string{"one": "1"},
CommandArgs{
UserMentions: map[string]string{"one": "1"},
},
},
{
CommandArgs{},
map[string]string{"one": "1", "two": "2", "three": "3"},
CommandArgs{
UserMentions: map[string]string{"one": "1", "two": "2", "three": "3"},
},
},
}
for _, data := range fixture {
for name, id := range data.mentions {
data.args.AddUserMention(name, id)
}
require.Equal(t, data.args, data.expected)
}
}
func TestCommandArgs_AddChannelMention(t *testing.T) {
fixture := []struct {
args CommandArgs
mentions map[string]string
expected CommandArgs
}{
{
CommandArgs{},
map[string]string{"one": "1"},
CommandArgs{
ChannelMentions: map[string]string{"one": "1"},
},
},
{
CommandArgs{
UserMentions: map[string]string{"user": "1"},
},
map[string]string{"one": "1"},
CommandArgs{
ChannelMentions: map[string]string{"one": "1"},
UserMentions: map[string]string{"user": "1"},
},
},
{
CommandArgs{
ChannelMentions: map[string]string{"one": "1"},
},
map[string]string{"one": "1"},
CommandArgs{
ChannelMentions: map[string]string{"one": "1"},
},
},
{
CommandArgs{},
map[string]string{"one": "1", "two": "2", "three": "3"},
CommandArgs{
ChannelMentions: map[string]string{"one": "1", "two": "2", "three": "3"},
},
},
}
for _, data := range fixture {
for name, id := range data.mentions {
data.args.AddChannelMention(name, id)
}
require.Equal(t, data.args, data.expected)
}
}

View File

@@ -0,0 +1,401 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"net/url"
"path"
"reflect"
"slices"
"strings"
"github.com/pkg/errors"
)
// AutocompleteArgType describes autocomplete argument type
type AutocompleteArgType string
// Argument types
const (
AutocompleteArgTypeText AutocompleteArgType = "TextInput"
AutocompleteArgTypeStaticList AutocompleteArgType = "StaticList"
AutocompleteArgTypeDynamicList AutocompleteArgType = "DynamicList"
)
// AutocompleteData describes slash command autocomplete information.
type AutocompleteData struct {
// Trigger of the command
Trigger string
// Hint of a command
Hint string
// Text displayed to the user to help with the autocomplete description
HelpText string
// Role of the user who should be able to see the autocomplete info of this command
RoleID string
// Arguments of the command. Arguments can be named or positional.
// If they are positional order in the list matters, if they are named order does not matter.
// All arguments should be either named or positional, no mixing allowed.
Arguments []*AutocompleteArg
// Subcommands of the command
SubCommands []*AutocompleteData
}
// AutocompleteArg describes an argument of the command. Arguments can be named or positional.
// If Name is empty string Argument is positional otherwise it is named argument.
// Named arguments are passed as --Name Argument_Value.
type AutocompleteArg struct {
// Name of the argument
Name string
// Text displayed to the user to help with the autocomplete
HelpText string
// Type of the argument
Type AutocompleteArgType
// Required determines if argument is optional or not.
Required bool
// Actual data of the argument (depends on the Type)
Data any
}
// AutocompleteTextArg describes text user can input as an argument.
type AutocompleteTextArg struct {
// Hint of the input text
Hint string
// Regex pattern to match
Pattern string
}
// AutocompleteListItem describes an item in the AutocompleteStaticListArg.
type AutocompleteListItem struct {
Item string
Hint string
HelpText string
}
// AutocompleteStaticListArg is used to input one of the arguments from the list,
// for example [yes, no], [on, off], and so on.
type AutocompleteStaticListArg struct {
PossibleArguments []AutocompleteListItem
}
// AutocompleteDynamicListArg is used when user wants to download possible argument list from the URL.
type AutocompleteDynamicListArg struct {
FetchURL string
}
// AutocompleteSuggestion describes a single suggestion item sent to the front-end
// Example: for user input `/jira cre` -
// Complete might be `/jira create`
// Suggestion might be `create`,
// Hint might be `[issue text]`,
// Description might be `Create a new Issue`
type AutocompleteSuggestion struct {
// Complete describes completed suggestion
Complete string
// Suggestion describes what user might want to input next
Suggestion string
// Hint describes a hint about the suggested input
Hint string
// Description of the command or a suggestion
Description string
// IconData is base64 encoded svg image
IconData string
}
// NewAutocompleteData returns new Autocomplete data.
func NewAutocompleteData(trigger, hint, helpText string) *AutocompleteData {
return &AutocompleteData{
Trigger: trigger,
Hint: hint,
HelpText: helpText,
RoleID: SystemUserRoleId,
Arguments: []*AutocompleteArg{},
SubCommands: []*AutocompleteData{},
}
}
// AddCommand adds a subcommand to the autocomplete data.
func (ad *AutocompleteData) AddCommand(command *AutocompleteData) {
ad.SubCommands = append(ad.SubCommands, command)
}
// AddTextArgument adds positional AutocompleteArgTypeText argument to the command.
func (ad *AutocompleteData) AddTextArgument(helpText, hint, pattern string) {
ad.AddNamedTextArgument("", helpText, hint, pattern, true)
}
// AddNamedTextArgument adds named AutocompleteArgTypeText argument to the command.
func (ad *AutocompleteData) AddNamedTextArgument(name, helpText, hint, pattern string, required bool) {
argument := AutocompleteArg{
Name: name,
HelpText: helpText,
Type: AutocompleteArgTypeText,
Required: required,
Data: &AutocompleteTextArg{Hint: hint, Pattern: pattern},
}
ad.Arguments = append(ad.Arguments, &argument)
}
// AddStaticListArgument adds positional AutocompleteArgTypeStaticList argument to the command.
func (ad *AutocompleteData) AddStaticListArgument(helpText string, required bool, items []AutocompleteListItem) {
ad.AddNamedStaticListArgument("", helpText, required, items)
}
// AddNamedStaticListArgument adds named AutocompleteArgTypeStaticList argument to the command.
func (ad *AutocompleteData) AddNamedStaticListArgument(name, helpText string, required bool, items []AutocompleteListItem) {
argument := AutocompleteArg{
Name: name,
HelpText: helpText,
Type: AutocompleteArgTypeStaticList,
Required: required,
Data: &AutocompleteStaticListArg{PossibleArguments: items},
}
ad.Arguments = append(ad.Arguments, &argument)
}
// AddDynamicListArgument adds positional AutocompleteArgTypeDynamicList argument to the command.
func (ad *AutocompleteData) AddDynamicListArgument(helpText, url string, required bool) {
ad.AddNamedDynamicListArgument("", helpText, url, required)
}
// AddNamedDynamicListArgument adds named AutocompleteArgTypeDynamicList argument to the command.
func (ad *AutocompleteData) AddNamedDynamicListArgument(name, helpText, url string, required bool) {
argument := AutocompleteArg{
Name: name,
HelpText: helpText,
Type: AutocompleteArgTypeDynamicList,
Required: required,
Data: &AutocompleteDynamicListArg{FetchURL: url},
}
ad.Arguments = append(ad.Arguments, &argument)
}
// Equals method checks if command is the same.
func (ad *AutocompleteData) Equals(command *AutocompleteData) bool {
if !(ad.Trigger == command.Trigger && ad.HelpText == command.HelpText && ad.RoleID == command.RoleID && ad.Hint == command.Hint) {
return false
}
if len(ad.Arguments) != len(command.Arguments) || len(ad.SubCommands) != len(command.SubCommands) {
return false
}
for i := range ad.Arguments {
if !ad.Arguments[i].Equals(command.Arguments[i]) {
return false
}
}
for i := range ad.SubCommands {
if !ad.SubCommands[i].Equals(command.SubCommands[i]) {
return false
}
}
return true
}
// UpdateRelativeURLsForPluginCommands method updates relative urls for plugin commands
func (ad *AutocompleteData) UpdateRelativeURLsForPluginCommands(baseURL *url.URL) error {
for _, arg := range ad.Arguments {
if arg.Type != AutocompleteArgTypeDynamicList {
continue
}
dynamicList, ok := arg.Data.(*AutocompleteDynamicListArg)
if !ok {
return errors.New("Not a proper DynamicList type argument")
}
dynamicListURL, err := url.Parse(dynamicList.FetchURL)
if err != nil {
return errors.Wrapf(err, "FetchURL is not a proper url")
}
if !dynamicListURL.IsAbs() {
absURL := &url.URL{}
*absURL = *baseURL
absURL.Path = path.Join(absURL.Path, dynamicList.FetchURL)
dynamicList.FetchURL = absURL.String()
}
}
for _, command := range ad.SubCommands {
err := command.UpdateRelativeURLsForPluginCommands(baseURL)
if err != nil {
return err
}
}
return nil
}
// IsValid method checks if autocomplete data is valid.
func (ad *AutocompleteData) IsValid() error {
if ad == nil {
return errors.New("No nil commands are allowed in AutocompleteData")
}
if ad.Trigger == "" {
return errors.New("An empty command name in the autocomplete data")
}
if strings.ToLower(ad.Trigger) != ad.Trigger {
return errors.New("Command should be lowercase")
}
roles := []string{SystemAdminRoleId, SystemUserRoleId, ""}
if !slices.Contains(roles, ad.RoleID) {
return errors.New("Wrong role in the autocomplete data")
}
if len(ad.Arguments) > 0 && len(ad.SubCommands) > 0 {
return errors.New("Command can't have arguments and subcommands")
}
if len(ad.Arguments) > 0 {
namedArgumentIndex := -1
for i, arg := range ad.Arguments {
if arg.Name != "" { // it's a named argument
if namedArgumentIndex == -1 { // first named argument
namedArgumentIndex = i
}
} else { // it's a positional argument
if namedArgumentIndex != -1 {
return errors.New("Named argument should not be before positional argument")
}
}
if arg.Type == AutocompleteArgTypeDynamicList {
dynamicList, ok := arg.Data.(*AutocompleteDynamicListArg)
if !ok {
return errors.New("Not a proper DynamicList type argument")
}
_, err := url.Parse(dynamicList.FetchURL)
if err != nil {
return errors.Wrapf(err, "FetchURL is not a proper url")
}
} else if arg.Type == AutocompleteArgTypeStaticList {
staticList, ok := arg.Data.(*AutocompleteStaticListArg)
if !ok {
return errors.New("Not a proper StaticList type argument")
}
for _, arg := range staticList.PossibleArguments {
if arg.Item == "" {
return errors.New("Possible argument name not set in StaticList argument")
}
}
} else if arg.Type == AutocompleteArgTypeText {
if _, ok := arg.Data.(*AutocompleteTextArg); !ok {
return errors.New("Not a proper TextInput type argument")
}
if arg.Name == "" && !arg.Required {
return errors.New("Positional argument can not be optional")
}
}
}
}
for _, command := range ad.SubCommands {
err := command.IsValid()
if err != nil {
return err
}
}
return nil
}
// Equals method checks if argument is the same.
func (a *AutocompleteArg) Equals(arg *AutocompleteArg) bool {
if a.Name != arg.Name ||
a.HelpText != arg.HelpText ||
a.Type != arg.Type ||
a.Required != arg.Required ||
!reflect.DeepEqual(a.Data, arg.Data) {
return false
}
return true
}
// UnmarshalJSON will unmarshal argument
func (a *AutocompleteArg) UnmarshalJSON(b []byte) error {
var arg map[string]any
if err := json.Unmarshal(b, &arg); err != nil {
return errors.Wrapf(err, "Can't unmarshal argument %s", string(b))
}
var ok bool
a.Name, ok = arg["Name"].(string)
if !ok {
return errors.Errorf("No field Name in the argument %s", string(b))
}
a.HelpText, ok = arg["HelpText"].(string)
if !ok {
return errors.Errorf("No field HelpText in the argument %s", string(b))
}
t, ok := arg["Type"].(string)
if !ok {
return errors.Errorf("No field Type in the argument %s", string(b))
}
a.Type = AutocompleteArgType(t)
a.Required, ok = arg["Required"].(bool)
if !ok {
return errors.Errorf("No field Required in the argument %s", string(b))
}
data, ok := arg["Data"]
if !ok {
return errors.Errorf("No field Data in the argument %s", string(b))
}
if a.Type == AutocompleteArgTypeText {
m, ok := data.(map[string]any)
if !ok {
return errors.Errorf("Wrong Data type in the TextInput argument %s", string(b))
}
pattern, ok := m["Pattern"].(string)
if !ok {
return errors.Errorf("No field Pattern in the TextInput argument %s", string(b))
}
hint, ok := m["Hint"].(string)
if !ok {
return errors.Errorf("No field Hint in the TextInput argument %s", string(b))
}
a.Data = &AutocompleteTextArg{Hint: hint, Pattern: pattern}
} else if a.Type == AutocompleteArgTypeStaticList {
m, ok := data.(map[string]any)
if !ok {
return errors.Errorf("Wrong Data type in the StaticList argument %s", string(b))
}
list, ok := m["PossibleArguments"].([]any)
if !ok {
return errors.Errorf("No field PossibleArguments in the StaticList argument %s", string(b))
}
possibleArguments := []AutocompleteListItem{}
for i := range list {
args, ok := list[i].(map[string]any)
if !ok {
return errors.Errorf("Wrong AutocompleteStaticListItem type in the StaticList argument %s", string(b))
}
item, ok := args["Item"].(string)
if !ok {
return errors.Errorf("No field Item in the StaticList's possible arguments %s", string(b))
}
hint, ok := args["Hint"].(string)
if !ok {
return errors.Errorf("No field Hint in the StaticList's possible arguments %s", string(b))
}
helpText, ok := args["HelpText"].(string)
if !ok {
return errors.Errorf("No field Hint in the StaticList's possible arguments %s", string(b))
}
possibleArguments = append(possibleArguments, AutocompleteListItem{
Item: item,
Hint: hint,
HelpText: helpText,
})
}
a.Data = &AutocompleteStaticListArg{PossibleArguments: possibleArguments}
} else if a.Type == AutocompleteArgTypeDynamicList {
m, ok := data.(map[string]any)
if !ok {
return errors.Errorf("Wrong type in the DynamicList argument %s", string(b))
}
url, ok := m["FetchURL"].(string)
if !ok {
return errors.Errorf("No field FetchURL in the DynamicList's argument %s", string(b))
}
a.Data = &AutocompleteDynamicListArg{FetchURL: url}
}
return nil
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAutocompleteData(t *testing.T) {
ad := NewAutocompleteData("jira", "", "Available commands:")
assert.NoError(t, ad.IsValid())
ad.RoleID = "some_id"
assert.Error(t, ad.IsValid())
ad.RoleID = SystemAdminRoleId
assert.NoError(t, ad.IsValid())
ad.AddDynamicListArgument("help", "/some/url", true)
assert.NoError(t, ad.IsValid())
ad.AddNamedTextArgument("name", "help", "[text]", "", true)
assert.NoError(t, ad.IsValid())
ad = getAutocompleteData()
assert.NoError(t, ad.IsValid())
command := NewAutocompleteData("", "", "")
ad.AddCommand(command)
assert.Error(t, ad.IsValid())
ad = getAutocompleteData()
command = NewAutocompleteData("disconnect", "", "disconnect")
command.AddTextArgument("help", "[text]", "")
command.AddNamedTextArgument("some", "help", "[text]", "", true)
ad.AddCommand(command)
assert.NoError(t, ad.IsValid())
ad = getAutocompleteData()
command = NewAutocompleteData("disconnect", "", "disconnect")
command.AddDynamicListArgument("help", "valid_url", true)
ad.AddCommand(command)
assert.NoError(t, ad.IsValid())
ad = getAutocompleteData()
command = NewAutocompleteData("disconnect", "", "disconnect")
command.AddDynamicListArgument("help", "/valid/url", true)
items := []AutocompleteListItem{
{
Hint: "help",
Item: "",
HelpText: "text",
},
}
command.AddStaticListArgument("help", true, items)
ad.AddCommand(command)
assert.Error(t, ad.IsValid())
ad = getAutocompleteData()
ad.AddCommand(nil)
assert.Error(t, ad.IsValid())
ad = getAutocompleteData()
command = NewAutocompleteData("Disconnect", "", "")
ad.AddCommand(command)
assert.Error(t, ad.IsValid())
}
func getAutocompleteData() *AutocompleteData {
ad := NewAutocompleteData("jira", "", "Available commands:")
ad.RoleID = SystemUserRoleId
command := NewAutocompleteData("connect", "", "Connect to mattermost")
command.RoleID = SystemAdminRoleId
items := []AutocompleteListItem{
{
Hint: "arg1",
Item: "help1",
HelpText: "text1",
}, {
Hint: "arg2",
Item: "help2",
HelpText: "text2",
},
}
command.AddStaticListArgument("help", true, items)
command.AddNamedTextArgument("some", "help", "[text]", "", true)
command.AddNamedDynamicListArgument("other", "help", "/other/url", true)
ad.AddCommand(command)
return ad
}
func TestUpdateRelativeURLsForPluginCommands(t *testing.T) {
ad := getAutocompleteData()
baseURL, _ := url.Parse("http://localhost:8065/plugins/com.mattermost.demo-plugin")
err := ad.UpdateRelativeURLsForPluginCommands(baseURL)
assert.NoError(t, err)
arg, ok := ad.SubCommands[0].Arguments[2].Data.(*AutocompleteDynamicListArg)
assert.True(t, ok)
assert.Equal(t, "http://localhost:8065/plugins/com.mattermost.demo-plugin/other/url", arg.FetchURL)
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type CommandMoveRequest struct {
TeamId string `json:"team_id"`
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"github.com/mattermost/mattermost/server/public/utils"
)
const (
CommandResponseTypeInChannel = "in_channel"
CommandResponseTypeEphemeral = "ephemeral"
)
type CommandResponse struct {
ResponseType string `json:"response_type"`
Text string `json:"text"`
Username string `json:"username"`
ChannelId string `json:"channel_id"`
IconURL string `json:"icon_url"`
Type string `json:"type"`
Props StringInterface `json:"props"`
GotoLocation string `json:"goto_location"`
TriggerId string `json:"trigger_id"`
SkipSlackParsing bool `json:"skip_slack_parsing"` // Set to `true` to skip the Slack-compatibility handling of Text.
Attachments []*MessageAttachment `json:"attachments"`
ExtraResponses []*CommandResponse `json:"extra_responses"`
}
func CommandResponseFromHTTPBody(contentType string, body io.Reader) (*CommandResponse, error) {
if strings.TrimSpace(strings.Split(contentType, ";")[0]) == "application/json" {
return CommandResponseFromJSON(body)
}
if b, err := io.ReadAll(body); err == nil {
return CommandResponseFromPlainText(string(b)), nil
}
return nil, nil
}
func CommandResponseFromPlainText(text string) *CommandResponse {
return &CommandResponse{
Text: text,
}
}
func CommandResponseFromJSON(data io.Reader) (*CommandResponse, error) {
b, err := io.ReadAll(data)
if err != nil {
return nil, err
}
var o CommandResponse
err = json.Unmarshal(b, &o)
if err != nil {
return nil, utils.HumanizeJSONError(err, b)
}
o.Attachments = StringifyMessageAttachmentFieldValue(o.Attachments)
for _, resp := range o.ExtraResponses {
if resp == nil {
continue
}
resp.Attachments = StringifyMessageAttachmentFieldValue(resp.Attachments)
}
return &o, nil
}
func (o *CommandResponse) IsValid() *AppError {
// check response type
if o.ResponseType != CommandResponseTypeInChannel && o.ResponseType != CommandResponseTypeEphemeral && o.ResponseType != "" {
return NewAppError("CommandResponse.IsValid", "model.command_response.is_valid.response_type.app_error", nil, "invalid response type", http.StatusBadRequest)
}
// icon URL must be a valid URL if set
if o.IconURL != "" {
u, err := url.ParseRequestURI(o.IconURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return NewAppError("CommandResponse.IsValid", "model.command_response.is_valid.icon_url.app_error", nil, "invalid icon url", http.StatusBadRequest)
}
}
// goto location must be a valid URL if set
if o.GotoLocation != "" {
if _, err := url.ParseRequestURI(o.GotoLocation); err != nil {
return NewAppError("CommandResponse.IsValid", "model.command_response.is_valid.goto_location.app_error", nil, "invalid goto location", http.StatusBadRequest)
}
}
for _, attachment := range o.Attachments {
if attachment == nil {
continue
}
if err := attachment.IsValid(); err != nil {
return NewAppError("CommandResponse.IsValid", "model.command_response.is_valid.attachment.app_error", nil, "invalid attachment", http.StatusBadRequest)
}
}
// recursively validate nested responses
for _, resp := range o.ExtraResponses {
if resp == nil {
continue
}
if err := resp.IsValid(); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,278 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCommandResponseFromHTTPBody(t *testing.T) {
for _, test := range []struct {
ContentType string
Body string
ExpectedText string
}{
{"", "foo", "foo"},
{"text/plain", "foo", "foo"},
{"application/json", `{"text": "foo"}`, "foo"},
{"application/json; charset=utf-8", `{"text": "foo"}`, "foo"},
{"application/json", `{"text": "` + "```" + `haskell\nlet\n\nf1 = [ 3 | a <- [1]]\nf2 = [ 4 | b <- [2]]\nf3 = \\p -> 5\n\nin 1\n` + "```" + `", "skip_slack_parsing": true}`,
"```haskell\nlet\n\nf1 = [ 3 | a <- [1]]\nf2 = [ 4 | b <- [2]]\nf3 = \\p -> 5\n\nin 1\n```",
},
} {
response, err := CommandResponseFromHTTPBody(test.ContentType, strings.NewReader(test.Body))
assert.NoError(t, err)
assert.Equal(t, test.ExpectedText, response.Text)
}
}
func TestCommandResponseFromPlainText(t *testing.T) {
response := CommandResponseFromPlainText("foo")
assert.Equal(t, "foo", response.Text)
}
func TestCommandResponseFromJSON(t *testing.T) {
t.Parallel()
testCases := []struct {
Description string
Json string
ExpectedCommandResponse *CommandResponse
ShouldError bool
ExpectInvalid bool
}{
{
"empty response",
"",
nil,
true,
false,
},
{
"malformed response",
`{"text": }`,
nil,
true,
false,
},
{
"invalid response",
`{"text": "test", "response_type": 5}`,
nil,
true,
false,
},
{
"ephemeral response",
`{
"response_type": "ephemeral",
"text": "response text",
"username": "response username",
"channel_id": "response channel id",
"icon_url": "http://example.com/icon.png",
"goto_location": "http://example.com/icon.png",
"attachments": [{
"text": "attachment 1 text",
"pretext": "attachment 1 pretext"
},{
"text": "attachment 2 text",
"fields": [{
"title": "field 1",
"value": "value 1",
"short": true
},{
"title": "field 2",
"value": [],
"short": false
}]
}]
}`,
&CommandResponse{
ResponseType: "ephemeral",
Text: "response text",
Username: "response username",
ChannelId: "response channel id",
IconURL: "http://example.com/icon.png",
GotoLocation: "http://example.com/icon.png",
Attachments: []*MessageAttachment{
{
Text: "attachment 1 text",
Pretext: "attachment 1 pretext",
},
{
Text: "attachment 2 text",
Fields: []*MessageAttachmentField{
{
Title: "field 1",
Value: "value 1",
Short: true,
},
{
Title: "field 2",
Value: "[]",
Short: false,
},
},
},
},
},
false,
false,
},
{
"null array items",
`{"attachments":[{"fields":[{"title":"foo","value":"bar","short":true}, null]}, null]}`,
&CommandResponse{
Attachments: []*MessageAttachment{
{
Fields: []*MessageAttachmentField{
{
Title: "foo",
Value: "bar",
Short: true,
},
},
},
},
},
false,
false,
},
{
"multiple responses returned",
`
{
"text": "message 1",
"extra_responses": [
{"text": "message 2"}
]
}
`,
&CommandResponse{
Text: "message 1",
ExtraResponses: []*CommandResponse{
{
Text: "message 2",
},
},
},
false,
false,
},
{
"invalid response type should fail validation",
`{"text": "hello","response_type": "shliapa_type"}`,
&CommandResponse{
ResponseType: "shliapa_type",
Text: "hello",
},
false,
true,
},
{
"invalid response type inside extra_responses should fail validation",
`{
"text": "main ok",
"response_type": "in_channel",
"extra_responses": [
{"text": "nested bad", "response_type": "invalid_nested_type"}
]
}`,
&CommandResponse{
ResponseType: "in_channel",
Text: "main ok",
ExtraResponses: []*CommandResponse{
{
ResponseType: "invalid_nested_type",
Text: "nested bad",
},
},
},
false,
true,
},
{
"valid https goto_location should pass validation",
`{"text": "hello", "goto_location": "https://example.com"}`,
&CommandResponse{
Text: "hello",
GotoLocation: "https://example.com",
},
false,
false,
},
{
"multiple responses returned, with attachments",
`
{
"text": "message 1",
"attachments":[{"fields":[{"title":"foo","value":"bar","short":true}]}],
"extra_responses": [
{
"text": "message 2",
"attachments":[{"fields":[{"title":"foo 2","value":"bar 2","short":false}]}]
}
]
}`,
&CommandResponse{
Text: "message 1",
Attachments: []*MessageAttachment{
{
Fields: []*MessageAttachmentField{
{
Title: "foo",
Value: "bar",
Short: true,
},
},
},
},
ExtraResponses: []*CommandResponse{
{
Text: "message 2",
Attachments: []*MessageAttachment{
{
Fields: []*MessageAttachmentField{
{
Title: "foo 2",
Value: "bar 2",
Short: false,
},
},
},
},
},
},
},
false,
false,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
t.Parallel()
response, err := CommandResponseFromJSON(strings.NewReader(testCase.Json))
if testCase.ShouldError {
assert.Error(t, err)
assert.Nil(t, response)
} else {
assert.NoError(t, err)
if assert.NotNil(t, response) {
assert.Equal(t, testCase.ExpectedCommandResponse, response)
if testCase.ExpectInvalid {
assert.NotNil(t, response.IsValid())
} else {
assert.Nil(t, response.IsValid())
}
}
}
})
}
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestCommandIsValid(t *testing.T) {
o := Command{
Id: NewId(),
Token: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
CreatorId: NewId(),
TeamId: NewId(),
Trigger: "trigger",
URL: "http://example.com",
Method: CommandMethodGet,
DisplayName: "",
Description: "",
}
require.Nil(t, o.IsValid())
o.Id = ""
require.NotNil(t, o.IsValid(), "should be invalid")
o.Id = NewId()
require.Nil(t, o.IsValid())
o.Token = ""
require.NotNil(t, o.IsValid(), "should be invalid")
o.Token = NewId()
require.Nil(t, o.IsValid())
o.CreateAt = 0
require.NotNil(t, o.IsValid(), "should be invalid")
o.CreateAt = GetMillis()
require.Nil(t, o.IsValid())
o.UpdateAt = 0
require.NotNil(t, o.IsValid(), "should be invalid")
o.UpdateAt = GetMillis()
require.Nil(t, o.IsValid())
o.CreatorId = ""
require.NotNil(t, o.IsValid(), "should be invalid")
o.CreatorId = NewId()
require.Nil(t, o.IsValid())
o.TeamId = ""
require.NotNil(t, o.IsValid(), "should be invalid")
o.TeamId = NewId()
require.Nil(t, o.IsValid())
o.Trigger = ""
require.NotNil(t, o.IsValid(), "should be invalid")
o.Trigger = strings.Repeat("1", 129)
require.NotNil(t, o.IsValid(), "should be invalid")
o.Trigger = strings.Repeat("1", 128)
require.Nil(t, o.IsValid())
validTriggers := []string{"abc", "ABC", "abc123", "a_b-c.d/e"}
for _, trigger := range validTriggers {
o.Trigger = trigger
require.Nil(t, o.IsValid(), "trigger should be valid: %q", trigger)
}
invalidTriggers := []string{
" trigger",
"tri gger",
"tri\tger",
"tri\nger",
"tri\rger",
"tri\x00ger",
"/trigger",
"tri?ger",
"tri*ger",
"trígger",
"trigger😀",
}
for _, trigger := range invalidTriggers {
o.Trigger = trigger
require.NotNil(t, o.IsValid(), "trigger should be invalid: %q", trigger)
}
o.Trigger = "trigger"
o.URL = ""
require.NotNil(t, o.IsValid(), "should be invalid")
o.URL = "1234"
require.NotNil(t, o.IsValid(), "should be invalid")
o.URL = "https:////example.com"
require.NotNil(t, o.IsValid(), "should be invalid")
o.URL = "https://example.com"
require.Nil(t, o.IsValid())
o.Method = "https://example.com"
require.NotNil(t, o.IsValid(), "should be invalid")
o.Method = CommandMethodGet
require.Nil(t, o.IsValid())
o.Method = CommandMethodPost
require.Nil(t, o.IsValid())
o.DisplayName = strings.Repeat("1", 65)
require.NotNil(t, o.IsValid(), "should be invalid")
o.DisplayName = strings.Repeat("1", 64)
require.Nil(t, o.IsValid())
o.Description = strings.Repeat("1", 129)
require.NotNil(t, o.IsValid(), "should be invalid")
o.Description = strings.Repeat("1", 128)
require.Nil(t, o.IsValid())
}
func TestCommandPreSave(t *testing.T) {
o := Command{}
o.PreSave()
}
func TestCommandPreUpdate(t *testing.T) {
o := Command{}
o.PreUpdate()
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
)
type CommandWebhook struct {
Id string
CreateAt int64
CommandId string
UserId string
ChannelId string
RootId string
UseCount int
}
const (
CommandWebhookLifetime = 1000 * 60 * 30
)
func (o *CommandWebhook) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
if o.CreateAt == 0 {
o.CreateAt = GetMillis()
}
}
func (o *CommandWebhook) IsValid() *AppError {
if !IsValidId(o.Id) {
return NewAppError("CommandWebhook.IsValid", "model.command_hook.id.app_error", nil, "", http.StatusBadRequest)
}
if o.CreateAt == 0 {
return NewAppError("CommandWebhook.IsValid", "model.command_hook.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if !IsValidId(o.CommandId) {
return NewAppError("CommandWebhook.IsValid", "model.command_hook.command_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.UserId) {
return NewAppError("CommandWebhook.IsValid", "model.command_hook.user_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.ChannelId) {
return NewAppError("CommandWebhook.IsValid", "model.command_hook.channel_id.app_error", nil, "", http.StatusBadRequest)
}
if o.RootId != "" && !IsValidId(o.RootId) {
return NewAppError("CommandWebhook.IsValid", "model.command_hook.root_id.app_error", nil, "", http.StatusBadRequest)
}
return nil
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommandWebhookPreSave(t *testing.T) {
h := CommandWebhook{}
h.PreSave()
require.Len(t, h.Id, 26, "Id should be generated")
require.NotEqual(t, 0, h.CreateAt, "CreateAt should be set")
}
func TestCommandWebhookIsValid(t *testing.T) {
h := CommandWebhook{}
h.Id = NewId()
h.CreateAt = GetMillis()
h.CommandId = NewId()
h.UserId = NewId()
h.ChannelId = NewId()
for _, test := range []struct {
Transform func()
ExpectedError string
}{
{func() {}, ""},
{func() { h.Id = "asd" }, "model.command_hook.id.app_error"},
{func() { h.Id = NewId() }, ""},
{func() { h.CreateAt = 0 }, "model.command_hook.create_at.app_error"},
{func() { h.CreateAt = GetMillis() }, ""},
{func() { h.CommandId = "asd" }, "model.command_hook.command_id.app_error"},
{func() { h.CommandId = NewId() }, ""},
{func() { h.UserId = "asd" }, "model.command_hook.user_id.app_error"},
{func() { h.UserId = NewId() }, ""},
{func() { h.ChannelId = "asd" }, "model.command_hook.channel_id.app_error"},
{func() { h.ChannelId = NewId() }, ""},
{func() { h.RootId = "asd" }, "model.command_hook.root_id.app_error"},
{func() { h.RootId = NewId() }, ""},
} {
tmp := h
test.Transform()
appErr := h.IsValid()
if test.ExpectedError == "" {
assert.Nil(t, appErr, "hook should be valid")
} else {
require.NotNil(t, appErr)
assert.Equal(t, test.ExpectedError, appErr.Id, "expected "+test.ExpectedError+" error")
}
h = tmp
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
const (
ComplianceStatusCreated = "created"
ComplianceStatusRunning = "running"
ComplianceStatusFinished = "finished"
ComplianceStatusFailed = "failed"
ComplianceStatusRemoved = "removed"
ComplianceTypeDaily = "daily"
ComplianceTypeAdhoc = "adhoc"
)
type Compliance struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UserId string `json:"user_id"`
Status string `json:"status"`
Count int `json:"count"`
Desc string `json:"desc"`
Type string `json:"type"`
StartAt int64 `json:"start_at"`
EndAt int64 `json:"end_at"`
Keywords string `json:"keywords"`
Emails string `json:"emails"`
}
func (c *Compliance) Auditable() map[string]any {
return map[string]any{
"id": c.Id,
"create_at": c.CreateAt,
"user_id": c.UserId,
"status": c.Status,
"count": c.Count,
"desc": c.Desc,
"type": c.Type,
"start_at": c.StartAt,
"end_at": c.EndAt,
"keywords": c.Keywords,
"emails": c.Emails,
}
}
type Compliances []Compliance
// ComplianceExportCursor is used for paginated iteration of posts
// for compliance export.
// We need to keep track of the last post ID in addition to the last post
// CreateAt to break ties when two posts have the same CreateAt.
type ComplianceExportCursor struct {
LastChannelsQueryPostCreateAt int64
LastChannelsQueryPostID string
ChannelsQueryCompleted bool
LastDirectMessagesQueryPostCreateAt int64
LastDirectMessagesQueryPostID string
DirectMessagesQueryCompleted bool
}
func (c *Compliance) PreSave() {
if c.Id == "" {
c.Id = NewId()
}
if c.Status == "" {
c.Status = ComplianceStatusCreated
}
c.Count = 0
c.Emails = NormalizeEmail(c.Emails)
c.Keywords = strings.ToLower(c.Keywords)
c.CreateAt = GetMillis()
}
func (c *Compliance) DeepCopy() *Compliance {
cCopy := *c
return &cCopy
}
func (c *Compliance) JobName() string {
jobName := c.Type
if c.Type == ComplianceTypeDaily {
jobName += "-" + c.Desc
}
jobName += "-" + c.Id
return jobName
}
func (c *Compliance) IsValid() *AppError {
if !IsValidId(c.Id) {
return NewAppError("Compliance.IsValid", "model.compliance.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if c.CreateAt == 0 {
return NewAppError("Compliance.IsValid", "model.compliance.is_valid.create_at.app_error", nil, "", http.StatusBadRequest)
}
if len(c.Desc) > 512 || c.Desc == "" {
return NewAppError("Compliance.IsValid", "model.compliance.is_valid.desc.app_error", nil, "", http.StatusBadRequest)
}
if c.StartAt == 0 {
return NewAppError("Compliance.IsValid", "model.compliance.is_valid.start_at.app_error", nil, "", http.StatusBadRequest)
}
if c.EndAt == 0 {
return NewAppError("Compliance.IsValid", "model.compliance.is_valid.end_at.app_error", nil, "", http.StatusBadRequest)
}
if c.EndAt <= c.StartAt {
return NewAppError("Compliance.IsValid", "model.compliance.is_valid.start_end_at.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
// LoggerFields returns the logger annotations reflecting the given compliance job metadata.
func (c *Compliance) LoggerFields() []mlog.Field {
if c == nil {
return nil
}
return []mlog.Field{
mlog.String("job_id", c.Id),
mlog.String("job_type", c.Type),
mlog.String("job_name", c.JobName()),
mlog.Millis("job_create_at", c.CreateAt),
}
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"regexp"
"time"
)
type CompliancePost struct {
// From Team
TeamName string
TeamDisplayName string
// From Channel
ChannelName string
ChannelDisplayName string
ChannelType string
// From User
UserUsername string
UserEmail string
UserNickname string
// From Post
PostId string
PostCreateAt int64
PostUpdateAt int64
PostDeleteAt int64
PostRootId string
PostOriginalId string
PostMessage string
PostType string
PostProps string
PostHashtags string
PostFileIds string
IsBot bool
}
func CompliancePostHeader() []string {
return []string{
"TeamName",
"TeamDisplayName",
"ChannelName",
"ChannelDisplayName",
"ChannelType",
"UserUsername",
"UserEmail",
"UserNickname",
"UserType",
"PostId",
"PostCreateAt",
"PostUpdateAt",
"PostDeleteAt",
"PostRootId",
"PostOriginalId",
"PostMessage",
"PostType",
"PostProps",
"PostHashtags",
"PostFileIds",
}
}
func cleanComplianceStrings(in string) string {
if matched, _ := regexp.MatchString("^\\s*(=|\\+|\\-)", in); matched {
return "'" + in
}
return in
}
func (cp *CompliancePost) Row() []string {
postDeleteAt := ""
if cp.PostDeleteAt > 0 {
postDeleteAt = time.Unix(0, cp.PostDeleteAt*int64(1000*1000)).Format(time.RFC3339)
}
postUpdateAt := ""
if cp.PostUpdateAt != cp.PostCreateAt {
postUpdateAt = time.Unix(0, cp.PostUpdateAt*int64(1000*1000)).Format(time.RFC3339)
}
userType := "user"
if cp.IsBot {
userType = "bot"
}
return []string{
cleanComplianceStrings(cp.TeamName),
cleanComplianceStrings(cp.TeamDisplayName),
cleanComplianceStrings(cp.ChannelName),
cleanComplianceStrings(cp.ChannelDisplayName),
cleanComplianceStrings(cp.ChannelType),
cleanComplianceStrings(cp.UserUsername),
cleanComplianceStrings(cp.UserEmail),
cleanComplianceStrings(cp.UserNickname),
userType,
cp.PostId,
time.Unix(0, cp.PostCreateAt*int64(1000*1000)).Format(time.RFC3339),
postUpdateAt,
postDeleteAt,
cp.PostRootId,
cp.PostOriginalId,
cleanComplianceStrings(cp.PostMessage),
cp.PostType,
cp.PostProps,
cp.PostHashtags,
cp.PostFileIds,
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestCompliancePostHeader(t *testing.T) {
require.Equal(t, "TeamName", CompliancePostHeader()[0])
}
func TestCompliancePost(t *testing.T) {
o := CompliancePost{TeamName: "test", PostFileIds: "files", PostCreateAt: GetMillis()}
r := o.Row()
require.Equal(t, "test", r[0])
require.Equal(t, "files", r[len(r)-1])
}
var cleanTests = []struct {
in string
expected string
}{
{"hello", "hello"},
{"=hello", "'=hello"},
{"+hello", "'+hello"},
{"-hello", "'-hello"},
{" =hello", "' =hello"},
{" +hello", "' +hello"},
{" -hello", "' -hello"},
{"\t -hello", "'\t -hello"},
}
func TestCleanComplianceStrings(t *testing.T) {
for _, tt := range cleanTests {
actual := cleanComplianceStrings(tt.in)
if actual != tt.expected {
t.Errorf("cleanComplianceStrings(%v): expected %v, actual %v", tt.in, tt.expected, actual)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"slices"
"unicode/utf8"
)
const (
ContentFlaggingGroupName = "content_flagging"
ContentFlaggingPostType = PostCustomTypePrefix + "spillage_report"
ContentFlaggingBotUsername = "content-review"
commentMaxRunes = 1000
AsContentReviewerParam = "as_content_reviewer"
)
const (
ContentFlaggingStatusPending = "Pending"
ContentFlaggingStatusAssigned = "Assigned"
ContentFlaggingStatusRemoved = "Removed"
ContentFlaggingStatusRetained = "Retained"
)
const (
ContentFlaggingActionKeep = "keep"
ContentFlaggingActionRemove = "remove"
)
type FlagContentRequest struct {
Reason string `json:"reason"`
Comment string `json:"comment,omitempty"`
}
func (f *FlagContentRequest) IsValid(commentRequired bool, validReasons []string) *AppError {
if f.Reason == "" {
return NewAppError("FlagContentRequest.IsValid", "api.data_spillage.error.reason_required", nil, "", http.StatusBadRequest)
}
if !slices.Contains(validReasons, f.Reason) {
return NewAppError("FlagContentRequest.IsValid", "api.data_spillage.error.reason_invalid", nil, "", http.StatusBadRequest)
}
if commentRequired && f.Comment == "" {
return NewAppError("FlagContentRequest.IsValid", "api.data_spillage.error.comment_required", nil, "", http.StatusBadRequest)
}
if utf8.RuneCountInString(f.Comment) > commentMaxRunes {
return NewAppError("FlagContentRequest.IsValid", "api.data_spillage.error.comment_too_long", map[string]any{"MaxLength": commentMaxRunes}, "", http.StatusBadRequest)
}
return nil
}
type FlagContentActionRequest struct {
Comment string `json:"comment,omitempty"`
Action string `json:"action,omitempty"`
}
func (f *FlagContentActionRequest) IsValid(commentRequired bool) *AppError {
if commentRequired && f.Comment == "" {
return NewAppError("FlagContentActionRequest.IsValid", "api.data_spillage.error.comment_required", nil, "", http.StatusBadRequest)
}
if utf8.RuneCountInString(f.Comment) > commentMaxRunes {
return NewAppError("FlagContentActionRequest.IsValid", "api.data_spillage.error.comment_too_long", map[string]any{"MaxLength": commentMaxRunes}, "", http.StatusBadRequest)
}
return nil
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"time"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
const PostExposureReportVersion = "1.0"
type PostExposureReportEntry struct {
UserID string `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
IsGuest bool `json:"is_guest"`
IsRemote bool `json:"is_remote"`
IsDeactivated bool `json:"is_deactivated"`
// WasChannelMember records that the user was a member of the channel between the post's
// creation time and it being flagged, per ChannelMemberHistory.
WasChannelMember bool `json:"was_channel_member"`
// LastViewedAt is nil when the user is no longer a member of the channel and so has no
// read state at all. A non-nil zero means they are a member who never viewed the channel.
LastViewedAt *int64 `json:"last_viewed_at,omitempty"`
LastActivityAt *int64 `json:"last_activity_at,omitempty"`
}
type PostExposureReport struct {
Version string `json:"version"`
PostID string `json:"post_id"`
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelType ChannelType `json:"channel_type"`
TeamID string `json:"team_id,omitempty"`
WindowStart int64 `json:"window_start"`
WindowEnd int64 `json:"window_end"`
GeneratedAt int64 `json:"generated_at"`
Entries []*PostExposureReportEntry `json:"entries"`
}
func PostExposureReportCSVHeader(T i18n.TranslateFunc) []string {
return []string{
T("app.data_spillage.exposure.column.user_id"),
T("app.data_spillage.exposure.column.username"),
T("app.data_spillage.exposure.column.email"),
T("app.data_spillage.exposure.column.is_guest"),
T("app.data_spillage.exposure.column.is_remote"),
T("app.data_spillage.exposure.column.is_deactivated"),
T("app.data_spillage.exposure.column.was_channel_member"),
T("app.data_spillage.exposure.column.last_viewed_at"),
T("app.data_spillage.exposure.column.last_activity_at"),
}
}
func (e *PostExposureReportEntry) ToCSVRow(T i18n.TranslateFunc) []string {
return []string{
e.UserID,
e.Username,
e.UserEmail,
exposureBool(T, e.IsGuest),
exposureBool(T, e.IsRemote),
exposureBool(T, e.IsDeactivated),
exposureBool(T, e.WasChannelMember),
e.lastViewedAtCell(T),
e.lastActivityAtCell(T),
}
}
func (e *PostExposureReportEntry) lastViewedAtCell(T i18n.TranslateFunc) string {
switch {
case e.LastViewedAt == nil:
return T("app.data_spillage.exposure.value.unknown")
case *e.LastViewedAt <= 0:
return T("app.data_spillage.exposure.value.never_viewed")
default:
return FormatExposureTime(*e.LastViewedAt)
}
}
func (e *PostExposureReportEntry) lastActivityAtCell(T i18n.TranslateFunc) string {
if e.LastActivityAt == nil {
return T("app.data_spillage.exposure.value.no_sessions")
}
return FormatExposureTime(*e.LastActivityAt)
}
func exposureBool(T i18n.TranslateFunc, v bool) string {
if v {
return T("app.data_spillage.exposure.value.yes")
}
return T("app.data_spillage.exposure.value.no")
}
func FormatExposureTime(millis int64) string {
if millis <= 0 {
return ""
}
return time.UnixMilli(millis).UTC().Format(time.RFC3339)
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const FlaggedPostReportVersion = "1.1"
type FlaggedPostReportContext struct {
Post *Post
Channel *Channel
Team *Team
Author *User
EditHistory []*Post
}
// FlaggedPostReportPost is the on-disk shape for post.yaml. It
// embeds *Post to reuse common fields; the wire format is fixed by the
// MarshalYAML method below so the report layout does not depend on Post's
// own field tags.
type FlaggedPostReportPost struct {
*Post
AuthorName string
AuthorEmail string
ChannelDisplayName string
TeamID string
TeamDisplayName string
ReplyCountPtr *int64
EditHistoryOrder []string
}
func (f FlaggedPostReportPost) MarshalYAML() (any, error) {
out := map[string]any{
"author_name": f.AuthorName,
"author_email": f.AuthorEmail,
"channel_display_name": f.ChannelDisplayName,
"team_id": f.TeamID,
"team_display_name": f.TeamDisplayName,
}
if f.Post != nil {
out["id"] = f.Post.Id
out["author_id"] = f.Post.UserId
out["message"] = f.Post.Message
out["channel_id"] = f.Post.ChannelId
out["create_at"] = f.Post.CreateAt
out["update_at"] = f.Post.UpdateAt
out["is_pinned"] = f.Post.IsPinned
out["root_id"] = f.Post.RootId
if props := f.Post.GetProps(); len(props) > 0 {
out["props"] = props
}
if f.Post.Metadata != nil {
out["metadata"] = f.Post.Metadata
}
}
if f.ReplyCountPtr != nil {
out["reply_count"] = *f.ReplyCountPtr
}
if len(f.EditHistoryOrder) > 0 {
out["edit_history_order"] = f.EditHistoryOrder
}
return out, nil
}
// FlaggedPostReportContentReview is the on-disk shape for content_review.yaml.
type FlaggedPostReportContentReview struct {
ReporterUserID string `yaml:"reporter_user_id"`
ReporterUsername string `yaml:"reporter_username"`
ReporterReason string `yaml:"reporter_reason"`
ReporterComment string `yaml:"reporter_comment"`
ReportTimestamp int64 `yaml:"report_timestamp"`
Hidden bool `yaml:"hidden"`
ReviewerUserID string `yaml:"reviewer_user_id,omitempty"`
ReviewerUsername string `yaml:"reviewer_username,omitempty"`
ReviewerComment string `yaml:"reviewer_comment,omitempty"`
ActionTime int64 `yaml:"action_time,omitempty"`
ActorDecision string `yaml:"actor_decision,omitempty"`
ActorUserId string `yaml:"actor_user_id,omitempty"`
ActorUsername string `yaml:"actor_username,omitempty"`
}
// FlaggedPostReportMetadata is the on-disk shape for report_metadata.yaml.
type FlaggedPostReportMetadata struct {
GeneratedByUserID string `yaml:"generated_by_user_id"`
GeneratedByUsername string `yaml:"generated_by_username"`
Timestamp int64 `yaml:"timestamp"`
ReportVersion string `yaml:"report_version"`
}

View File

@@ -0,0 +1,80 @@
package model
import (
"net/http"
)
type ReviewSettingsRequest struct {
ReviewerSettings
ReviewerIDsSettings
}
func (rs *ReviewSettingsRequest) SetDefaults() {
rs.ReviewerSettings.SetDefaults()
rs.ReviewerIDsSettings.SetDefaults()
}
func (rs *ReviewSettingsRequest) IsValid() *AppError {
additionalReviewersEnabled := *rs.SystemAdminsAsReviewers || *rs.TeamAdminsAsReviewers
// If common reviewers are enabled, there must be at least one specified reviewer, or additional viewers be specified
if *rs.CommonReviewers && len(rs.CommonReviewerIds) == 0 && !additionalReviewersEnabled {
return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error", nil, "", http.StatusBadRequest)
}
// if Additional Reviewers are specified, no extra validation is needed in team specific settings as
// settings team reviewers keeping team feature disabled is valid, as well as
// enabling team feature and not specified reviews is fine as well (since Additional Reviewers are set)
if !additionalReviewersEnabled {
for _, setting := range rs.TeamReviewersSetting {
if *setting.Enabled && len(setting.ReviewerIds) == 0 {
return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error", nil, "", http.StatusBadRequest)
}
}
}
return nil
}
type ContentFlaggingSettingsRequest struct {
ContentFlaggingSettingsBase
ReviewerSettings *ReviewSettingsRequest
}
func (cfs *ContentFlaggingSettingsRequest) SetDefaults() {
cfs.ContentFlaggingSettingsBase.SetDefaults()
if cfs.EnableContentFlagging == nil {
cfs.EnableContentFlagging = new(false)
}
if cfs.NotificationSettings == nil {
cfs.NotificationSettings = &ContentFlaggingNotificationSettings{
EventTargetMapping: make(map[ContentFlaggingEvent][]NotificationTarget),
}
}
if cfs.ReviewerSettings == nil {
cfs.ReviewerSettings = &ReviewSettingsRequest{}
}
if cfs.AdditionalSettings == nil {
cfs.AdditionalSettings = &AdditionalContentFlaggingSettings{}
}
cfs.NotificationSettings.SetDefaults()
cfs.ReviewerSettings.SetDefaults()
cfs.AdditionalSettings.SetDefaults()
}
func (cfs *ContentFlaggingSettingsRequest) IsValid() *AppError {
if appErr := cfs.ContentFlaggingSettingsBase.IsValid(); appErr != nil {
return appErr
}
if appErr := cfs.ReviewerSettings.IsValid(); appErr != nil {
return appErr
}
return nil
}

View File

@@ -0,0 +1,235 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"fmt"
"net/http"
"slices"
)
type ContentFlaggingEvent string
const (
EventFlagged ContentFlaggingEvent = "flagged"
EventAssigned ContentFlaggingEvent = "assigned"
EventContentRemoved ContentFlaggingEvent = "removed"
EventContentDismissed ContentFlaggingEvent = "dismissed"
)
type NotificationTarget string
const (
TargetReviewers NotificationTarget = "reviewers"
TargetAuthor NotificationTarget = "author"
TargetReporter NotificationTarget = "reporter"
)
var ContentFlaggingDefaultReasons = []string{
"Classification mismatch",
"Need-to-know violation",
"Personally identifiable information (PII) exposure",
"Operational security (OPSEC) concern",
"Controlled Unclassified Information (CUI) violation",
"Unauthorized disclosure",
"Other",
}
type ContentFlaggingNotificationSettings struct {
EventTargetMapping map[ContentFlaggingEvent][]NotificationTarget
}
func (cfs *ContentFlaggingNotificationSettings) SetDefaults() {
if cfs.EventTargetMapping == nil {
cfs.EventTargetMapping = make(map[ContentFlaggingEvent][]NotificationTarget)
}
if _, exists := cfs.EventTargetMapping[EventFlagged]; !exists {
cfs.EventTargetMapping[EventFlagged] = []NotificationTarget{TargetReviewers}
} else {
// Ensure TargetReviewers is always included for EventFlagged
if !slices.Contains(cfs.EventTargetMapping[EventFlagged], TargetReviewers) {
cfs.EventTargetMapping[EventFlagged] = append(cfs.EventTargetMapping[EventFlagged], TargetReviewers)
}
}
if _, exists := cfs.EventTargetMapping[EventAssigned]; !exists {
cfs.EventTargetMapping[EventAssigned] = []NotificationTarget{TargetReviewers}
}
if _, exists := cfs.EventTargetMapping[EventContentRemoved]; !exists {
cfs.EventTargetMapping[EventContentRemoved] = []NotificationTarget{TargetReviewers, TargetAuthor, TargetReporter}
}
if _, exists := cfs.EventTargetMapping[EventContentDismissed]; !exists {
cfs.EventTargetMapping[EventContentDismissed] = []NotificationTarget{TargetReviewers, TargetReporter}
}
}
func (cfs *ContentFlaggingNotificationSettings) IsValid() *AppError {
// Reviewers must be notified when content is flagged
// Disabling this option is not allowed in the UI, so this check is for safety and consistency.
// Only valid events and targets are allowed
for event, targets := range cfs.EventTargetMapping {
if event != EventFlagged && event != EventAssigned && event != EventContentRemoved && event != EventContentDismissed {
return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.invalid_event", nil, "", http.StatusBadRequest)
}
for _, target := range targets {
if target != TargetReviewers && target != TargetAuthor && target != TargetReporter {
return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.invalid_target", nil, fmt.Sprintf("target: %s", target), http.StatusBadRequest)
}
}
}
if len(cfs.EventTargetMapping[EventFlagged]) == 0 {
return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", nil, "", http.StatusBadRequest)
}
// Search for the TargetReviewers in the EventFlagged event
reviewerFound := slices.Contains(cfs.EventTargetMapping[EventFlagged], TargetReviewers)
if !reviewerFound {
return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", nil, "", http.StatusBadRequest)
}
return nil
}
type TeamReviewerSetting struct {
Enabled *bool
ReviewerIds []string
}
type ReviewerSettings struct {
CommonReviewers *bool
SystemAdminsAsReviewers *bool
TeamAdminsAsReviewers *bool
}
func (rs *ReviewerSettings) SetDefaults() {
if rs.CommonReviewers == nil {
rs.CommonReviewers = new(true)
}
if rs.SystemAdminsAsReviewers == nil {
rs.SystemAdminsAsReviewers = new(false)
}
if rs.TeamAdminsAsReviewers == nil {
rs.TeamAdminsAsReviewers = new(true)
}
}
type AdditionalContentFlaggingSettings struct {
Reasons *[]string
ReporterCommentRequired *bool
ReviewerCommentRequired *bool
HideFlaggedContent *bool
}
func (acfs *AdditionalContentFlaggingSettings) SetDefaults() {
if acfs.Reasons == nil {
acfs.Reasons = &ContentFlaggingDefaultReasons
}
if acfs.ReporterCommentRequired == nil {
acfs.ReporterCommentRequired = new(true)
}
if acfs.ReviewerCommentRequired == nil {
acfs.ReviewerCommentRequired = new(true)
}
if acfs.HideFlaggedContent == nil {
acfs.HideFlaggedContent = new(true)
}
}
func (acfs *AdditionalContentFlaggingSettings) IsValid() *AppError {
if acfs.Reasons == nil || len(*acfs.Reasons) == 0 {
return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.reasons_not_set.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
type ContentFlaggingSettingsBase struct {
EnableContentFlagging *bool
NotificationSettings *ContentFlaggingNotificationSettings
AdditionalSettings *AdditionalContentFlaggingSettings
}
func (cfs *ContentFlaggingSettingsBase) SetDefaults() {
if cfs.EnableContentFlagging == nil {
cfs.EnableContentFlagging = new(false)
}
if cfs.NotificationSettings == nil {
cfs.NotificationSettings = &ContentFlaggingNotificationSettings{
EventTargetMapping: make(map[ContentFlaggingEvent][]NotificationTarget),
}
}
if cfs.AdditionalSettings == nil {
cfs.AdditionalSettings = &AdditionalContentFlaggingSettings{}
}
cfs.NotificationSettings.SetDefaults()
cfs.AdditionalSettings.SetDefaults()
}
func (cfs *ContentFlaggingSettingsBase) IsValid() *AppError {
if err := cfs.NotificationSettings.IsValid(); err != nil {
return err
}
if err := cfs.AdditionalSettings.IsValid(); err != nil {
return err
}
return nil
}
type ContentFlaggingSettings struct {
ContentFlaggingSettingsBase
ReviewerSettings *ReviewerSettings
}
func (cfs *ContentFlaggingSettings) SetDefaults() {
cfs.ContentFlaggingSettingsBase.SetDefaults()
if cfs.ReviewerSettings == nil {
cfs.ReviewerSettings = &ReviewerSettings{}
}
cfs.ReviewerSettings.SetDefaults()
}
func (cfs *ContentFlaggingSettings) IsValid() *AppError {
return cfs.ContentFlaggingSettingsBase.IsValid()
}
type ContentFlaggingReportingConfig struct {
Reasons *[]string `json:"reasons"`
ReporterCommentRequired *bool `json:"reporter_comment_required"`
ReviewerCommentRequired *bool `json:"reviewer_comment_required"`
NotifyReporterOnDismissal *bool `json:"notify_reporter_on_dismissal,omitempty"`
NotifyReporterOnRemoval *bool `json:"notify_reporter_on_removal,omitempty"`
}
type ReviewerIDsSettings struct {
CommonReviewerIds []string
TeamReviewersSetting map[string]*TeamReviewerSetting
}
func (rs *ReviewerIDsSettings) SetDefaults() {
if rs.CommonReviewerIds == nil {
rs.CommonReviewerIds = []string{}
}
if rs.TeamReviewersSetting == nil {
rs.TeamReviewersSetting = map[string]*TeamReviewerSetting{}
}
}

View File

@@ -0,0 +1,379 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestContentFlaggingNotificationSettings_SetDefault(t *testing.T) {
t.Run("should set default event target mappings", func(t *testing.T) {
settings := &ContentFlaggingNotificationSettings{}
settings.SetDefaults()
require.Nil(t, settings.IsValid())
require.NotNil(t, settings.EventTargetMapping)
require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventFlagged])
require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventAssigned])
require.Equal(t, []NotificationTarget{TargetReviewers, TargetAuthor, TargetReporter}, settings.EventTargetMapping[EventContentRemoved])
require.Equal(t, []NotificationTarget{TargetReviewers, TargetReporter}, settings.EventTargetMapping[EventContentDismissed])
})
t.Run("should not override existing mappings", func(t *testing.T) {
settings := &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
EventFlagged: {TargetReviewers},
},
}
settings.SetDefaults()
require.Nil(t, settings.IsValid())
require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventFlagged])
require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventAssigned])
})
}
func TestContentFlaggingNotificationSettings_IsValid(t *testing.T) {
t.Run("should be valid when reviewers are notified for flagged events", func(t *testing.T) {
settings := &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
EventFlagged: {TargetReviewers, TargetAuthor},
},
}
err := settings.IsValid()
require.Nil(t, err)
})
t.Run("should be invalid when no targets for flagged events", func(t *testing.T) {
settings := &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
EventFlagged: {},
},
}
err := settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id)
})
t.Run("should be invalid when flagged event mapping is nil", func(t *testing.T) {
settings := &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{},
}
err := settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id)
})
t.Run("should be invalid when reviewers not included in flagged event targets", func(t *testing.T) {
settings := &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
EventFlagged: {TargetAuthor, TargetReporter},
},
}
err := settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id)
})
t.Run("should be invalid when invalid events and targets are specified", func(t *testing.T) {
settings := &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
"invalid_event": {TargetAuthor, TargetReporter},
},
}
err := settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.notification_settings.invalid_event", err.Id)
settings = &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
EventFlagged: {"invalid_target_1", "invalid_target_2"},
},
}
err = settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.notification_settings.invalid_target", err.Id)
settings = &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
"invalid_event": {"invalid_target_1", "invalid_target_2"},
},
}
err = settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.notification_settings.invalid_event", err.Id)
})
}
func TestReviewerSettings_IsValid(t *testing.T) {
t.Run("should be valid when common reviewers enabled with reviewer IDs", func(t *testing.T) {
settings := &ReviewSettingsRequest{
ReviewerSettings: ReviewerSettings{
CommonReviewers: new(true),
SystemAdminsAsReviewers: new(false),
TeamAdminsAsReviewers: new(false),
},
ReviewerIDsSettings: ReviewerIDsSettings{
CommonReviewerIds: []string{"user1", "user2"},
TeamReviewersSetting: map[string]*TeamReviewerSetting{},
},
}
err := settings.IsValid()
require.Nil(t, err)
})
t.Run("should be valid when common reviewers enabled with Additional Reviewers", func(t *testing.T) {
settings := &ReviewSettingsRequest{
ReviewerSettings: ReviewerSettings{
CommonReviewers: new(true),
SystemAdminsAsReviewers: new(true),
TeamAdminsAsReviewers: new(false),
},
ReviewerIDsSettings: ReviewerIDsSettings{
CommonReviewerIds: []string{},
TeamReviewersSetting: map[string]*TeamReviewerSetting{},
},
}
err := settings.IsValid()
require.Nil(t, err)
})
t.Run("should be invalid when common reviewers enabled but no reviewers specified", func(t *testing.T) {
settings := &ReviewSettingsRequest{
ReviewerSettings: ReviewerSettings{
CommonReviewers: new(true),
SystemAdminsAsReviewers: new(false),
TeamAdminsAsReviewers: new(false),
},
ReviewerIDsSettings: ReviewerIDsSettings{
CommonReviewerIds: []string{},
TeamReviewersSetting: map[string]*TeamReviewerSetting{},
},
}
err := settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error", err.Id)
})
t.Run("should be valid when team reviewers enabled with reviewer IDs", func(t *testing.T) {
settings := &ReviewSettingsRequest{
ReviewerSettings: ReviewerSettings{
CommonReviewers: new(false),
SystemAdminsAsReviewers: new(false),
TeamAdminsAsReviewers: new(false),
},
ReviewerIDsSettings: ReviewerIDsSettings{
CommonReviewerIds: []string{},
TeamReviewersSetting: map[string]*TeamReviewerSetting{
"team1": {
Enabled: new(true),
ReviewerIds: []string{"user1"},
},
},
},
}
err := settings.IsValid()
require.Nil(t, err)
})
t.Run("should be invalid when team reviewers enabled but no reviewer IDs", func(t *testing.T) {
settings := &ReviewSettingsRequest{
ReviewerSettings: ReviewerSettings{
CommonReviewers: new(false),
SystemAdminsAsReviewers: new(false),
TeamAdminsAsReviewers: new(false),
},
ReviewerIDsSettings: ReviewerIDsSettings{
CommonReviewerIds: []string{},
TeamReviewersSetting: map[string]*TeamReviewerSetting{
"team1": {
Enabled: new(true),
ReviewerIds: []string{},
},
},
},
}
err := settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error", err.Id)
})
t.Run("should be valid when team reviewers enabled but no reviewer IDs with Additional Reviewers", func(t *testing.T) {
settings := &ReviewSettingsRequest{
ReviewerSettings: ReviewerSettings{
CommonReviewers: new(false),
SystemAdminsAsReviewers: new(true),
TeamAdminsAsReviewers: new(false),
},
ReviewerIDsSettings: ReviewerIDsSettings{
CommonReviewerIds: []string{},
TeamReviewersSetting: map[string]*TeamReviewerSetting{
"team1": {
Enabled: new(true),
ReviewerIds: []string{},
},
},
},
}
err := settings.IsValid()
require.Nil(t, err)
})
}
func TestAdditionalContentFlaggingSettings_SetDefault(t *testing.T) {
t.Run("should not override existing values", func(t *testing.T) {
customReasons := []string{"Custom reason"}
settings := &AdditionalContentFlaggingSettings{
Reasons: &customReasons,
}
settings.SetDefaults()
require.Nil(t, settings.IsValid())
require.Equal(t, customReasons, *settings.Reasons)
})
}
func TestAdditionalContentFlaggingSettings_IsValid(t *testing.T) {
t.Run("should be valid when reasons are provided", func(t *testing.T) {
settings := &AdditionalContentFlaggingSettings{
Reasons: &[]string{"Reason 1", "Reason 2"},
}
err := settings.IsValid()
require.Nil(t, err)
})
t.Run("should be invalid when reasons are nil", func(t *testing.T) {
settings := &AdditionalContentFlaggingSettings{
Reasons: nil,
}
err := settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.content_flagging.reasons_not_set.app_error", err.Id)
})
t.Run("should be invalid when reasons are empty", func(t *testing.T) {
settings := &AdditionalContentFlaggingSettings{
Reasons: &[]string{},
}
err := settings.IsValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.content_flagging.reasons_not_set.app_error", err.Id)
})
}
func TestContentFlaggingSettings_SetDefault(t *testing.T) {
t.Run("should not override existing values", func(t *testing.T) {
enabled := true
settings := &ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: ContentFlaggingSettingsBase{
EnableContentFlagging: &enabled,
},
}
settings.SetDefaults()
require.Nil(t, settings.IsValid())
require.True(t, *settings.EnableContentFlagging)
})
}
func TestContentFlaggingSettings_IsValid(t *testing.T) {
t.Run("should be valid when all nested settings are valid", func(t *testing.T) {
settings := &ContentFlaggingSettings{}
settings.SetDefaults()
err := settings.IsValid()
require.Nil(t, err)
})
t.Run("should be invalid when notification settings are invalid", func(t *testing.T) {
settings := &ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: ContentFlaggingSettingsBase{
NotificationSettings: &ContentFlaggingNotificationSettings{
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
EventFlagged: {},
},
},
AdditionalSettings: &AdditionalContentFlaggingSettings{},
},
}
err := settings.IsValid()
require.NotNil(t, err)
require.Contains(t, err.Id, "notification_settings")
})
t.Run("should be invalid when reviewer settings are invalid", func(t *testing.T) {
settings := &ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: ContentFlaggingSettingsBase{
NotificationSettings: &ContentFlaggingNotificationSettings{},
AdditionalSettings: &AdditionalContentFlaggingSettings{},
},
ReviewerSettings: &ReviewSettingsRequest{
ReviewerSettings: ReviewerSettings{
CommonReviewers: new(true),
SystemAdminsAsReviewers: new(false),
TeamAdminsAsReviewers: new(false),
},
ReviewerIDsSettings: ReviewerIDsSettings{
CommonReviewerIds: []string{},
TeamReviewersSetting: map[string]*TeamReviewerSetting{},
},
},
}
settings.NotificationSettings.SetDefaults()
settings.AdditionalSettings.SetDefaults()
err := settings.IsValid()
require.NotNil(t, err)
require.Contains(t, err.Id, "common_reviewers_not_set")
})
t.Run("should be invalid when additional settings are invalid", func(t *testing.T) {
settings := &ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: ContentFlaggingSettingsBase{
NotificationSettings: &ContentFlaggingNotificationSettings{},
AdditionalSettings: &AdditionalContentFlaggingSettings{
Reasons: &[]string{},
},
},
}
settings.SetDefaults()
err := settings.IsValid()
require.NotNil(t, err)
require.Contains(t, err.Id, "reasons_not_set")
})
}
func TestContentFlaggingConstants(t *testing.T) {
t.Run("should have correct event constants", func(t *testing.T) {
require.Equal(t, ContentFlaggingEvent("flagged"), EventFlagged)
require.Equal(t, ContentFlaggingEvent("assigned"), EventAssigned)
require.Equal(t, ContentFlaggingEvent("removed"), EventContentRemoved)
require.Equal(t, ContentFlaggingEvent("dismissed"), EventContentDismissed)
})
t.Run("should have correct target constants", func(t *testing.T) {
require.Equal(t, NotificationTarget("reviewers"), TargetReviewers)
require.Equal(t, NotificationTarget("author"), TargetAuthor)
require.Equal(t, NotificationTarget("reporter"), TargetReporter)
})
}

View File

@@ -0,0 +1,118 @@
package model
import (
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFlagContentRequest_IsValid(t *testing.T) {
validReasons := []string{"spam", "harassment", "inappropriate"}
t.Run("valid request without comment", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "spam",
Comment: "",
}
err := req.IsValid(false, validReasons)
assert.Nil(t, err)
})
t.Run("valid request with comment", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "harassment",
Comment: "This is inappropriate content",
}
err := req.IsValid(false, validReasons)
assert.Nil(t, err)
})
t.Run("valid request with comment when required", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "inappropriate",
Comment: "This violates community guidelines",
}
err := req.IsValid(true, validReasons)
assert.Nil(t, err)
})
t.Run("missing comment when required", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "spam",
Comment: "",
}
err := req.IsValid(true, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.data_spillage.error.comment_required", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("missing reason", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "",
Comment: "Some comment",
}
err := req.IsValid(false, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.data_spillage.error.reason_required", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("invalid reason", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "invalid_reason",
Comment: "Some comment",
}
err := req.IsValid(false, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.data_spillage.error.reason_invalid", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("comment too long", func(t *testing.T) {
longComment := strings.Repeat("a", commentMaxRunes+1)
req := &FlagContentRequest{
Reason: "spam",
Comment: longComment,
}
err := req.IsValid(false, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.data_spillage.error.comment_too_long", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
assert.Equal(t, commentMaxRunes, err.params["MaxLength"])
})
t.Run("comment at max length", func(t *testing.T) {
maxLengthComment := strings.Repeat("a", commentMaxRunes)
req := &FlagContentRequest{
Reason: "harassment",
Comment: maxLengthComment,
}
err := req.IsValid(false, validReasons)
assert.Nil(t, err)
})
t.Run("unicode comment length validation", func(t *testing.T) {
// Test with unicode characters that take multiple bytes
unicodeComment := strings.Repeat("🚀", commentMaxRunes+1)
req := &FlagContentRequest{
Reason: "spam",
Comment: unicodeComment,
}
err := req.IsValid(false, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.data_spillage.error.comment_too_long", err.Id)
})
t.Run("empty valid reasons list", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "spam",
Comment: "Some comment",
}
err := req.IsValid(false, []string{})
assert.NotNil(t, err)
assert.Equal(t, "api.data_spillage.error.reason_invalid", err.Id)
})
}

View File

@@ -0,0 +1,280 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This file implements the "User Attributes" feature, formerly known as
// "Custom Profile Attributes" (CPA). Internal identifiers retain the old
// naming for backward compatibility with REST APIs, WebSocket events,
// JSON wire formats, and the Property System Architecture (PSA) group name
// "custom_profile_attributes". See MM-68235.
package model
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"slices"
)
// CPA-prefixed aliases for the canonical PropertyField* constants in
// property_field_attrs_validation.go. Aliasing (not redeclaring) keeps CPA
// writes and property-hook reads keyed on the same string at compile time,
// so a rename to one side cannot silently diverge from the other.
const (
// Attributes keys
CustomProfileAttributesPropertyAttrsSortOrder = PropertyFieldAttrSortOrder
CustomProfileAttributesPropertyAttrsValueType = PropertyFieldAttrValueType
CustomProfileAttributesPropertyAttrsVisibility = PropertyFieldAttrVisibility
CustomProfileAttributesPropertyAttrsLDAP = PropertyFieldAttrLDAP
CustomProfileAttributesPropertyAttrsSAML = PropertyFieldAttrSAML
CustomProfileAttributesPropertyAttrsManaged = PropertyFieldAttrManaged
CustomProfileAttributesPropertyAttrsDisplayName = PropertyFieldAttrDisplayName
// Value Types
CustomProfileAttributesValueTypeEmail = PropertyFieldValueTypeEmail
CustomProfileAttributesValueTypeURL = PropertyFieldValueTypeURL
CustomProfileAttributesValueTypePhone = PropertyFieldValueTypePhone
// Visibility
CustomProfileAttributesVisibilityHidden = PropertyFieldVisibilityHidden
CustomProfileAttributesVisibilityWhenSet = PropertyFieldVisibilityWhenSet
CustomProfileAttributesVisibilityAlways = PropertyFieldVisibilityAlways
CustomProfileAttributesVisibilityDefault = CustomProfileAttributesVisibilityWhenSet
// CPA options
CPAOptionNameMaxLength = 128
CPAOptionColorMaxLength = 128
// CPA value constraints
CPAValueTypeTextMaxLength = PropertyFieldValueTypeTextMaxLength
)
// CPAFieldNamePattern defines the character set allowed for CPA field names.
// Matches the CEL IDENTIFIER grammar (^[A-Za-z_][A-Za-z0-9_]*$) used by the
// ABAC engine (cel-go v0.27.0). Leading underscore is permitted — this is consistent
// with both the CEL grammar and the enterprise unparser (identifierPartPattern in
// access_control/cel_utils/normalizer.go).
var CPAFieldNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// CPAFieldNameReservedWords is the set of CEL keywords that cannot be used as CPA
// field names. Bare use of these tokens in member-select position (e.g.
// user.attributes.in) either fails CEL parse or requires backtick quoting that
// the ABAC visual builder (ToCEL) does not currently emit.
//
// List sourced from cel-go v0.27.0 CEL.g4 lexer rules.
// Grouped: literals (true/false/null), operator-keywords (in/as), then alphabetical reserved keywords.
var CPAFieldNameReservedWords = map[string]struct{}{
"true": {}, "false": {}, "null": {},
"in": {}, "as": {},
"break": {}, "const": {}, "continue": {}, "else": {},
"for": {}, "function": {}, "if": {}, "import": {},
"let": {}, "loop": {}, "package": {}, "namespace": {},
"return": {}, "var": {}, "void": {}, "while": {},
}
func ValidateCPAFieldName(name string) *AppError {
if !CPAFieldNamePattern.MatchString(name) {
return NewAppError(
"ValidateCPAFieldName",
"model.cpa_field.name.invalid_charset.app_error",
map[string]any{"Name": name},
"",
http.StatusUnprocessableEntity,
)
}
if _, reserved := CPAFieldNameReservedWords[name]; reserved {
return NewAppError(
"ValidateCPAFieldName",
"model.cpa_field.name.reserved_word.app_error",
map[string]any{"Name": name},
"",
http.StatusUnprocessableEntity,
)
}
return nil
}
type CustomProfileAttributesSelectOption struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Rank *int `json:"rank,omitempty"`
}
func (c CustomProfileAttributesSelectOption) GetID() string {
return c.ID
}
func (c CustomProfileAttributesSelectOption) GetName() string {
return c.Name
}
func (c *CustomProfileAttributesSelectOption) SetID(id string) {
c.ID = id
}
func (c CustomProfileAttributesSelectOption) IsValid() error {
if c.ID == "" {
return errors.New("id cannot be empty")
}
if !IsValidId(c.ID) {
return errors.New("id is not a valid ID")
}
if c.Name == "" {
return errors.New("name cannot be empty")
}
if len(c.Name) > CPAOptionNameMaxLength {
return fmt.Errorf("name is too long, max length is %d", CPAOptionNameMaxLength)
}
if c.Color != "" && len(c.Color) > CPAOptionColorMaxLength {
return fmt.Errorf("color is too long, max length is %d", CPAOptionColorMaxLength)
}
return nil
}
type CPAField struct {
PropertyField
Attrs CPAAttrs `json:"attrs"`
}
// CPAAttrs holds the typed attributes for a CPA (Custom Profile Attributes) field.
//
// # CEL-safe-identifier validation for Name
//
// CPA field names double as identifiers in ABAC CEL policy expressions of the form
// user.attributes.<name>. To be valid in that position without backtick quoting,
// Name must satisfy [CPAFieldNamePattern] (^[A-Za-z_][A-Za-z0-9_]*$) and must not
// appear in [CPAFieldNameReservedWords].
//
// # DisplayName
//
// DisplayName carries the user-facing label (e.g. "Department Head") separately
// from Name (the CEL identifier, e.g. "department_head").
type CPAAttrs struct {
Visibility string `json:"visibility"`
SortOrder float64 `json:"sort_order"`
Options PropertyOptions[*CustomProfileAttributesSelectOption] `json:"options"`
ValueType string `json:"value_type"`
LDAP string `json:"ldap"`
SAML string `json:"saml"`
Managed string `json:"managed"`
Protected bool `json:"protected"`
SourcePluginID string `json:"source_plugin_id"`
AccessMode string `json:"access_mode"`
DisplayName string `json:"display_name,omitempty"` // omitempty applies only to direct JSON marshal of CPAAttrs; ToPropertyField always writes the key into the underlying StringInterface map.
// Owners, when set, declares the identities that own this field. A non-empty
// Owners list governs the field's write-access decision, superseding the
// legacy protected / SourcePluginID gating and the sync-lock. The list is
// managed only by an administrator via the REST API; see PropertyOwner.
Owners []PropertyOwner `json:"owners,omitempty"`
}
func (c *CPAField) IsSynced() bool {
return c.Attrs.LDAP != "" || c.Attrs.SAML != ""
}
func (c *CPAField) IsAdminManaged() bool {
return c.Attrs.Managed == "admin"
}
// Patch applies a PropertyFieldPatch to the CPAField by converting to PropertyField,
// applying the patch, and converting back. This ensures we only maintain one patch logic path.
// Custom profile attributes doesn't use targets, so TargetID and TargetType are cleared.
func (c *CPAField) Patch(patch *PropertyFieldPatch) error {
// Custom profile attributes doesn't use targets
patch.TargetID = nil
patch.TargetType = nil
// Convert to PropertyField
pf := c.ToPropertyField()
// Apply the patch using PropertyField's patch logic
pf.Patch(patch, false)
// Convert back to CPAField
patched, err := NewCPAFieldFromPropertyField(pf)
if err != nil {
return err
}
// Update the current CPAField with patched values
*c = *patched
return nil
}
func (c *CPAField) ToPropertyField() *PropertyField {
pf := c.PropertyField
pf.Attrs = StringInterface{
CustomProfileAttributesPropertyAttrsVisibility: c.Attrs.Visibility,
CustomProfileAttributesPropertyAttrsSortOrder: c.Attrs.SortOrder,
CustomProfileAttributesPropertyAttrsValueType: c.Attrs.ValueType,
PropertyFieldAttributeOptions: c.Attrs.Options,
CustomProfileAttributesPropertyAttrsLDAP: c.Attrs.LDAP,
CustomProfileAttributesPropertyAttrsSAML: c.Attrs.SAML,
CustomProfileAttributesPropertyAttrsManaged: c.Attrs.Managed,
PropertyAttrsProtected: c.Attrs.Protected,
PropertyAttrsSourcePluginID: c.Attrs.SourcePluginID,
PropertyAttrsAccessMode: c.Attrs.AccessMode,
CustomProfileAttributesPropertyAttrsDisplayName: c.Attrs.DisplayName,
}
// Only write the owners key when the field declares owners, so existing
// fields keep their attrs blob unchanged and HasPropertyFieldOwners stays
// false for legacy-managed fields.
if len(c.Attrs.Owners) > 0 {
pf.Attrs[PropertyAttrsOwners] = c.Attrs.Owners
}
return &pf
}
func NewCPAFieldFromPropertyField(pf *PropertyField) (*CPAField, error) {
attrsJSON, err := json.Marshal(pf.Attrs)
if err != nil {
return nil, err
}
var attrs CPAAttrs
err = json.Unmarshal(attrsJSON, &attrs)
if err != nil {
return nil, err
}
cpaField := &CPAField{
PropertyField: *pf,
Attrs: attrs,
}
return cpaField, nil
}
// CPAFieldsFromPropertyFields converts a slice of PropertyFields to CPAFields
// and sorts the result by Attrs.SortOrder ascending.
func CPAFieldsFromPropertyFields(pfs []*PropertyField) ([]*CPAField, error) {
cpaFields := make([]*CPAField, 0, len(pfs))
for _, pf := range pfs {
cpaField, err := NewCPAFieldFromPropertyField(pf)
if err != nil {
return nil, err
}
cpaFields = append(cpaFields, cpaField)
}
slices.SortFunc(cpaFields, func(a, b *CPAField) int {
if a.Attrs.SortOrder != b.Attrs.SortOrder {
return cmp.Compare(a.Attrs.SortOrder, b.Attrs.SortOrder)
}
return cmp.Compare(a.ID, b.ID)
})
return cpaFields, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"bytes"
"encoding/json"
"fmt"
"time"
)
const (
UserPropsKeyCustomStatus = "customStatus"
CustomStatusTextMaxRunes = 100
MaxRecentCustomStatuses = 5
DefaultCustomStatusEmoji = "speech_balloon"
)
var validCustomStatusDuration = map[string]bool{
"thirty_minutes": true,
"one_hour": true,
"four_hours": true,
"today": true,
"this_week": true,
"date_and_time": true,
}
type CustomStatus struct {
Emoji string `json:"emoji"`
Text string `json:"text"`
Duration string `json:"duration"`
ExpiresAt time.Time `json:"expires_at"`
}
func (cs *CustomStatus) PreSave() {
if cs.Duration == "" && !cs.ExpiresAt.Before(time.Now()) {
cs.Duration = "date_and_time"
}
runes := []rune(cs.Text)
if len(runes) > CustomStatusTextMaxRunes {
cs.Text = string(runes[:CustomStatusTextMaxRunes])
}
}
func (cs *CustomStatus) AreDurationAndExpirationTimeValid() bool {
if cs.Duration == "" && (cs.ExpiresAt.IsZero() || !cs.ExpiresAt.Before(time.Now())) {
return true
}
if validCustomStatusDuration[cs.Duration] && !cs.ExpiresAt.Before(time.Now()) {
return true
}
return false
}
func RuneToHexadecimalString(r rune) string {
return fmt.Sprintf("%04x", r)
}
type RecentCustomStatuses []CustomStatus
func (rcs RecentCustomStatuses) Contains(cs *CustomStatus) (bool, error) {
if cs == nil {
return false, nil
}
csJSON, jsonErr := json.Marshal(cs)
if jsonErr != nil {
return false, jsonErr
}
// status is empty
if len(csJSON) == 0 || (cs.Emoji == "" && cs.Text == "") {
return false, nil
}
for _, status := range rcs {
js, jsonErr := json.Marshal(status)
if jsonErr != nil {
return false, jsonErr
}
if bytes.Equal(js, csJSON) {
return true, nil
}
}
return false, nil
}
func (rcs RecentCustomStatuses) Add(cs *CustomStatus) RecentCustomStatuses {
newRCS := rcs[:0]
// if same `text` exists in existing recent custom statuses, modify existing status
for _, status := range rcs {
if status.Text != cs.Text {
newRCS = append(newRCS, status)
}
}
newRCS = append(RecentCustomStatuses{*cs}, newRCS...)
if len(newRCS) > MaxRecentCustomStatuses {
newRCS = newRCS[:MaxRecentCustomStatuses]
}
return newRCS
}
func (rcs RecentCustomStatuses) Remove(cs *CustomStatus) (RecentCustomStatuses, error) {
if cs == nil {
return rcs, nil
}
csJSON, jsonErr := json.Marshal(cs)
if jsonErr != nil {
return rcs, jsonErr
}
if len(csJSON) == 0 || (cs.Emoji == "" && cs.Text == "") {
return rcs, nil
}
newRCS := rcs[:0]
for _, status := range rcs {
js, jsonErr := json.Marshal(status)
if jsonErr != nil {
return rcs, jsonErr
}
if !bytes.Equal(js, csJSON) {
newRCS = append(newRCS, status)
}
}
return newRCS, nil
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type GlobalRetentionPolicy struct {
MessageDeletionEnabled bool `json:"message_deletion_enabled"`
FileDeletionEnabled bool `json:"file_deletion_enabled"`
MessageRetentionCutoff int64 `json:"message_retention_cutoff"`
FileRetentionCutoff int64 `json:"file_retention_cutoff"`
}
type RetentionPolicy struct {
ID string `db:"Id" json:"id"`
DisplayName string `json:"display_name"`
PostDurationDays *int64 `db:"PostDuration" json:"post_duration"`
}
type RetentionPolicyWithTeamAndChannelIDs struct {
RetentionPolicy
TeamIDs []string `json:"team_ids"`
ChannelIDs []string `json:"channel_ids"`
}
func (o *RetentionPolicyWithTeamAndChannelIDs) Auditable() map[string]any {
return map[string]any{
"retention_policy": o.RetentionPolicy,
"team_ids": o.TeamIDs,
"channel_ids": o.ChannelIDs,
}
}
type RetentionPolicyWithTeamAndChannelCounts struct {
RetentionPolicy
ChannelCount int64 `json:"channel_count"`
TeamCount int64 `json:"team_count"`
}
func (o *RetentionPolicyWithTeamAndChannelCounts) Auditable() map[string]any {
return map[string]any{
"retention_policy": o.RetentionPolicy,
"channel_count": o.ChannelCount,
"team_count": o.TeamCount,
}
}
type RetentionPolicyChannel struct {
PolicyID string `db:"PolicyId"`
ChannelID string `db:"ChannelId"`
}
type RetentionPolicyTeam struct {
PolicyID string `db:"PolicyId"`
TeamID string `db:"TeamId"`
}
type RetentionPolicyWithTeamAndChannelCountsList struct {
Policies []*RetentionPolicyWithTeamAndChannelCounts `json:"policies"`
TotalCount int64 `json:"total_count"`
}
type RetentionPolicyForTeam struct {
TeamID string `db:"Id" json:"team_id"`
PostDurationDays int64 `db:"PostDuration" json:"post_duration"`
}
type RetentionPolicyForTeamList struct {
Policies []*RetentionPolicyForTeam `json:"policies"`
TotalCount int64 `json:"total_count"`
}
type RetentionPolicyForChannel struct {
ChannelID string `db:"Id" json:"channel_id"`
PostDurationDays int64 `db:"PostDuration" json:"post_duration"`
}
type RetentionPolicyForChannelList struct {
Policies []*RetentionPolicyForChannel `json:"policies"`
TotalCount int64 `json:"total_count"`
}
type RetentionPolicyCursor struct {
ChannelPoliciesDone bool
TeamPoliciesDone bool
GlobalPoliciesDone bool
}
type RetentionIdsForDeletion struct {
Id string
TableName string
Ids []string
}
type RetentionPolicyBatchConfigs struct {
Now int64
GlobalPolicyEndTime int64
Limit int64
PreservePinnedPosts bool
}
func (r *RetentionIdsForDeletion) PreSave() {
if r.Id == "" {
r.Id = NewId()
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import "net/http"
// DeliveryTrackingSettings controls post delivery audit logging. Only the toggles live
// here; the list of channels delivery is recorded in is stored in the
// PostDeliveryTrackingChannels table, because it can grow large and the whole config is
// broadcast on every config change.
type DeliveryTrackingSettings struct {
Enable *bool
EnableForAllChannels *bool
}
func (s *DeliveryTrackingSettings) SetDefaults() {
if s.Enable == nil {
s.Enable = new(false)
}
if s.EnableForAllChannels == nil {
s.EnableForAllChannels = new(true)
}
}
func (s *DeliveryTrackingSettings) IsValid() *AppError {
if s.Enable == nil || s.EnableForAllChannels == nil {
return NewAppError("DeliveryTrackingSettings.IsValid", "model.delivery_tracking.is_valid.missing_toggle.app_error", nil, "", http.StatusBadRequest)
}
// Neither toggle can hold an invalid value on its own. The cross-check against the
// channel list lives on DeliveryTrackingConfig, since the ids are not part of Config.
return nil
}
// DeliveryTrackingConfig is the request and response body for
// GET and PUT /api/v4/delivery_tracking/config. It embeds DeliveryTrackingSettings so the
// toggles marshal flat alongside ChannelIds.
type DeliveryTrackingConfig struct {
DeliveryTrackingSettings
// ChannelIds fully replaces the stored list. A nil value means the caller omitted the
// field and the stored list is left untouched; an empty slice clears it.
ChannelIds []string
}
func (c *DeliveryTrackingConfig) SetDefaults() {
c.DeliveryTrackingSettings.SetDefaults()
// ChannelIds is deliberately not defaulted: nil and empty mean different things, and
// collapsing them would make a request that omits the field wipe the stored list.
}
func (c *DeliveryTrackingConfig) IsValid() *AppError {
if appErr := c.DeliveryTrackingSettings.IsValid(); appErr != nil {
return appErr
}
if !*c.EnableForAllChannels && len(c.ChannelIds) == 0 {
return NewAppError("DeliveryTrackingConfig.IsValid", "model.delivery_tracking.is_valid.all_channels.app_error", nil, "", http.StatusBadRequest)
}
for _, channelID := range c.ChannelIds {
if !IsValidId(channelID) {
return NewAppError("DeliveryTrackingConfig.IsValid", "model.delivery_tracking.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest)
}
}
return nil
}

View File

@@ -0,0 +1,219 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeliveryTrackingSettingsSetDefaults(t *testing.T) {
t.Run("defaults to disabled and all channels", func(t *testing.T) {
settings := DeliveryTrackingSettings{}
settings.SetDefaults()
require.NotNil(t, settings.Enable)
require.NotNil(t, settings.EnableForAllChannels)
assert.False(t, *settings.Enable)
assert.True(t, *settings.EnableForAllChannels)
})
t.Run("preserves existing values", func(t *testing.T) {
settings := DeliveryTrackingSettings{
Enable: new(true),
EnableForAllChannels: new(false),
}
settings.SetDefaults()
assert.True(t, *settings.Enable)
assert.False(t, *settings.EnableForAllChannels)
})
}
func TestDeliveryTrackingSettingsIsValid(t *testing.T) {
testCases := []struct {
name string
settings DeliveryTrackingSettings
expectedError string
}{
{
name: "both toggles set is valid",
settings: DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
},
{
name: "nil Enable is invalid",
settings: DeliveryTrackingSettings{EnableForAllChannels: new(true)},
expectedError: "model.delivery_tracking.is_valid.missing_toggle.app_error",
},
{
name: "nil EnableForAllChannels is invalid",
settings: DeliveryTrackingSettings{Enable: new(true)},
expectedError: "model.delivery_tracking.is_valid.missing_toggle.app_error",
},
{
name: "both toggles nil is invalid",
settings: DeliveryTrackingSettings{},
expectedError: "model.delivery_tracking.is_valid.missing_toggle.app_error",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
appErr := tc.settings.IsValid()
if tc.expectedError == "" {
assert.Nil(t, appErr)
return
}
require.NotNil(t, appErr)
assert.Equal(t, tc.expectedError, appErr.Id)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
}
}
func TestDeliveryTrackingConfigSetDefaults(t *testing.T) {
t.Run("leaves nil ChannelIds nil", func(t *testing.T) {
// nil and empty mean different things on save: nil leaves the stored list
// untouched, empty clears it. SetDefaults must not collapse the two.
config := DeliveryTrackingConfig{}
config.SetDefaults()
assert.Nil(t, config.ChannelIds)
})
t.Run("leaves empty ChannelIds empty", func(t *testing.T) {
config := DeliveryTrackingConfig{ChannelIds: []string{}}
config.SetDefaults()
require.NotNil(t, config.ChannelIds)
assert.Empty(t, config.ChannelIds)
})
t.Run("defaults the embedded toggles", func(t *testing.T) {
config := DeliveryTrackingConfig{}
config.SetDefaults()
require.NotNil(t, config.Enable)
require.NotNil(t, config.EnableForAllChannels)
assert.False(t, *config.Enable)
assert.True(t, *config.EnableForAllChannels)
})
}
func TestDeliveryTrackingConfigIsValid(t *testing.T) {
channelID := NewId()
testCases := []struct {
name string
config DeliveryTrackingConfig
expectedError string
}{
{
name: "all channels with no ids is valid",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
},
},
{
name: "all channels with ids is valid",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
ChannelIds: []string{channelID},
},
},
{
name: "selected channels with ids is valid",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{channelID},
},
},
{
name: "selected channels with nil ids is invalid",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
},
expectedError: "model.delivery_tracking.is_valid.all_channels.app_error",
},
{
name: "selected channels with empty ids is invalid",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{},
},
expectedError: "model.delivery_tracking.is_valid.all_channels.app_error",
},
{
name: "malformed channel id is invalid",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{"not-a-valid-id"},
},
expectedError: "model.delivery_tracking.is_valid.channel_id.app_error",
},
{
name: "malformed channel id is rejected even with all channels on",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
ChannelIds: []string{channelID, ""},
},
expectedError: "model.delivery_tracking.is_valid.channel_id.app_error",
},
{
name: "disabled config is still validated",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(false), EnableForAllChannels: new(false)},
},
expectedError: "model.delivery_tracking.is_valid.all_channels.app_error",
},
{
// Without the guard on the embedded settings these would dereference nil rather
// than return an error, since IsValid can be reached without SetDefaults.
name: "nil Enable is rejected without panicking",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{EnableForAllChannels: new(true)},
},
expectedError: "model.delivery_tracking.is_valid.missing_toggle.app_error",
},
{
name: "nil EnableForAllChannels is rejected without panicking",
config: DeliveryTrackingConfig{
DeliveryTrackingSettings: DeliveryTrackingSettings{Enable: new(true)},
ChannelIds: []string{channelID},
},
expectedError: "model.delivery_tracking.is_valid.missing_toggle.app_error",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
appErr := tc.config.IsValid()
if tc.expectedError == "" {
assert.Nil(t, appErr)
return
}
require.NotNil(t, appErr)
assert.Equal(t, tc.expectedError, appErr.Id)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
}
}
func TestConfigDeliveryTrackingSettingsDefaults(t *testing.T) {
cfg := Config{}
cfg.SetDefaults()
require.NotNil(t, cfg.DeliveryTrackingSettings.Enable)
require.NotNil(t, cfg.DeliveryTrackingSettings.EnableForAllChannels)
assert.False(t, *cfg.DeliveryTrackingSettings.Enable)
assert.True(t, *cfg.DeliveryTrackingSettings.EnableForAllChannels)
assert.Nil(t, cfg.IsValid())
}

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"sync"
"unicode/utf8"
)
type Draft struct {
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"` // Deprecated, we now just hard delete the rows
UserId string `json:"user_id"`
ChannelId string `json:"channel_id"`
RootId string `json:"root_id"`
Message string `json:"message"`
Type string `json:"type"`
propsMu sync.RWMutex `db:"-"` // Unexported mutex used to guard Draft.Props.
Props StringInterface `json:"props"` // Deprecated: use GetProps()
FileIds StringArray `json:"file_ids,omitempty"`
Metadata *PostMetadata `json:"metadata,omitempty"`
Priority StringInterface `json:"priority,omitempty"`
}
func (o *Draft) IsValid(maxDraftSize int) *AppError {
if utf8.RuneCountInString(o.Message) > maxDraftSize {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.message_length.app_error",
map[string]any{"Length": utf8.RuneCountInString(o.Message), "MaxLength": maxDraftSize}, "channelid="+o.ChannelId, http.StatusBadRequest)
}
return o.BaseIsValid()
}
func (o *Draft) BaseIsValid() *AppError {
if o.CreateAt == 0 {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.create_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest)
}
if o.UpdateAt == 0 {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.update_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest)
}
if !IsValidId(o.UserId) {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.user_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.ChannelId) {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest)
}
if !(IsValidId(o.RootId) || o.RootId == "") {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.root_id.app_error", nil, "", http.StatusBadRequest)
}
if utf8.RuneCountInString(ArrayToJSON(o.FileIds)) > PostFileidsMaxRunes {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.file_ids.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest)
}
if utf8.RuneCountInString(StringInterfaceToJSON(o.GetProps())) > PostPropsMaxRunes {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.props.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest)
}
if utf8.RuneCountInString(StringInterfaceToJSON(o.Priority)) > PostPropsMaxRunes {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.priority.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest)
}
return nil
}
func (o *Draft) SetProps(props StringInterface) {
o.propsMu.Lock()
defer o.propsMu.Unlock()
o.Props = props
}
func (o *Draft) GetProps() StringInterface {
o.propsMu.RLock()
defer o.propsMu.RUnlock()
return o.Props
}
func (o *Draft) PreSave() {
if o.CreateAt == 0 {
o.CreateAt = GetMillis()
o.UpdateAt = o.CreateAt
} else {
o.UpdateAt = GetMillis()
}
o.DeleteAt = 0
o.PreCommit()
}
func (o *Draft) PreCommit() {
if o.GetProps() == nil {
o.SetProps(make(map[string]any))
}
if o.FileIds == nil {
o.FileIds = []string{}
}
// There's a rare bug where the client sends up duplicate FileIds so protect against that
o.FileIds = RemoveDuplicateStrings(o.FileIds)
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDraftIsValid(t *testing.T) {
o := Draft{}
maxDraftSize := 10000
err := o.IsValid(maxDraftSize)
assert.NotNil(t, err)
o.CreateAt = GetMillis()
err = o.IsValid(maxDraftSize)
assert.NotNil(t, err)
o.UpdateAt = GetMillis()
err = o.IsValid(maxDraftSize)
assert.NotNil(t, err)
o.UserId = NewId()
err = o.IsValid(maxDraftSize)
assert.NotNil(t, err)
o.ChannelId = NewId()
o.RootId = "123"
err = o.IsValid(maxDraftSize)
assert.NotNil(t, err)
o.RootId = ""
o.Message = strings.Repeat("0", maxDraftSize+1)
err = o.IsValid(maxDraftSize)
assert.NotNil(t, err)
o.Message = strings.Repeat("0", maxDraftSize)
err = o.IsValid(maxDraftSize)
assert.Nil(t, err)
o.Message = "test"
err = o.IsValid(maxDraftSize)
assert.Nil(t, err)
o.FileIds = StringArray{strings.Repeat("0", maxDraftSize+1)}
err = o.IsValid(maxDraftSize)
assert.NotNil(t, err)
}
func TestDraftPreSave(t *testing.T) {
o := Draft{Message: "test"}
o.PreSave()
assert.NotEqual(t, 0, o.CreateAt)
past := GetMillis() - 1
o = Draft{Message: "test", CreateAt: past}
o.PreSave()
assert.LessOrEqual(t, o.CreateAt, past)
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type EmailNotificationContent struct {
Subject string `json:"subject,omitempty"`
Title string `json:"title,omitempty"`
SubTitle string `json:"subtitle,omitempty"`
MessageHTML string `json:"message_html,omitempty"`
MessageText string `json:"message_text,omitempty"`
ButtonText string `json:"button_text,omitempty"`
ButtonURL string `json:"button_url,omitempty"`
FooterText string `json:"footer_text,omitempty"`
}
type EmailNotification struct {
PostId string `json:"post_id"`
ChannelId string `json:"channel_id"`
TeamId string `json:"team_id"`
SenderId string `json:"sender_id"`
SenderDisplayName string `json:"sender_display_name,omitempty"`
RecipientId string `json:"recipient_id"`
RootId string `json:"root_id,omitempty"`
ChannelType string `json:"channel_type"`
ChannelName string `json:"channel_name"`
TeamName string `json:"team_name"`
SenderUsername string `json:"sender_username"`
IsDirectMessage bool `json:"is_direct_message"`
IsGroupMessage bool `json:"is_group_message"`
IsThreadReply bool `json:"is_thread_reply"`
IsCRTEnabled bool `json:"is_crt_enabled"`
UseMilitaryTime bool `json:"use_military_time"`
EmailNotificationContent
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"regexp"
"slices"
"strings"
)
const (
EmojiNameMaxLength = 64
EmojiSortByName = "name"
)
var EmojiPattern = regexp.MustCompile(`:[a-zA-Z0-9_+-]+:`)
type Emoji struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
CreatorId string `json:"creator_id"`
Name string `json:"name"`
}
func (emoji *Emoji) Auditable() map[string]any {
return map[string]any{
"id": emoji.Id,
"create_at": emoji.CreateAt,
"update_at": emoji.UpdateAt,
"delete_at": emoji.CreateAt,
"creator_id": emoji.CreatorId,
"name": emoji.Name,
}
}
func IsSystemEmojiName(emojiName string) bool {
_, ok := SystemEmojis[emojiName]
return ok
}
func GetSystemEmojiId(emojiName string) (string, bool) {
id, found := SystemEmojis[emojiName]
return id, found
}
func makeReverseEmojiMap() map[string][]string {
reverseEmojiMap := make(map[string][]string)
for key, value := range SystemEmojis {
emojiNames := reverseEmojiMap[value]
emojiNames = append(emojiNames, key)
slices.Sort(emojiNames)
reverseEmojiMap[value] = emojiNames
}
return reverseEmojiMap
}
var reverseSystemEmojisMap = makeReverseEmojiMap()
func GetEmojiNameFromUnicode(unicode string) (emojiName string, count int) {
if emojiNames, found := reverseSystemEmojisMap[unicode]; found {
return emojiNames[0], len(emojiNames)
}
return "", 0
}
func (emoji *Emoji) IsValid() *AppError {
if !IsValidId(emoji.Id) {
return NewAppError("Emoji.IsValid", "model.emoji.id.app_error", nil, "", http.StatusBadRequest)
}
if emoji.CreateAt == 0 {
return NewAppError("Emoji.IsValid", "model.emoji.create_at.app_error", nil, "id="+emoji.Id, http.StatusBadRequest)
}
if emoji.UpdateAt == 0 {
return NewAppError("Emoji.IsValid", "model.emoji.update_at.app_error", nil, "id="+emoji.Id, http.StatusBadRequest)
}
if len(emoji.CreatorId) > 26 {
return NewAppError("Emoji.IsValid", "model.emoji.user_id.app_error", nil, "", http.StatusBadRequest)
}
return IsValidEmojiName(emoji.Name)
}
func IsValidEmojiName(name string) *AppError {
if name == "" || len(name) > EmojiNameMaxLength || !IsValidAlphaNumHyphenUnderscorePlus(name) {
return NewAppError("Emoji.IsValid", "model.emoji.name.app_error", nil, "", http.StatusBadRequest)
}
if IsSystemEmojiName(name) {
return NewAppError("Emoji.IsValid", "model.emoji.system_emoji_name.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
func (emoji *Emoji) PreSave() {
if emoji.Id == "" {
emoji.Id = NewId()
}
emoji.Name = strings.ToLower(emoji.Name)
emoji.CreateAt = GetMillis()
emoji.UpdateAt = emoji.CreateAt
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type EmojiSearch struct {
Term string `json:"term"`
PrefixOnly bool `json:"prefix_only"`
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestEmojiIsValid(t *testing.T) {
emoji := Emoji{
Id: NewId(),
CreateAt: 1234,
UpdateAt: 1234,
DeleteAt: 0,
CreatorId: NewId(),
Name: "name",
}
require.Nil(t, emoji.IsValid())
emoji.Id = "1234"
require.NotNil(t, emoji.IsValid())
emoji.Id = NewId()
emoji.CreateAt = 0
require.NotNil(t, emoji.IsValid())
emoji.CreateAt = 1234
emoji.UpdateAt = 0
require.NotNil(t, emoji.IsValid())
emoji.UpdateAt = 1234
emoji.CreatorId = strings.Repeat("1", 27)
require.NotNil(t, emoji.IsValid())
emoji.CreatorId = NewId()
emoji.Name = strings.Repeat("1", 65)
require.NotNil(t, emoji.IsValid())
emoji.Name = ""
require.NotNil(t, emoji.IsValid())
emoji.Name = strings.Repeat("1", 64)
require.Nil(t, emoji.IsValid())
emoji.Name = "name-"
require.Nil(t, emoji.IsValid())
emoji.Name = "name+"
require.Nil(t, emoji.IsValid())
emoji.Name = "name_"
require.Nil(t, emoji.IsValid())
emoji.Name = "name:"
require.NotNil(t, emoji.IsValid())
emoji.Name = "croissant"
require.NotNil(t, emoji.IsValid())
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// CleanupReport describes the outcome of a client-side ephemeral mode cleanup run.
type CleanupReport struct {
PostsDeleted *int64 `json:"posts_deleted"`
PlaybookRunsDeleted *int64 `json:"playbook_runs_deleted"`
CleanupAt *int64 `json:"cleanup_at"`
ErrorReason *string `json:"error_reason"`
}
// OfflinePurgeReport describes the outcome of a client-side ephemeral mode offline purge.
type OfflinePurgeReport struct {
OfflineTimeMinutes *int64 `json:"offline_time_minutes"`
PurgeAt *int64 `json:"purge_at"`
ErrorReason *string `json:"error_reason"`
}
// SessionWipeReport confirms a client-side ephemeral mode wipe triggered by a session revocation.
// Signature is the one the wipe push carried, echoed back to attribute the report: the session it
// refers to is already revoked by the time it is sent.
type SessionWipeReport struct {
WipeAt *int64 `json:"wipe_at"`
ErrorReason *string `json:"error_reason"`
Signature string `json:"signature"`
}

Some files were not shown because too many files have changed in this diff Show More