Files
upstream-mattermost/server/channels/app/property_value.go
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

446 lines
18 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
func (a *App) resolveValueBroadcastParams(rctx request.CTX, objectType, targetID string) (teamID, channelID string, err *model.AppError) {
switch objectType {
case model.PropertyFieldObjectTypePost:
post, appErr := a.GetSinglePost(rctx, targetID, false)
if appErr != nil {
return "", "", appErr
}
return "", post.ChannelId, nil
case model.PropertyFieldObjectTypeChannel:
return "", targetID, nil
case model.PropertyFieldObjectTypeUser:
return "", "", nil // system-wide
case model.PropertyFieldObjectTypeSystem:
return "", "", nil // system-wide
default:
return "", "", model.NewAppError(
"resolveValueBroadcastParams",
"app.property_value.resolve_broadcast_params.unknown_object_type.app_error",
map[string]any{"ObjectType": objectType},
"unrecognized object type",
http.StatusBadRequest,
)
}
}
// invalidateUserPropertyValuesEpochs marks the AttributeView stale and drops the cached CPA epoch
// for every distinct user the values target. It keys off each value's own TargetType/TargetID
// rather than a caller-supplied object type, to stay correct for plugin callers that pass none.
//
// Both halves must move together: the epoch alone forces a refetch that renders from a stale view
// and then caches that under the new ETag, and the view alone leaves the client 304ing on content
// sanitized under the old attributes.
func (a *App) invalidateUserPropertyValuesEpochs(values []*model.PropertyValue) {
seen := make(map[string]bool, len(values))
for _, v := range values {
if v == nil || v.TargetType != model.PropertyFieldObjectTypeUser || seen[v.TargetID] {
continue
}
seen[v.TargetID] = true
}
if len(seen) == 0 {
return
}
a.invalidateAttributeViewCache()
// Only the epoch half is gated: nothing reads it while ABAC is off, and each invalidation
// costs a cluster message. A flip clears both caches wholesale, so nothing skipped here
// survives as a stale entry.
if !a.attributeBasedAccessControlEnabled() {
return
}
for targetID := range seen {
a.Srv().Store().Attributes().InvalidateUserPropertyValuesEpoch(targetID)
}
}
// invalidateUserPropertyValuesEpoch is the single-target form, for delete paths that have a target
// ID rather than a set of values. Same split gate as above.
func (a *App) invalidateUserPropertyValuesEpoch(targetType, targetID string) {
if targetType != model.PropertyFieldObjectTypeUser {
return
}
a.invalidateAttributeViewCache()
if !a.attributeBasedAccessControlEnabled() {
return
}
a.Srv().Store().Attributes().InvalidateUserPropertyValuesEpoch(targetID)
}
// invalidateAllUserAttributeCaches marks the AttributeView stale and drops every cached CPA epoch.
// For field mutations, which change what every subject resolves to without touching a single
// PropertyValues row, and so are invisible to an epoch computed from that table.
func (a *App) invalidateAllUserAttributeCaches() {
a.invalidateAttributeViewCache()
if !a.attributeBasedAccessControlEnabled() {
return
}
a.Srv().Store().Attributes().ClearUserPropertyValuesEpochCache()
}
// CreatePropertyValue creates a new property value.
func (a *App) CreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, *model.AppError) {
if value == nil {
return nil, model.NewAppError("CreatePropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest)
}
value.Value = model.SanitizePropertyValue(value.Value)
createdValue, err := a.Srv().propertyService.CreatePropertyValue(rctx, value)
if err != nil {
if appErr := mapPropertyServiceError("CreatePropertyValue", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("CreatePropertyValue", "app.property_value.create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.invalidateUserPropertyValuesEpochs([]*model.PropertyValue{createdValue})
return createdValue, nil
}
// CreatePropertyValues creates multiple property values.
func (a *App) CreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, *model.AppError) {
if len(values) == 0 {
return nil, model.NewAppError("CreatePropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest)
}
for _, v := range values {
v.Value = model.SanitizePropertyValue(v.Value)
}
createdValues, err := a.Srv().propertyService.CreatePropertyValues(rctx, values)
if err != nil {
if appErr := mapPropertyServiceError("CreatePropertyValues", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("CreatePropertyValues", "app.property_value.create_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.invalidateUserPropertyValuesEpochs(createdValues)
return createdValues, nil
}
// GetPropertyValue retrieves a property value by group ID and value ID.
func (a *App) GetPropertyValue(rctx request.CTX, groupID, valueID string) (*model.PropertyValue, *model.AppError) {
value, err := a.Srv().propertyService.GetPropertyValue(rctx, groupID, valueID)
if err != nil {
if appErr := mapPropertyServiceError("GetPropertyValue", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("GetPropertyValue", "app.property_value.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return value, nil
}
// GetPropertyValues retrieves multiple property values by their IDs.
func (a *App) GetPropertyValues(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyValue, *model.AppError) {
values, err := a.Srv().propertyService.GetPropertyValues(rctx, groupID, ids)
if err != nil {
if appErr := mapPropertyServiceError("GetPropertyValues", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("GetPropertyValues", "app.property_value.get_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return values, nil
}
// SearchPropertyValues searches for property values matching the given options.
func (a *App) SearchPropertyValues(rctx request.CTX, groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, *model.AppError) {
values, err := a.Srv().propertyService.SearchPropertyValues(rctx, groupID, opts)
if err != nil {
if appErr := mapPropertyServiceError("SearchPropertyValues", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("SearchPropertyValues", "app.property_value.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return values, nil
}
// UpdatePropertyValue updates an existing property value.
func (a *App) UpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, *model.AppError) {
if value == nil {
return nil, model.NewAppError("UpdatePropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest)
}
value.Value = model.SanitizePropertyValue(value.Value)
updatedValue, err := a.Srv().propertyService.UpdatePropertyValue(rctx, groupID, value)
if err != nil {
if appErr := mapPropertyServiceError("UpdatePropertyValue", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("UpdatePropertyValue", "app.property_value.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.invalidateUserPropertyValuesEpochs([]*model.PropertyValue{updatedValue})
return updatedValue, nil
}
// UpdatePropertyValues updates multiple property values.
func (a *App) UpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, *model.AppError) {
if len(values) == 0 {
return nil, model.NewAppError("UpdatePropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest)
}
for _, v := range values {
v.Value = model.SanitizePropertyValue(v.Value)
}
updatedValues, err := a.Srv().propertyService.UpdatePropertyValues(rctx, groupID, values)
if err != nil {
if appErr := mapPropertyServiceError("UpdatePropertyValues", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("UpdatePropertyValues", "app.property_value.update_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.invalidateUserPropertyValuesEpochs(updatedValues)
return updatedValues, nil
}
// UpsertPropertyValue creates or updates a property value.
func (a *App) UpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, *model.AppError) {
if value == nil {
return nil, model.NewAppError("UpsertPropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest)
}
value.Value = model.SanitizePropertyValue(value.Value)
upsertedValue, err := a.Srv().propertyService.UpsertPropertyValue(rctx, value)
if err != nil {
if appErr := mapPropertyServiceError("UpsertPropertyValue", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("UpsertPropertyValue", "app.property_value.upsert.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.invalidateUserPropertyValuesEpochs([]*model.PropertyValue{upsertedValue})
return upsertedValue, nil
}
// UpsertPropertyValues creates or updates multiple property values.
// When objectType is non-empty, WebSocket events are broadcast to notify
// clients of the updated values, and every referenced field is required
// to have a matching ObjectType.
func (a *App) UpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue, objectType, targetID, connectionID string) ([]*model.PropertyValue, *model.AppError) {
if len(values) == 0 {
return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest)
}
// Intrinsic invariants — apply to every caller (HTTP, plugin, internal).
// Single-group invariant must run before the bulk-load below, since
// GetPropertyFields takes a single groupID. Guard values[0] explicitly
// because the per-element nil check inside the loop would otherwise be
// reached after the values[0].GroupID dereference.
if values[0] == nil {
return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "nil property value in batch", http.StatusBadRequest)
}
groupID := values[0].GroupID
seenIDs := make(map[string]bool, len(values))
fieldIDs := make([]string, 0, len(values))
for _, v := range values {
if v == nil {
return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "nil property value in batch", http.StatusBadRequest)
}
if v.GroupID != groupID {
return nil, model.NewAppError(
"UpsertPropertyValues",
"app.property_value.upsert.mixed_groups.app_error",
nil,
"all values in a batch must belong to the same group",
http.StatusBadRequest,
)
}
if !model.IsValidId(v.FieldID) {
return nil, model.NewAppError(
"UpsertPropertyValues",
"app.property_value.upsert.invalid_field_id.app_error",
map[string]any{"FieldID": v.FieldID},
"invalid field ID",
http.StatusBadRequest,
)
}
if seenIDs[v.FieldID] {
return nil, model.NewAppError(
"UpsertPropertyValues",
"app.property_value.upsert.duplicate_field_id.app_error",
map[string]any{"FieldID": v.FieldID},
"duplicate field ID in batch",
http.StatusBadRequest,
)
}
seenIDs[v.FieldID] = true
fieldIDs = append(fieldIDs, v.FieldID)
v.Value = model.SanitizePropertyValue(v.Value)
}
// ObjectType-mismatch check is gated on a non-empty objectType argument.
// Plugin API today always passes objectType="" and keeps its loose
// contract on this specific check.
if objectType != "" {
fields, fieldsErr := a.GetPropertyFields(rctx, groupID, fieldIDs)
if fieldsErr != nil {
return nil, fieldsErr
}
fieldByID := make(map[string]*model.PropertyField, len(fields))
for _, f := range fields {
fieldByID[f.ID] = f
}
for _, v := range values {
f, ok := fieldByID[v.FieldID]
if !ok {
return nil, model.NewAppError(
"UpsertPropertyValues",
"app.property_value.upsert.field_not_found.app_error",
map[string]any{"FieldID": v.FieldID},
"field not found",
http.StatusNotFound,
)
}
if f.ObjectType != objectType {
// 404 matches the shape of a non-existent field so callers
// cannot distinguish "no such field" from "field exists but
// in a different object-type bucket".
return nil, model.NewAppError(
"UpsertPropertyValues",
"app.property_value.upsert.object_type_mismatch.app_error",
map[string]any{"FieldID": v.FieldID},
"object type mismatch",
http.StatusNotFound,
)
}
}
}
result, err := a.Srv().propertyService.UpsertPropertyValues(rctx, values)
if err != nil {
if appErr := mapPropertyServiceError("UpsertPropertyValues", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.upsert_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.invalidateUserPropertyValuesEpochs(result)
// Only publish websocket events for PSAv2 properties (those with an ObjectType)
if objectType != "" {
teamID, channelID, appErr := a.resolveValueBroadcastParams(rctx, objectType, targetID)
if appErr != nil {
rctx.Logger().Warn("Failed to resolve broadcast params for property values", mlog.Err(appErr))
} else {
valuesJSON, jsonErr := json.Marshal(result)
if jsonErr != nil {
rctx.Logger().Warn("Failed to encode property values to JSON", mlog.Err(jsonErr))
} else {
message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, teamID, channelID, "", nil, connectionID)
message.Add("object_type", objectType)
message.Add("target_id", targetID)
message.Add("values", string(valuesJSON))
a.Publish(message)
}
}
}
return result, nil
}
// DeletePropertyValue deletes a property value and broadcasts a property_values_updated event.
func (a *App) DeletePropertyValue(rctx request.CTX, groupID, valueID string) *model.AppError {
value, appErr := a.GetPropertyValue(rctx, groupID, valueID)
if appErr != nil {
return appErr
}
if err := a.Srv().propertyService.DeletePropertyValue(rctx, groupID, valueID); err != nil {
if mappedErr := mapPropertyServiceError("DeletePropertyValue", err); mappedErr != nil {
return mappedErr
}
return model.NewAppError("DeletePropertyValue", "app.property_value.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.invalidateUserPropertyValuesEpoch(value.TargetType, value.TargetID)
teamID, channelID, appErr := a.resolveValueBroadcastParams(rctx, value.TargetType, value.TargetID)
if appErr != nil {
rctx.Logger().Warn("Failed to resolve broadcast params for property value deletion", mlog.Err(appErr))
return nil
}
deleted := &model.PropertyValue{
TargetID: value.TargetID,
TargetType: value.TargetType,
GroupID: value.GroupID,
FieldID: value.FieldID,
}
valuesJSON, jsonErr := json.Marshal([]*model.PropertyValue{deleted})
if jsonErr != nil {
rctx.Logger().Warn("Failed to encode deleted property value to JSON", mlog.Err(jsonErr))
return nil
}
message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, teamID, channelID, "", nil, "")
message.Add("object_type", value.TargetType)
message.Add("target_id", value.TargetID)
message.Add("values", string(valuesJSON))
a.Publish(message)
return nil
}
// DeletePropertyValuesForTarget deletes all property values for a target and broadcasts a property_values_updated event.
func (a *App) DeletePropertyValuesForTarget(rctx request.CTX, groupID, targetType, targetID string) *model.AppError {
if err := a.Srv().propertyService.DeletePropertyValuesForTarget(rctx, groupID, targetType, targetID); err != nil {
if appErr := mapPropertyServiceError("DeletePropertyValuesForTarget", err); appErr != nil {
return appErr
}
return model.NewAppError("DeletePropertyValuesForTarget", "app.property_value.delete_for_target.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.invalidateUserPropertyValuesEpoch(targetType, targetID)
teamID, channelID, appErr := a.resolveValueBroadcastParams(rctx, targetType, targetID)
if appErr != nil {
rctx.Logger().Warn("Failed to resolve broadcast params for property value deletion", mlog.Err(appErr))
return nil
}
message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, teamID, channelID, "", nil, "")
message.Add("object_type", targetType)
message.Add("target_id", targetID)
message.Add("values", "[]")
a.Publish(message)
return nil
}
// DeletePropertyValuesForField deletes all property values for a field and broadcasts a property_values_updated event.
func (a *App) DeletePropertyValuesForField(rctx request.CTX, groupID, fieldID string) *model.AppError {
if err := a.Srv().propertyService.DeletePropertyValuesForField(rctx, groupID, fieldID); err != nil {
if appErr := mapPropertyServiceError("DeletePropertyValuesForField", err); appErr != nil {
return appErr
}
return model.NewAppError("DeletePropertyValuesForField", "app.property_value.delete_for_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// A field delete can drop values for many users at once, so clear the whole epoch cache
// rather than trying to enumerate the affected users.
a.invalidateAllUserAttributeCaches()
message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, "", "", "", nil, "")
message.Add("field_id", fieldID)
message.Add("values", "[]")
a.Publish(message)
return nil
}