mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
475 lines
19 KiB
Go
475 lines
19 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/shared/mlog"
|
|
"github.com/hanzoteam/server/server/public/shared/request"
|
|
)
|
|
|
|
// propertyFieldOptionsEqual reports whether two values from
|
|
// PropertyField.Attrs[options] are equivalent. Used to detect a no-op options
|
|
// patch on a linked field — see UpdatePropertyFields' linked-field invariants.
|
|
// Both nil/zero forms compare equal; otherwise reflect.DeepEqual handles the
|
|
// nested map/slice shape produced by JSON unmarshalling.
|
|
func propertyFieldOptionsEqual(a, b any) bool {
|
|
if a == nil && b == nil {
|
|
return true
|
|
}
|
|
return reflect.DeepEqual(a, b)
|
|
}
|
|
|
|
func propertyFieldBroadcastParams(rctx request.CTX, field *model.PropertyField) (teamID, channelID string, ok bool) {
|
|
switch field.TargetType {
|
|
case "team":
|
|
return field.TargetID, "", true
|
|
case "channel":
|
|
return "", field.TargetID, true
|
|
case "system":
|
|
return "", "", true
|
|
default:
|
|
rctx.Logger().Warn(
|
|
"Unrecognized property field TargetType, skipping broadcast",
|
|
mlog.String("target_type", field.TargetType),
|
|
mlog.String("field_id", field.ID),
|
|
)
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func (a *App) publishPropertyFieldEvent(rctx request.CTX, eventType model.WebsocketEventType, field *model.PropertyField, connectionID string) {
|
|
if field == nil || field.IsPSAv1() {
|
|
return
|
|
}
|
|
teamID, channelID, ok := propertyFieldBroadcastParams(rctx, field)
|
|
if !ok {
|
|
return
|
|
}
|
|
fieldJSON, err := json.Marshal(field)
|
|
if err != nil {
|
|
rctx.Logger().Warn("Failed to encode property field to JSON", mlog.Err(err))
|
|
return
|
|
}
|
|
message := model.NewWebSocketEvent(eventType, teamID, channelID, "", nil, connectionID)
|
|
message.Add("property_field", string(fieldJSON))
|
|
message.Add("object_type", field.ObjectType)
|
|
a.Publish(message)
|
|
}
|
|
|
|
// rankPropertyFieldGate blocks the user-facing "rank" custom profile attribute
|
|
// type while the PropertyFieldRank feature flag is disabled. Consolidated here
|
|
// so that both CreatePropertyField (which blocks creating a rank field) and
|
|
// UpdatePropertyFields (which blocks converting an existing field to rank)
|
|
// share a single check.
|
|
//
|
|
// The gate is scoped to user-object fields, because that is the only place the
|
|
// user-facing rank type can originate: createCPAField forces ObjectType=user
|
|
// before reaching CreatePropertyField. Rank fields on other object types are
|
|
// intentionally exempt — in particular the classification-markings fields
|
|
// (template/system/channel in the access_control group) legitimately use the
|
|
// rank type and ship behind the separate, GA-by-default ClassificationMarkings
|
|
// flag. Gating those here would break the classification admin panel (create
|
|
// and edit alike) whenever PropertyFieldRank is off.
|
|
func (a *App) rankPropertyFieldGate(where string, field *model.PropertyField) *model.AppError {
|
|
if field == nil || field.Type != model.PropertyFieldTypeRank {
|
|
return nil
|
|
}
|
|
if field.ObjectType != model.PropertyFieldObjectTypeUser {
|
|
return nil
|
|
}
|
|
if a.Config().FeatureFlags.PropertyFieldRank {
|
|
return nil
|
|
}
|
|
return model.NewAppError(
|
|
where,
|
|
"app.property_field.rank_disabled.app_error",
|
|
nil,
|
|
"rank property fields are not enabled",
|
|
http.StatusBadRequest,
|
|
)
|
|
}
|
|
|
|
// CreatePropertyField creates a new property field.
|
|
func (a *App) CreatePropertyField(rctx request.CTX, field *model.PropertyField, bypassProtectedCheck bool, connectionID string) (*model.PropertyField, *model.AppError) {
|
|
if field == nil {
|
|
return nil, model.NewAppError("CreatePropertyField", "app.property_field.invalid_input.app_error", nil, "property field is required", http.StatusBadRequest)
|
|
}
|
|
|
|
// Intrinsic invariants (apply to every caller — HTTP, plugin, internal).
|
|
CanonicalizeSystemObjectField(field)
|
|
field.Name = strings.TrimSpace(field.Name)
|
|
|
|
if appErr := a.rankPropertyFieldGate("CreatePropertyField", field); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
if !bypassProtectedCheck && field.Protected {
|
|
return nil, model.NewAppError(
|
|
"CreatePropertyField",
|
|
"app.property_field.create.protected.app_error",
|
|
nil,
|
|
"cannot create protected field",
|
|
http.StatusBadRequest,
|
|
)
|
|
}
|
|
|
|
createdField, err := a.Srv().propertyService.CreatePropertyField(rctx, field)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("CreatePropertyField", err); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("CreatePropertyField", "app.property_field.create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
a.publishPropertyFieldEvent(rctx, model.WebsocketEventPropertyFieldCreated, createdField, connectionID)
|
|
|
|
return createdField, nil
|
|
}
|
|
|
|
// GetPropertyField retrieves a property field by group ID and field ID.
|
|
func (a *App) GetPropertyField(rctx request.CTX, groupID, fieldID string) (*model.PropertyField, *model.AppError) {
|
|
field, err := a.Srv().propertyService.GetPropertyField(rctx, groupID, fieldID)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("GetPropertyField", err); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("GetPropertyField", "app.property_field.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return field, nil
|
|
}
|
|
|
|
// GetPropertyFields retrieves multiple property fields by their IDs.
|
|
func (a *App) GetPropertyFields(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyField, *model.AppError) {
|
|
fields, err := a.Srv().propertyService.GetPropertyFields(rctx, groupID, ids)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("GetPropertyFields", err); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("GetPropertyFields", "app.property_field.get_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
// GetPropertyFieldByName retrieves a property field by name within a group and target.
|
|
//
|
|
// Deprecated: name is not unique within a group when fields of different object
|
|
// types share a name. Use GetPropertyFieldByNameForObjectType to scope the
|
|
// lookup to a single object type and get a deterministic result.
|
|
func (a *App) GetPropertyFieldByName(rctx request.CTX, groupID, targetID, name string) (*model.PropertyField, *model.AppError) {
|
|
field, err := a.Srv().propertyService.GetPropertyFieldByName(rctx, groupID, targetID, name)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("GetPropertyFieldByName", err); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("GetPropertyFieldByName", "app.property_field.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return field, nil
|
|
}
|
|
|
|
// GetPropertyFieldByNameForObjectType retrieves a property field by name within
|
|
// a group and target, scoped to a single object type. Prefer this over
|
|
// GetPropertyFieldByName when a name may be shared across object types within a
|
|
// group (e.g. the access_control "classification" fields).
|
|
func (a *App) GetPropertyFieldByNameForObjectType(rctx request.CTX, groupID, targetID, objectType, name string) (*model.PropertyField, *model.AppError) {
|
|
field, err := a.Srv().propertyService.GetPropertyFieldByNameForObjectType(rctx, groupID, targetID, objectType, name)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("GetPropertyFieldByNameForObjectType", err); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("GetPropertyFieldByNameForObjectType", "app.property_field.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return field, nil
|
|
}
|
|
|
|
// SearchPropertyFields searches for property fields matching the given options.
|
|
func (a *App) SearchPropertyFields(rctx request.CTX, groupID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, *model.AppError) {
|
|
fields, err := a.Srv().propertyService.SearchPropertyFields(rctx, groupID, opts)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("SearchPropertyFields", err); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("SearchPropertyFields", "app.property_field.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
// GetPropertyFieldsForGroup retrieves all active property fields for a group.
|
|
func (a *App) GetPropertyFieldsForGroup(rctx request.CTX, groupID string) ([]*model.PropertyField, *model.AppError) {
|
|
fields, err := a.Srv().propertyService.GetPropertyFieldsForGroup(rctx, groupID)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("GetPropertyFieldsForGroup", err); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("GetPropertyFieldsForGroup", "app.property_field.get_for_group.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
// CountPropertyFieldsForGroup counts property fields for a group.
|
|
func (a *App) CountPropertyFieldsForGroup(rctx request.CTX, groupID string, includeDeleted bool) (int64, *model.AppError) {
|
|
var count int64
|
|
var err error
|
|
if includeDeleted {
|
|
count, err = a.Srv().propertyService.CountAllPropertyFieldsForGroup(rctx, groupID)
|
|
} else {
|
|
count, err = a.Srv().propertyService.CountActivePropertyFieldsForGroup(rctx, groupID)
|
|
}
|
|
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("CountPropertyFieldsForGroup", err); appErr != nil {
|
|
return 0, appErr
|
|
}
|
|
return 0, model.NewAppError("CountPropertyFieldsForGroup", "app.property_field.count_for_group.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// CountPropertyFieldsForTarget counts property fields for a specific target.
|
|
func (a *App) CountPropertyFieldsForTarget(rctx request.CTX, groupID, targetType, targetID string, includeDeleted bool) (int64, *model.AppError) {
|
|
var count int64
|
|
var err error
|
|
if includeDeleted {
|
|
count, err = a.Srv().propertyService.CountAllPropertyFieldsForTarget(rctx, groupID, targetType, targetID)
|
|
} else {
|
|
count, err = a.Srv().propertyService.CountActivePropertyFieldsForTarget(rctx, groupID, targetType, targetID)
|
|
}
|
|
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("CountPropertyFieldsForTarget", err); appErr != nil {
|
|
return 0, appErr
|
|
}
|
|
return 0, model.NewAppError("CountPropertyFieldsForTarget", "app.property_field.count_for_target.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// UpdatePropertyField updates an existing property field. The second return
|
|
// value lists the IDs of fields whose dependent property values were cleared
|
|
// as a side effect (e.g. by TypeChangeValueCleanupHook on a type change).
|
|
// Hooks may cascade clears to other fields, so the slice is not necessarily
|
|
// limited to the updated field's own ID.
|
|
func (a *App) UpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField, bypassProtectedCheck bool, connectionID string) (*model.PropertyField, []string, *model.AppError) {
|
|
fields, clearedIDs, err := a.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field}, bypassProtectedCheck, connectionID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return fields[0], clearedIDs, nil
|
|
}
|
|
|
|
// UpdatePropertyFields updates multiple property fields. The second return
|
|
// value lists the IDs of fields whose dependent property values were cleared
|
|
// as a side effect.
|
|
func (a *App) UpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField, bypassProtectedCheck bool, connectionID string) ([]*model.PropertyField, []string, *model.AppError) {
|
|
if len(fields) == 0 {
|
|
return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.invalid_input.app_error", nil, "property fields are required", http.StatusBadRequest)
|
|
}
|
|
|
|
// Intrinsic invariants — apply to every caller (HTTP, plugin, internal).
|
|
// Service returns DB-order, not input-order, so we'll build a lookup map
|
|
// keyed by ID below; collect IDs in this same pass.
|
|
ids := make([]string, len(fields))
|
|
for i, f := range fields {
|
|
f.Name = strings.TrimSpace(f.Name)
|
|
ids[i] = f.ID
|
|
}
|
|
|
|
// Load existing fields once. Used for: protected-check (gated by
|
|
// bypassProtectedCheck), PSAv1 reject (always-on), linked-field diff
|
|
// invariants (always-on).
|
|
|
|
existingFields, err := a.Srv().propertyService.GetPropertyFields(rctx, groupID, ids)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("UpdatePropertyFields", err); appErr != nil {
|
|
return nil, nil, appErr
|
|
}
|
|
return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.get_existing.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
existingByID := make(map[string]*model.PropertyField, len(existingFields))
|
|
for _, ex := range existingFields {
|
|
existingByID[ex.ID] = ex
|
|
}
|
|
|
|
for _, f := range fields {
|
|
existing, ok := existingByID[f.ID]
|
|
if !ok {
|
|
// Service-level GetPropertyFields returns an ErrResultsMismatch when
|
|
// any input ID is missing, so this branch is defensive.
|
|
continue
|
|
}
|
|
|
|
// Rank-type gate: block converting a field to rank while the feature
|
|
// flag is off (shares the create-path check).
|
|
if appErr := a.rankPropertyFieldGate("UpdatePropertyFields", f); appErr != nil {
|
|
return nil, nil, appErr
|
|
}
|
|
|
|
// Linked-field diff invariants. "Linked" = LinkedFieldID != nil &&
|
|
// *LinkedFieldID != "". Unlink (nil or "") is always allowed when
|
|
// existing was linked.
|
|
existingLinked := existing.LinkedFieldID != nil && *existing.LinkedFieldID != ""
|
|
incomingLinked := f.LinkedFieldID != nil && *f.LinkedFieldID != ""
|
|
|
|
if existingLinked {
|
|
if f.Type != existing.Type {
|
|
return nil, nil, model.NewAppError(
|
|
"UpdatePropertyFields",
|
|
"app.property_field.update.linked_type_change.app_error",
|
|
map[string]any{"FieldID": existing.ID},
|
|
"cannot modify type of a linked field",
|
|
http.StatusBadRequest,
|
|
)
|
|
}
|
|
// Compare the options portion of Attrs.
|
|
var existingOpts, incomingOpts any
|
|
if existing.Attrs != nil {
|
|
existingOpts = existing.Attrs[model.PropertyFieldAttributeOptions]
|
|
}
|
|
if f.Attrs != nil {
|
|
incomingOpts = f.Attrs[model.PropertyFieldAttributeOptions]
|
|
}
|
|
if !propertyFieldOptionsEqual(existingOpts, incomingOpts) {
|
|
return nil, nil, model.NewAppError(
|
|
"UpdatePropertyFields",
|
|
"app.property_field.update.linked_options_change.app_error",
|
|
map[string]any{"FieldID": existing.ID},
|
|
"cannot modify options of a linked field",
|
|
http.StatusBadRequest,
|
|
)
|
|
}
|
|
if incomingLinked && *f.LinkedFieldID != *existing.LinkedFieldID {
|
|
return nil, nil, model.NewAppError(
|
|
"UpdatePropertyFields",
|
|
"app.property_field.update.cannot_change_link_target.app_error",
|
|
map[string]any{"FieldID": existing.ID},
|
|
"cannot change link target",
|
|
http.StatusBadRequest,
|
|
)
|
|
}
|
|
} else if incomingLinked {
|
|
return nil, nil, model.NewAppError(
|
|
"UpdatePropertyFields",
|
|
"app.property_field.update.cannot_link_existing.app_error",
|
|
map[string]any{"FieldID": existing.ID},
|
|
"linked_field_id can only be set at creation time",
|
|
http.StatusBadRequest,
|
|
)
|
|
}
|
|
|
|
// Protected-check is the only invariant gated on the caller's opt-out.
|
|
if !bypassProtectedCheck && existing.Protected {
|
|
return nil, nil, model.NewAppError(
|
|
"UpdatePropertyFields",
|
|
"app.property_field.update.protected.app_error",
|
|
map[string]any{"FieldID": existing.ID},
|
|
"cannot update protected field",
|
|
http.StatusForbidden,
|
|
)
|
|
}
|
|
}
|
|
|
|
updated, propagated, clearedFieldIDs, err := a.Srv().propertyService.UpdatePropertyFields(rctx, groupID, fields)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("UpdatePropertyFields", err); appErr != nil {
|
|
return nil, nil, appErr
|
|
}
|
|
return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
// Notify the access control service so any per-field metadata it
|
|
// caches (e.g. the rank-by-name lookup used by the live evaluator)
|
|
// and any compiled-policy cache entries that depend on this field
|
|
// are dropped. This runs before the websocket broadcast so a client
|
|
// reacting to the event never re-reads stale cached metadata (mirrors
|
|
// the ordering in DeletePropertyField).
|
|
if acs := a.Srv().ch.AccessControl; acs != nil {
|
|
for _, field := range updated {
|
|
acs.OnPropertyFieldOptionsChanged(rctx, field.ID)
|
|
}
|
|
for _, field := range propagated {
|
|
acs.OnPropertyFieldOptionsChanged(rctx, field.ID)
|
|
}
|
|
}
|
|
|
|
// Broadcast websocket events for both requested and propagated fields
|
|
for _, field := range updated {
|
|
a.publishPropertyFieldEvent(rctx, model.WebsocketEventPropertyFieldUpdated, field, connectionID)
|
|
}
|
|
for _, field := range propagated {
|
|
a.publishPropertyFieldEvent(rctx, model.WebsocketEventPropertyFieldUpdated, field, "")
|
|
}
|
|
|
|
// For each field whose dependent values were cleared as a side effect of
|
|
// the update (e.g. a type change handled by TypeChangeValueCleanupHook),
|
|
// publish the generic property_values_updated event so subscribers refresh
|
|
// their local caches. Mirrors App.DeletePropertyValuesForField's wire shape.
|
|
for _, fieldID := range clearedFieldIDs {
|
|
message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, "", "", "", nil, "")
|
|
message.Add("field_id", fieldID)
|
|
message.Add("values", "[]")
|
|
a.Publish(message)
|
|
}
|
|
|
|
return updated, clearedFieldIDs, nil
|
|
}
|
|
|
|
// DeletePropertyField deletes a property field.
|
|
func (a *App) DeletePropertyField(rctx request.CTX, groupID, id string, bypassProtectedCheck bool, connectionID string) *model.AppError {
|
|
existing, err := a.Srv().propertyService.GetPropertyField(rctx, groupID, id)
|
|
if err != nil {
|
|
if appErr := mapPropertyServiceError("DeletePropertyField", err); appErr != nil {
|
|
return appErr
|
|
}
|
|
return model.NewAppError("DeletePropertyField", "app.property_field.delete.get_existing.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
if existing == nil {
|
|
return model.NewAppError("DeletePropertyField", "app.property_field.delete.not_found.app_error", nil, "", http.StatusNotFound)
|
|
}
|
|
|
|
if !bypassProtectedCheck && existing.Protected {
|
|
return model.NewAppError(
|
|
"DeletePropertyField",
|
|
"app.property_field.delete.protected.app_error",
|
|
nil,
|
|
"cannot delete protected field",
|
|
http.StatusForbidden,
|
|
)
|
|
}
|
|
|
|
if err := a.Srv().propertyService.DeletePropertyField(rctx, groupID, id); err != nil {
|
|
if appErr := mapPropertyServiceError("DeletePropertyField", err); appErr != nil {
|
|
return appErr
|
|
}
|
|
return model.NewAppError("DeletePropertyField", "app.property_field.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
// Notify the access control service so any per-field metadata it caches
|
|
// (e.g. the rank-by-name lookup used by the live evaluator) and any
|
|
// compiled-policy cache entries that depend on this field are dropped
|
|
// cluster-wide. Without this a deleted rank field's stale options would
|
|
// linger in the per-node cache until restart.
|
|
if acs := a.Srv().ch.AccessControl; acs != nil {
|
|
acs.OnPropertyFieldOptionsChanged(rctx, existing.ID)
|
|
}
|
|
|
|
if existing.IsPSAv2() {
|
|
teamID, channelID, ok := propertyFieldBroadcastParams(rctx, existing)
|
|
if ok {
|
|
message := model.NewWebSocketEvent(model.WebsocketEventPropertyFieldDeleted, teamID, channelID, "", nil, connectionID)
|
|
message.Add("field_id", existing.ID)
|
|
message.Add("object_type", existing.ObjectType)
|
|
a.Publish(message)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|