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
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:
79
server/public/model/access.go
Normal file
79
server/public/model/access.go
Normal 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
|
||||
}
|
||||
114
server/public/model/access_control_decision.go
Normal file
114
server/public/model/access_control_decision.go
Normal 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
|
||||
}
|
||||
71
server/public/model/access_control_masking.go
Normal file
71
server/public/model/access_control_masking.go
Normal 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)
|
||||
}
|
||||
755
server/public/model/access_policy.go
Normal file
755
server/public/model/access_policy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
1549
server/public/model/access_policy_test.go
Normal file
1549
server/public/model/access_policy_test.go
Normal file
File diff suppressed because it is too large
Load Diff
649
server/public/model/access_request.go
Normal file
649
server/public/model/access_request.go
Normal 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"`
|
||||
}
|
||||
58
server/public/model/access_request_test.go
Normal file
58
server/public/model/access_request_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
139
server/public/model/access_test.go
Normal file
139
server/public/model/access_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
24
server/public/model/agents.go
Normal file
24
server/public/model/agents.go
Normal 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"`
|
||||
}
|
||||
58
server/public/model/ai_bridge_test_helper.go
Normal file
58
server/public/model/ai_bridge_test_helper.go
Normal 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"`
|
||||
}
|
||||
61
server/public/model/ai_recap_limits.go
Normal file
61
server/public/model/ai_recap_limits.go
Normal 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
|
||||
}
|
||||
155
server/public/model/ai_recap_settings.go
Normal file
155
server/public/model/ai_recap_settings.go
Normal 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
|
||||
}
|
||||
236
server/public/model/ai_recap_settings_test.go
Normal file
236
server/public/model/ai_recap_settings_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
11
server/public/model/analytics_row.go
Normal file
11
server/public/model/analytics_row.go
Normal 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
|
||||
14
server/public/model/audit.go
Normal file
14
server/public/model/audit.go
Normal 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"`
|
||||
}
|
||||
558
server/public/model/audit_events.go
Normal file
558
server/public/model/audit_events.go
Normal 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
|
||||
)
|
||||
149
server/public/model/audit_record.go
Normal file
149
server/public/model/audit_record.go
Normal 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())
|
||||
}
|
||||
776
server/public/model/auditconv.go
Normal file
776
server/public/model/auditconv.go
Normal 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
|
||||
}
|
||||
98
server/public/model/auditconv_test.go
Normal file
98
server/public/model/auditconv_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
14
server/public/model/audits.go
Normal file
14
server/public/model/audits.go
Normal 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 ""
|
||||
}
|
||||
300
server/public/model/authorize.go
Normal file
300
server/public/model/authorize.go
Normal 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
|
||||
}
|
||||
371
server/public/model/authorize_test.go
Normal file
371
server/public/model/authorize_test.go
Normal 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¶m2=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())
|
||||
})
|
||||
}
|
||||
223
server/public/model/autotranslation.go
Normal file
223
server/public/model/autotranslation.go
Normal 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
|
||||
}
|
||||
86
server/public/model/autotranslation_test.go
Normal file
86
server/public/model/autotranslation_test.go
Normal 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
259
server/public/model/bot.go
Normal 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
|
||||
}
|
||||
740
server/public/model/bot_test.go
Normal file
740
server/public/model/bot_test.go
Normal 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"])
|
||||
})
|
||||
}
|
||||
17
server/public/model/builtin.go
Normal file
17
server/public/model/builtin.go
Normal 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
|
||||
}
|
||||
39
server/public/model/builtin_test.go
Normal file
39
server/public/model/builtin_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
16
server/public/model/bulk_export.go
Normal file
16
server/public/model/bulk_export.go
Normal 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
|
||||
}
|
||||
34
server/public/model/bundle_info.go
Normal file
34
server/public/model/bundle_info.go
Normal 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,
|
||||
}
|
||||
}
|
||||
32
server/public/model/bundle_info_test.go
Normal file
32
server/public/model/bundle_info_test.go
Normal 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)
|
||||
}
|
||||
34
server/public/model/cel.go
Normal file
34
server/public/model/cel.go
Normal 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"`
|
||||
}
|
||||
124
server/public/model/cel_test.go
Normal file
124
server/public/model/cel_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
661
server/public/model/channel.go
Normal file
661
server/public/model/channel.go
Normal 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
|
||||
}
|
||||
389
server/public/model/channel_bookmark.go
Normal file
389
server/public/model/channel_bookmark.go
Normal 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
|
||||
}
|
||||
793
server/public/model/channel_bookmark_test.go
Normal file
793
server/public/model/channel_bookmark_test.go
Normal 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")))
|
||||
}
|
||||
18
server/public/model/channel_data.go
Normal file
18
server/public/model/channel_data.go
Normal 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)
|
||||
}
|
||||
165
server/public/model/channel_join_request.go
Normal file
165
server/public/model/channel_join_request.go
Normal 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)
|
||||
}
|
||||
114
server/public/model/channel_join_request_test.go
Normal file
114
server/public/model/channel_join_request_test.go
Normal 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 "))
|
||||
}
|
||||
53
server/public/model/channel_list.go
Normal file
53
server/public/model/channel_list.go
Normal 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))
|
||||
}
|
||||
357
server/public/model/channel_member.go
Normal file
357
server/public/model/channel_member.go
Normal 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"`
|
||||
}
|
||||
11
server/public/model/channel_member_history.go
Normal file
11
server/public/model/channel_member_history.go
Normal 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
|
||||
}
|
||||
17
server/public/model/channel_member_history_result.go
Normal file
17
server/public/model/channel_member_history_result.go
Normal 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
|
||||
}
|
||||
11
server/public/model/channel_member_last_viewed.go
Normal file
11
server/public/model/channel_member_last_viewed.go
Normal 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
|
||||
}
|
||||
253
server/public/model/channel_member_test.go
Normal file
253
server/public/model/channel_member_test.go
Normal 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"])
|
||||
})
|
||||
}
|
||||
96
server/public/model/channel_mentions.go
Normal file
96
server/public/model/channel_mentions.go
Normal 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
|
||||
}
|
||||
456
server/public/model/channel_mentions_test.go
Normal file
456
server/public/model/channel_mentions_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
27
server/public/model/channel_search.go
Normal file
27
server/public/model/channel_search.go
Normal 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"`
|
||||
}
|
||||
101
server/public/model/channel_sidebar.go
Normal file
101
server/public/model/channel_sidebar.go
Normal 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))
|
||||
}
|
||||
49
server/public/model/channel_sidebar_test.go
Normal file
49
server/public/model/channel_sidebar_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
24
server/public/model/channel_stats.go
Normal file
24
server/public/model/channel_stats.go
Normal 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)
|
||||
}
|
||||
447
server/public/model/channel_test.go
Normal file
447
server/public/model/channel_test.go
Normal 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)
|
||||
}
|
||||
15
server/public/model/channel_view.go
Normal file
15
server/public/model/channel_view.go
Normal 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"`
|
||||
}
|
||||
8541
server/public/model/client4.go
Normal file
8541
server/public/model/client4.go
Normal file
File diff suppressed because it is too large
Load Diff
46
server/public/model/client4_route.go
Normal file
46
server/public/model/client4_route.go
Normal 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, "/"))
|
||||
}
|
||||
399
server/public/model/client4_route_test.go
Normal file
399
server/public/model/client4_route_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
442
server/public/model/client4_test.go
Normal file
442
server/public/model/client4_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
385
server/public/model/cloud.go
Normal file
385
server/public/model/cloud.go
Normal 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
|
||||
}
|
||||
115
server/public/model/cluster_discovery.go
Normal file
115
server/public/model/cluster_discovery.go
Normal 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
|
||||
}
|
||||
50
server/public/model/cluster_discovery_test.go
Normal file
50
server/public/model/cluster_discovery_test.go
Normal 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("", "")
|
||||
}
|
||||
13
server/public/model/cluster_info.go
Normal file
13
server/public/model/cluster_info.go
Normal 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"`
|
||||
}
|
||||
140
server/public/model/cluster_message.go
Normal file
140
server/public/model/cluster_message.go
Normal 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
|
||||
}
|
||||
175
server/public/model/cluster_message_test.go
Normal file
175
server/public/model/cluster_message_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
11
server/public/model/cluster_stats.go
Normal file
11
server/public/model/cluster_stats.go
Normal 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"`
|
||||
}
|
||||
159
server/public/model/command.go
Normal file
159
server/public/model/command.go
Normal 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 = ""
|
||||
}
|
||||
57
server/public/model/command_args.go
Normal file
57
server/public/model/command_args.go
Normal 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
|
||||
}
|
||||
147
server/public/model/command_args_test.go
Normal file
147
server/public/model/command_args_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
401
server/public/model/command_autocomplete.go
Normal file
401
server/public/model/command_autocomplete.go
Normal 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
|
||||
}
|
||||
99
server/public/model/command_autocomplete_test.go
Normal file
99
server/public/model/command_autocomplete_test.go
Normal 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)
|
||||
}
|
||||
8
server/public/model/command_request.go
Normal file
8
server/public/model/command_request.go
Normal 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"`
|
||||
}
|
||||
117
server/public/model/command_response.go
Normal file
117
server/public/model/command_response.go
Normal 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
|
||||
}
|
||||
278
server/public/model/command_response_test.go
Normal file
278
server/public/model/command_response_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
142
server/public/model/command_test.go
Normal file
142
server/public/model/command_test.go
Normal 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()
|
||||
}
|
||||
60
server/public/model/command_webhook.go
Normal file
60
server/public/model/command_webhook.go
Normal 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
|
||||
}
|
||||
60
server/public/model/command_webhook_test.go
Normal file
60
server/public/model/command_webhook_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
141
server/public/model/compliance.go
Normal file
141
server/public/model/compliance.go
Normal 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),
|
||||
}
|
||||
}
|
||||
120
server/public/model/compliance_post.go
Normal file
120
server/public/model/compliance_post.go
Normal 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,
|
||||
}
|
||||
}
|
||||
45
server/public/model/compliance_post_test.go
Normal file
45
server/public/model/compliance_post_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
6063
server/public/model/config.go
Normal file
6063
server/public/model/config.go
Normal file
File diff suppressed because it is too large
Load Diff
74
server/public/model/content_flagging.go
Normal file
74
server/public/model/content_flagging.go
Normal 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
|
||||
}
|
||||
106
server/public/model/content_flagging_exposure_report.go
Normal file
106
server/public/model/content_flagging_exposure_report.go
Normal 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)
|
||||
}
|
||||
88
server/public/model/content_flagging_report.go
Normal file
88
server/public/model/content_flagging_report.go
Normal 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"`
|
||||
}
|
||||
80
server/public/model/content_flagging_setting_request.go
Normal file
80
server/public/model/content_flagging_setting_request.go
Normal 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
|
||||
}
|
||||
235
server/public/model/content_flagging_settings.go
Normal file
235
server/public/model/content_flagging_settings.go
Normal 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{}
|
||||
}
|
||||
}
|
||||
379
server/public/model/content_flagging_settings_test.go
Normal file
379
server/public/model/content_flagging_settings_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
118
server/public/model/content_flagging_test.go
Normal file
118
server/public/model/content_flagging_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
280
server/public/model/custom_profile_attributes.go
Normal file
280
server/public/model/custom_profile_attributes.go
Normal 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
|
||||
}
|
||||
1097
server/public/model/custom_profile_attributes_test.go
Normal file
1097
server/public/model/custom_profile_attributes_test.go
Normal file
File diff suppressed because it is too large
Load Diff
136
server/public/model/custom_status.go
Normal file
136
server/public/model/custom_status.go
Normal 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
|
||||
}
|
||||
105
server/public/model/data_retention_policy.go
Normal file
105
server/public/model/data_retention_policy.go
Normal 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()
|
||||
}
|
||||
}
|
||||
71
server/public/model/delivery_tracking_settings.go
Normal file
71
server/public/model/delivery_tracking_settings.go
Normal 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
|
||||
}
|
||||
219
server/public/model/delivery_tracking_settings_test.go
Normal file
219
server/public/model/delivery_tracking_settings_test.go
Normal 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())
|
||||
}
|
||||
110
server/public/model/draft.go
Normal file
110
server/public/model/draft.go
Normal 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)
|
||||
}
|
||||
67
server/public/model/draft_test.go
Normal file
67
server/public/model/draft_test.go
Normal 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)
|
||||
}
|
||||
37
server/public/model/email_notification.go
Normal file
37
server/public/model/email_notification.go
Normal 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
|
||||
}
|
||||
111
server/public/model/emoji.go
Normal file
111
server/public/model/emoji.go
Normal 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
|
||||
}
|
||||
4473
server/public/model/emoji_data.go
Normal file
4473
server/public/model/emoji_data.go
Normal file
File diff suppressed because it is too large
Load Diff
9
server/public/model/emoji_search.go
Normal file
9
server/public/model/emoji_search.go
Normal 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"`
|
||||
}
|
||||
64
server/public/model/emoji_test.go
Normal file
64
server/public/model/emoji_test.go
Normal 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())
|
||||
}
|
||||
28
server/public/model/ephemeral_mode.go
Normal file
28
server/public/model/ephemeral_mode.go
Normal 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
Reference in New Issue
Block a user