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.
313 lines
11 KiB
Go
313 lines
11 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package sqlstore
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/shared/request"
|
|
"github.com/hanzoteam/server/server/v8/channels/store"
|
|
"github.com/hanzoteam/server/server/v8/einterfaces"
|
|
sq "github.com/mattermost/squirrel"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
type SqlAttributesStore struct {
|
|
*SqlStore
|
|
metrics einterfaces.MetricsInterface
|
|
|
|
selectQueryBuilder sq.SelectBuilder
|
|
}
|
|
|
|
func attributesSliceColumns(prefix ...string) []string {
|
|
var p string
|
|
if len(prefix) == 1 {
|
|
p = prefix[0] + "."
|
|
} else if len(prefix) > 1 {
|
|
panic("cannot accept multiple prefixes")
|
|
}
|
|
|
|
return []string{
|
|
p + "TargetID as ID",
|
|
p + "TargetType as Type",
|
|
p + "Attributes",
|
|
}
|
|
}
|
|
|
|
// qualify prefixes each column with the given table name. The members-to-remove
|
|
// queries join Users, whose columns (Roles, DeleteAt, CreateAt) collide with the
|
|
// member columns, so the member SELECT must be table-qualified to stay unambiguous.
|
|
func qualify(table string, columns []string) []string {
|
|
qualified := make([]string, len(columns))
|
|
for i, c := range columns {
|
|
qualified[i] = table + "." + c
|
|
}
|
|
return qualified
|
|
}
|
|
|
|
func newSqlAttributesStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.AttributesStore {
|
|
s := &SqlAttributesStore{
|
|
SqlStore: sqlStore,
|
|
metrics: metrics,
|
|
}
|
|
|
|
s.selectQueryBuilder = s.getQueryBuilder().Select(attributesSliceColumns()...).From("AttributeView")
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *SqlAttributesStore) RefreshAttributes() error {
|
|
if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW AttributeView"); err != nil {
|
|
return errors.Wrap(err, "error refreshing materialized view AttributeView")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlAttributesStore) GetSubject(rctx request.CTX, ID, groupID string) (*model.Subject, error) {
|
|
query := s.selectQueryBuilder.Where(sq.And{sq.Eq{"TargetID": ID}, sq.Eq{"GroupID": groupID}})
|
|
|
|
q, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to build query for subject")
|
|
}
|
|
|
|
row := s.GetReplica().QueryRowContext(rctx.Context(), q, args...)
|
|
|
|
var subject model.Subject
|
|
var properties []byte
|
|
|
|
if err := row.Scan(&subject.ID, &subject.Type, &properties); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Attributes", ID)
|
|
}
|
|
return nil, errors.Wrap(err, "failed to scan subject row")
|
|
}
|
|
|
|
if err := json.Unmarshal(properties, &subject.Attributes); err != nil {
|
|
return nil, errors.Wrap(err, "failed to unmarshal attributes")
|
|
}
|
|
|
|
return &subject, nil
|
|
}
|
|
|
|
func (s *SqlAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) {
|
|
query := s.getQueryBuilder().
|
|
Select(getUsersColumns()...).From("Users").LeftJoin("AttributeView ON Users.Id = AttributeView.TargetID").
|
|
OrderBy("Users.Id ASC")
|
|
|
|
count := s.getQueryBuilder().Select("COUNT(*)").From("Users").LeftJoin("AttributeView ON Users.Id = AttributeView.TargetID")
|
|
|
|
if opts.Query != "" {
|
|
// Wrap the CEL-derived expression in parentheses so that any top-level
|
|
// OR (e.g. produced by "has any of [a, b]") does not bind across the
|
|
// AND-joined WHERE clauses appended below (SubjectID, DeleteAt,
|
|
// TeamID, ExcludeChannelMembers, Cursor, Term). Without these
|
|
// parens, "A OR B AND Users.Id = $X" would be parsed as
|
|
// "A OR (B AND Users.Id = $X)" because AND binds tighter than OR.
|
|
wrapped := "(" + opts.Query + ")"
|
|
query = query.Where(sq.Expr(wrapped, opts.Args...))
|
|
count = count.Where(sq.Expr(wrapped, opts.Args...))
|
|
}
|
|
|
|
argCount := len(opts.Args)
|
|
|
|
if opts.Limit > 0 {
|
|
query = query.Limit(uint64(opts.Limit))
|
|
} else if opts.Limit > MaxPerPage {
|
|
query = query.Limit(uint64(MaxPerPage))
|
|
}
|
|
|
|
if !opts.AllowInactive {
|
|
query = query.Where("Users.DeleteAt = 0")
|
|
count = count.Where("Users.DeleteAt = 0")
|
|
}
|
|
|
|
if opts.TeamID != "" {
|
|
argCount++
|
|
query = query.Where(sq.Expr(fmt.Sprintf("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = $%d AND DeleteAt = 0)", argCount), opts.TeamID))
|
|
count = count.Where(sq.Expr(fmt.Sprintf("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = $%d AND DeleteAt = 0)", argCount), opts.TeamID))
|
|
}
|
|
|
|
if opts.ExcludeChannelMembers != "" {
|
|
argCount++
|
|
query = query.Where(sq.Expr(fmt.Sprintf("NOT EXISTS (SELECT 1 FROM ChannelMembers WHERE ChannelMembers.UserId = Users.Id AND ChannelMembers.ChannelId = $%d)", argCount), opts.ExcludeChannelMembers))
|
|
}
|
|
|
|
if opts.SubjectID != "" {
|
|
argCount++
|
|
query = query.Where(sq.Expr(fmt.Sprintf("Users.Id = $%d", argCount), opts.SubjectID))
|
|
count = count.Where(sq.Expr(fmt.Sprintf("Users.Id = $%d", argCount), opts.SubjectID))
|
|
}
|
|
|
|
if opts.Cursor.TargetID != "" {
|
|
argCount++
|
|
// Paginate on Users.Id (the ORDER BY column), not AttributeView.TargetID.
|
|
// The cursor value is a user id, and TargetID comes from a LEFT JOIN so it
|
|
// is NULL for users with no custom-attribute row — comparing against it
|
|
// silently drops those users (e.g. matches of a native-only policy).
|
|
query = query.Where(sq.Expr(fmt.Sprintf("Users.Id > $%d", argCount), opts.Cursor.TargetID))
|
|
}
|
|
|
|
termFields := UserSearchTypeNames
|
|
if opts.ExcludeFullNames {
|
|
termFields = UserSearchTypeNamesNoFullName
|
|
}
|
|
searchFields := make([]string, 0, len(termFields))
|
|
for _, field := range termFields {
|
|
searchFields = append(searchFields, strings.Join([]string{"Users", field}, "."))
|
|
}
|
|
|
|
if term := opts.Term; strings.TrimSpace(term) != "" {
|
|
_, query = generateSearchQueryForExpression(query, strings.Fields(term), searchFields, argCount)
|
|
_, count = generateSearchQueryForExpression(count, strings.Fields(term), searchFields, argCount)
|
|
}
|
|
|
|
q, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, 0, errors.Wrap(err, "failed to build query for subjects")
|
|
}
|
|
|
|
users := []*model.User{}
|
|
if err = s.GetReplica().Select(&users, q, args...); err != nil {
|
|
return nil, 0, errors.Wrapf(err, "failed to find Users with term=%s and searchType=%v", opts.Term, searchFields)
|
|
}
|
|
|
|
for _, u := range users {
|
|
u.Sanitize(map[string]bool{})
|
|
}
|
|
|
|
var total int64
|
|
|
|
if !opts.IgnoreCount {
|
|
err = s.GetReplica().GetBuilder(&total, count)
|
|
if err != nil {
|
|
return nil, 0, errors.Wrapf(err, "failed to count Users with term=%s and searchType=%v", opts.Term, searchFields)
|
|
}
|
|
}
|
|
|
|
return users, total, nil
|
|
}
|
|
|
|
func (s *SqlAttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) {
|
|
query := s.getQueryBuilder().
|
|
Select(qualify("ChannelMembers", channelMemberSliceColumns())...).From("ChannelMembers").
|
|
// Join Users so native-attribute expressions (e.g. Users.EmailVerified)
|
|
// resolve here, mirroring SearchUsers on the add path.
|
|
LeftJoin("Users ON Users.Id = ChannelMembers.UserId").
|
|
LeftJoin("AttributeView ON ChannelMembers.UserId = AttributeView.TargetID").
|
|
OrderBy("ChannelMembers.UserId ASC")
|
|
|
|
if opts.Query != "" {
|
|
// A member is removed when they do NOT satisfy the policy; a NULL result
|
|
// (e.g. a missing custom attribute) counts as "does not satisfy" via
|
|
// COALESCE. We must not additionally remove members just because they
|
|
// lack an AttributeView row — a native-only policy matches against the
|
|
// Users table, so a user with zero custom attributes can still satisfy it.
|
|
query = query.Where(sq.Expr(fmt.Sprintf("NOT COALESCE((%s), FALSE)", opts.Query), opts.Args...))
|
|
}
|
|
|
|
argCount := len(opts.Args)
|
|
|
|
argCount++
|
|
query = query.Where(sq.Expr(fmt.Sprintf("ChannelMembers.ChannelId = $%d", argCount), channelID))
|
|
|
|
if opts.Limit > 0 {
|
|
query = query.Limit(uint64(opts.Limit))
|
|
} else if opts.Limit > MaxPerPage {
|
|
query = query.Limit(uint64(MaxPerPage))
|
|
}
|
|
|
|
if opts.Cursor.TargetID != "" {
|
|
argCount++
|
|
query = query.Where(sq.Expr(fmt.Sprintf("ChannelMembers.UserId > $%d", argCount), opts.Cursor.TargetID))
|
|
}
|
|
|
|
q, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to build query for subjects")
|
|
}
|
|
|
|
members := []*model.ChannelMember{}
|
|
if err := s.GetReplica().Select(&members, q, args...); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find channel members with for channel id=%s", channelID)
|
|
}
|
|
|
|
return members, nil
|
|
}
|
|
|
|
func (s *SqlAttributesStore) GetTeamMembersToRemove(rctx request.CTX, teamID string, opts model.SubjectSearchOptions) ([]*model.TeamMember, error) {
|
|
query := s.getQueryBuilder().
|
|
Select(qualify("TeamMembers", teamMemberSliceColumns())...).From("TeamMembers").
|
|
// Join Users so native-attribute expressions (e.g. Users.EmailVerified)
|
|
// resolve here, mirroring SearchUsers on the add path.
|
|
LeftJoin("Users ON Users.Id = TeamMembers.UserId").
|
|
LeftJoin("AttributeView ON TeamMembers.UserId = AttributeView.TargetID").
|
|
Where("TeamMembers.DeleteAt = 0").
|
|
OrderBy("TeamMembers.UserId ASC")
|
|
|
|
if opts.Query != "" {
|
|
// A member is removed when they do NOT satisfy the policy; a NULL result
|
|
// (e.g. a missing custom attribute) counts as "does not satisfy" via
|
|
// COALESCE. We must not additionally remove members just because they
|
|
// lack an AttributeView row — a native-only policy matches against the
|
|
// Users table, so a user with zero custom attributes can still satisfy it.
|
|
query = query.Where(sq.Expr(fmt.Sprintf("NOT COALESCE((%s), FALSE)", opts.Query), opts.Args...))
|
|
}
|
|
|
|
argCount := len(opts.Args)
|
|
|
|
argCount++
|
|
query = query.Where(sq.Expr(fmt.Sprintf("TeamMembers.TeamId = $%d", argCount), teamID))
|
|
|
|
// An explicit limit is capped at MaxPerPage; an unset limit (0) intentionally
|
|
// returns every removal candidate for the team. The membership-sync caller
|
|
// consumes the full set in one pass, so capping an unset limit here would
|
|
// permanently leave members beyond the cap in a team they no longer qualify
|
|
// for. The result is naturally bounded by the team's membership.
|
|
if opts.Limit > 0 {
|
|
limit := min(opts.Limit, MaxPerPage)
|
|
query = query.Limit(uint64(limit))
|
|
}
|
|
|
|
if opts.Cursor.TargetID != "" {
|
|
argCount++
|
|
query = query.Where(sq.Expr(fmt.Sprintf("TeamMembers.UserId > $%d", argCount), opts.Cursor.TargetID))
|
|
}
|
|
|
|
q, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to build query for subjects")
|
|
}
|
|
|
|
members := []*model.TeamMember{}
|
|
if err := s.GetReplica().Select(&members, q, args...); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find team members for team id=%s", teamID)
|
|
}
|
|
|
|
return members, nil
|
|
}
|
|
|
|
func generateSearchQueryForExpression(query sq.SelectBuilder, terms []string, fields []string, prevArgs int) (int, sq.SelectBuilder) {
|
|
for _, term := range terms {
|
|
searchFields := []string{}
|
|
termArgs := []any{}
|
|
for _, field := range fields {
|
|
prevArgs++
|
|
searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", field, prevArgs))
|
|
termArgs = append(termArgs, fmt.Sprintf("%%%s%%", strings.TrimLeft(term, "@")))
|
|
}
|
|
prevArgs++
|
|
searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", "Id", prevArgs))
|
|
termArgs = append(termArgs, strings.TrimLeft(term, "@"))
|
|
query = query.Where(fmt.Sprintf("(%s)", strings.Join(searchFields, " OR ")), termArgs...)
|
|
}
|
|
|
|
return prevArgs, query
|
|
}
|