https://mattermost.atlassian.net/browse/MM-52079

```release-note
We upgrade the module version to 8.0. The new module path is github.com/mattermost-server/server/v8.
```


Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
Этот коммит содержится в:
Agniva De Sarker
2023-04-18 11:05:28 +05:30
коммит произвёл GitHub
родитель 831ea38f7e
Коммит b200a07881
1534 изменённых файлов: 3778 добавлений и 3853 удалений

72
server/model/access.go Обычный файл
Просмотреть файл

@@ -0,0 +1,72 @@
// 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"`
}
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"`
}
// 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)
}
return nil
}
func (ad *AccessData) IsExpired() bool {
if ad.ExpiresAt <= 0 {
return false
}
if GetMillis() > ad.ExpiresAt {
return true
}
return false
}

55
server/model/access_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,55 @@
// 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())
}

11
server/model/analytics_row.go Обычный файл
Просмотреть файл

@@ -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/model/audit.go Обычный файл
Просмотреть файл

@@ -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"`
}

777
server/model/auditconv.go Обычный файл
Просмотреть файл

@@ -0,0 +1,777 @@
// 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.RemoteTeamId = r.RemoteTeamId
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/model/auditconv_test.go Обычный файл
Просмотреть файл

@@ -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/model/audits.go Обычный файл
Просмотреть файл

@@ -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 ""
}

118
server/model/authorize.go Обычный файл
Просмотреть файл

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
)
const (
AuthCodeExpireTime = 60 * 10 // 10 minutes
AuthCodeResponseType = "code"
ImplicitResponseType = "token"
DefaultScope = "user"
)
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"`
}
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"`
}
// 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)
}
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)
}
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)
}

83
server/model/authorize_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,83 @@
// 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())
}

230
server/model/bot.go Обычный файл
Просмотреть файл

@@ -0,0 +1,230 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"fmt"
"net/http"
"strings"
"unicode/utf8"
)
const (
BotDisplayNameMaxRunes = UserFirstNameMaxRunes
BotDescriptionMaxRunes = 1024
BotCreatorIdMaxRunes = KeyValuePluginIdMaxRunes // UserId or PluginId
BotWarnMetricBotUsername = "mattermost-advisor"
BotSystemBotUsername = "system-bot"
)
// 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"`
}
func (b *Bot) Auditable() map[string]interface{} {
return map[string]interface{}{
"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]interface{} {
return map[string]interface{}{
"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 {
copy := *b
return &copy
}
// 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
}
// 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 = 0
var delta int64 = 0
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(userId string) *AppError {
return NewAppError("SqlBotStore.Get", "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
}

705
server/model/bot_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,705 @@
// 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 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 sToP(s string) *string {
return &s
}
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: sToP("new_username"),
DisplayName: nil,
Description: sToP("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: sToP("new_username"),
DisplayName: sToP("new display name"),
Description: sToP("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: NewString("BotName"),
}
ok := b.WouldPatch(patch)
require.True(t, ok)
})
t.Run("no patch", func(t *testing.T) {
patch := &BotPatch{
DisplayName: NewString("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: "user1__user2",
Type: ChannelTypeDirect,
},
Expected: false,
},
{
Name: "a direct channel with the name containing the bot's ID first",
Channel: &Channel{
Name: "botUserID__user2",
Type: ChannelTypeDirect,
},
Expected: true,
},
{
Name: "a direct channel with the name containing the bot's ID second",
Channel: &Channel{
Name: "user1__botUserID",
Type: ChannelTypeDirect,
},
Expected: true,
},
} {
t.Run(test.Name, func(t *testing.T) {
assert.Equal(t, test.Expected, IsBotDMChannel(test.Channel, "botUserID"))
})
}
}

9
server/model/builtin.go Обычный файл
Просмотреть файл

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
func NewBool(b bool) *bool { return &b }
func NewInt(n int) *int { return &n }
func NewInt64(n int64) *int64 { return &n }
func NewString(s string) *string { return &s }

13
server/model/bulk_export.go Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
// 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
CreateArchive bool
}

34
server/model/bundle_info.go Обычный файл
Просмотреть файл

@@ -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/server/v8/platform/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/model/bundle_info_test.go Обычный файл
Просмотреть файл

@@ -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)
}

445
server/model/channel.go Обычный файл
Просмотреть файл

@@ -0,0 +1,445 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
type ChannelType string
const (
ChannelTypeOpen ChannelType = "O"
ChannelTypePrivate ChannelType = "P"
ChannelTypeDirect ChannelType = "D"
ChannelTypeGroup ChannelType = "G"
ChannelGroupMaxUsers = 8
ChannelGroupMinUsers = 3
DefaultChannelName = "town-square"
ChannelDisplayNameMaxRunes = 64
ChannelNameMinLength = 1
ChannelNameMaxLength = 64
ChannelHeaderMaxRunes = 1024
ChannelPurposeMaxRunes = 250
ChannelCacheSize = 25000
ChannelSortByUsername = "username"
ChannelSortByStatus = "status"
)
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"`
Shared *bool `json:"shared"`
TotalMsgCountRoot int64 `json:"total_msg_count_root"`
PolicyID *string `json:"policy_id"`
LastRootPostAt int64 `json:"last_root_post_at"`
}
func (o *Channel) Auditable() map[string]interface{} {
return map[string]interface{}{
"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,
}
}
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"`
}
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"`
}
func (c *ChannelPatch) Auditable() map[string]interface{} {
return map[string]interface{}{
"header": c.Header,
"group_constrained": c.GroupConstrained,
"purpose": c.Purpose,
}
}
type ChannelForExport struct {
Channel
TeamName string
SchemeName *string
}
type DirectChannelForExport struct {
Channel
Members *[]string
}
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]interface{} {
return map[string]interface{}{
"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.
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
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
}
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
}
}
// The following are some GraphQL methods necessary to return the
// data in float64 type. The spec doesn't support 64 bit integers,
// so we have to pass the data in float64. The _ at the end is
// a hack to keep the attribute name same in GraphQL schema.
func (o *Channel) CreateAt_() float64 {
return float64(o.CreateAt)
}
func (o *Channel) UpdateAt_() float64 {
return float64(o.UpdateAt)
}
func (o *Channel) DeleteAt_() float64 {
return float64(o.DeleteAt)
}
func (o *Channel) LastPostAt_() float64 {
return float64(o.LastPostAt)
}
func (o *Channel) TotalMsgCount_() float64 {
return float64(o.TotalMsgCount)
}
func (o *Channel) TotalMsgCountRoot_() float64 {
return float64(o.TotalMsgCountRoot)
}
func (o *Channel) LastRootPostAt_() float64 {
return float64(o.LastRootPostAt)
}
func (o *Channel) ExtraUpdateAt_() float64 {
return float64(o.ExtraUpdateAt)
}
func (o *Channel) Props_() StringInterface {
return StringInterface(o.Props)
}
func (o *Channel) DeepCopy() *Channel {
copy := *o
if copy.SchemeId != nil {
copy.SchemeId = NewString(*o.SchemeId)
}
return &copy
}
func (o *Channel) Etag() string {
return Etag(o.Id, o.UpdateAt)
}
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) {
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)
}
}
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
}
func (o *Channel) IsOpen() bool {
return o.Type == ChannelTypeOpen
}
func (o *Channel) Patch(patch *ChannelPatch) {
if patch.DisplayName != nil {
o.DisplayName = *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
}
}
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 {
if o.Type != ChannelTypeDirect {
return ""
}
userIds := strings.Split(o.Name, "__")
var otherUserId string
if userIds[0] != userIds[1] {
if userIds[0] == userId {
otherUserId = userIds[1]
} else {
otherUserId = userIds[0]
}
}
return otherUserId
}
func (ChannelType) ImplementsGraphQLType(name string) bool {
return name == "ChannelType"
}
func (t ChannelType) MarshalJSON() ([]byte, error) {
return json.Marshal(string(t))
}
func (t *ChannelType) UnmarshalGraphQL(input any) error {
chType, ok := input.(string)
if !ok {
return errors.New("wrong type")
}
*t = ChannelType(chType)
return nil
}
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
}
sort.Strings(usernames)
name := strings.Join(usernames, ", ")
if truncate && len(name) > ChannelNameMaxLength {
name = name[:ChannelNameMaxLength]
}
return name
}
func GetGroupNameFromUserIds(userIds []string) string {
sort.Strings(userIds)
h := sha1.New()
for _, id := range userIds {
io.WriteString(h, id)
}
return hex.EncodeToString(h.Sum(nil))
}

42
server/model/channel_count.go Обычный файл
Просмотреть файл

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"crypto/md5"
"fmt"
"sort"
"strconv"
)
type ChannelCounts struct {
Counts map[string]int64 `json:"counts"`
CountsRoot map[string]int64 `json:"counts_root"`
UpdateTimes map[string]int64 `json:"update_times"`
}
func (o *ChannelCounts) Etag() string {
// we don't include CountsRoot in ETag calculation, since it's a derivative
ids := []string{}
for id := range o.Counts {
ids = append(ids, id)
}
sort.Strings(ids)
str := ""
for _, id := range ids {
str += id + strconv.FormatInt(o.Counts[id], 10)
}
md5Counts := fmt.Sprintf("%x", md5.Sum([]byte(str)))
var update int64 = 0
for _, u := range o.UpdateTimes {
if u > update {
update = u
}
}
return Etag(md5Counts, update)
}

18
server/model/channel_data.go Обычный файл
Просмотреть файл

@@ -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 = 0
if o.Member != nil {
mt = o.Member.LastUpdateAt
}
return Etag(o.Channel.Id, o.Channel.UpdateAt, o.Channel.LastPostAt, mt)
}

56
server/model/channel_list.go Обычный файл
Просмотреть файл

@@ -0,0 +1,56 @@
// 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 = 0
var delta int64 = 0
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 = 0
var delta int64 = 0
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))
}

234
server/model/channel_member.go Обычный файл
Просмотреть файл

@@ -0,0 +1,234 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"strings"
)
const (
ChannelNotifyDefault = "default"
ChannelNotifyAll = "all"
ChannelNotifyMention = "mention"
ChannelNotifyNone = "none"
ChannelMarkUnreadAll = "all"
ChannelMarkUnreadMention = "mention"
IgnoreChannelMentionsDefault = "default"
IgnoreChannelMentionsOff = "off"
IgnoreChannelMentionsOn = "on"
IgnoreChannelMentionsNotifyProp = "ignore_channel_mentions"
)
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"`
}
func (o *ChannelMember) Auditable() map[string]interface{} {
return map[string]interface{}{
"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,
}
}
// The following are some GraphQL methods necessary to return the
// data in float64 type. The spec doesn't support 64 bit integers,
// so we have to pass the data in float64. The _ at the end is
// a hack to keep the attribute name same in GraphQL schema.
func (o *ChannelMember) LastViewedAt_() float64 {
return float64(o.LastViewedAt)
}
func (o *ChannelMember) MsgCount_() float64 {
return float64(o.MsgCount)
}
func (o *ChannelMember) MentionCount_() float64 {
return float64(o.MentionCount)
}
func (o *ChannelMember) MentionCountRoot_() float64 {
return float64(o.MentionCountRoot)
}
func (o *ChannelMember) UrgentMentionCount_() float64 {
return float64(o.UrgentMentionCount)
}
func (o *ChannelMember) MsgCountRoot_() float64 {
return float64(o.MsgCountRoot)
}
func (o *ChannelMember) LastUpdateAt_() float64 {
return float64(o.LastUpdateAt)
}
// ChannelMemberWithTeamData contains ChannelMember appended with extra team information
// as well.
type ChannelMemberWithTeamData struct {
ChannelMember
TeamDisplayName string `json:"team_display_name"`
TeamName string `json:"team_name"`
TeamUpdateAt int64 `json:"team_update_at"`
}
type ChannelMembers []ChannelMember
type ChannelMembersWithTeamData []ChannelMemberWithTeamData
type ChannelMemberForExport struct {
ChannelMember
ChannelName string
Username 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)
}
notifyLevel := o.NotifyProps[DesktopNotifyProp]
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)
}
markUnreadLevel := o.NotifyProps[MarkUnreadNotifyProp]
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 := o.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 := o.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 := o.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 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 (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 GetDefaultChannelNotifyProps() StringMap {
return StringMap{
DesktopNotifyProp: ChannelNotifyDefault,
MarkUnreadNotifyProp: ChannelMarkUnreadAll,
PushNotifyProp: ChannelNotifyDefault,
EmailNotifyProp: ChannelNotifyDefault,
IgnoreChannelMentionsNotifyProp: IgnoreChannelMentionsDefault,
}
}

11
server/model/channel_member_history.go Обычный файл
Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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
}

40
server/model/channel_member_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,40 @@
// 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 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.NotifyProps = GetDefaultChannelNotifyProps()
o.UserId = NewId()
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")
}

28
server/model/channel_mentions.go Обычный файл
Просмотреть файл

@@ -0,0 +1,28 @@
// 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 {
var names []string
if strings.Contains(message, "~") {
alreadyMentioned := make(map[string]bool)
for _, match := range channelMentionRegexp.FindAllString(message, -1) {
name := match[1:]
if !alreadyMentioned[name] {
names = append(names, name)
alreadyMentioned[name] = true
}
}
}
return names
}

23
server/model/channel_search.go Обычный файл
Просмотреть файл

@@ -0,0 +1,23 @@
// 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"`
Deleted bool `json:"deleted"`
Page *int `json:"page,omitempty"`
PerPage *int `json:"per_page,omitempty"`
}

130
server/model/channel_sidebar.go Обычный файл
Просмотреть файл

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"errors"
"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"
// 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"
)
// 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 (SidebarCategoryType) ImplementsGraphQLType(name string) bool {
return name == "SidebarCategoryType"
}
func (t SidebarCategoryType) MarshalJSON() ([]byte, error) {
return json.Marshal(string(t))
}
func (t *SidebarCategoryType) UnmarshalGraphQL(input any) error {
chType, ok := input.(string)
if !ok {
return errors.New("wrong type")
}
*t = SidebarCategoryType(chType)
return nil
}
func (SidebarCategorySorting) ImplementsGraphQLType(name string) bool {
return name == "SidebarCategorySorting"
}
func (t SidebarCategorySorting) MarshalJSON() ([]byte, error) {
return json.Marshal(string(t))
}
func (t *SidebarCategorySorting) UnmarshalGraphQL(input any) error {
chType, ok := input.(string)
if !ok {
return errors.New("wrong type")
}
*t = SidebarCategorySorting(chType)
return nil
}
func (t *SidebarCategory) SortOrder_() float64 {
return float64(t.SortOrder)
}

49
server/model/channel_sidebar_test.go Обычный файл
Просмотреть файл

@@ -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/model/channel_stats.go Обычный файл
Просмотреть файл

@@ -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)
}

116
server/model/channel_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,116 @@
// 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 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 TestChannelPreSave(t *testing.T) {
o := Channel{Name: "test"}
o.PreSave()
o.Etag()
}
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)
}

15
server/model/channel_view.go Обычный файл
Просмотреть файл

@@ -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"`
}

8819
server/model/client4.go Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

106
server/model/client4_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// 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 := NewAPIv4Client(testURL)
assert.Equal(t, baseURL, client.URL)
assert.Equal(t, baseURL+APIURLSuffix, client.APIURL)
}
}
// https://github.com/mattermost/mattermost-server/server/v8/channels/issues/8205
func TestClient4CreatePost(t *testing.T) {
post := &Post{
Props: map[string]any{
"attachments": []*SlackAttachment{
{
Actions: []*PostAction{
{
Integration: &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 Post
err := json.NewDecoder(r.Body).Decode(&post)
assert.NoError(t, err)
attachments := post.Attachments()
assert.Equal(t, []*SlackAttachment{
{
Actions: []*PostAction{
{
Integration: &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 := NewAPIv4Client(server.URL)
_, resp, err := client.CreatePost(post)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestClient4SetToken(t *testing.T) {
expected := NewId()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get(HeaderAuth)
token := strings.Split(authHeader, HeaderBearer)
if len(token) < 2 {
t.Errorf("wrong authorization header format, got %s, expected: %s %s", authHeader, HeaderBearer, expected)
}
assert.Equal(t, expected, strings.TrimSpace(token[1]))
var user User
err := json.NewEncoder(w).Encode(&user)
assert.NoError(t, err)
}))
client := NewAPIv4Client(server.URL)
client.SetToken(expected)
_, resp, err := client.GetMe("")
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}

349
server/model/cloud.go Обычный файл
Просмотреть файл

@@ -0,0 +1,349 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"strings"
)
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 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"`
}
// 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"`
}
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"`
}
// TODO remove BoardsLimits.
// It is not used for real.
// Focalboard has some lingering code using this struct
// https://github.com/mattermost/mattermost-server/server/v8/boards/blob/fd4cf95f8ac9ba616864b25bf91bb1e4ec21335a/server/app/cloud.go#L86
// we should remove this struct once that code is removed.
type BoardsLimits struct {
Cards *int `json:"cards"`
Views *int `json:"views"`
}
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 {
// TODO remove Boards property.
// It is not used for real.
// Focalboard has some lingering code using this property
// https://github.com/mattermost/mattermost-server/server/v8/boards/blob/fd4cf95f8ac9ba616864b25bf91bb1e4ec21335a/server/app/cloud.go#L86
// we should remove this property once that code is removed.
Boards *BoardsLimits `json:"boards,omitempty"`
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 Feedback struct {
Reason string `json:"reason"`
Comments string `json:"comments"`
}
type WorkspaceDeletionRequest struct {
SubscriptionID string `json:"subscription_id"`
Feedback *Feedback `json:"delete_feedback"`
}
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/model/cluster_discovery.go Обычный файл
Просмотреть файл

@@ -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"`
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 {
copy := make([]*ClusterDiscovery, 0)
for _, v := range vs {
if f(v) {
copy = append(copy, v)
}
}
return copy
}
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/model/cluster_discovery_test.go Обычный файл
Просмотреть файл

@@ -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("", "")
}

12
server/model/cluster_info.go Обычный файл
Просмотреть файл

@@ -0,0 +1,12 @@
// 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"`
ConfigHash string `json:"config_hash"`
IPAddress string `json:"ipaddress"`
Hostname string `json:"hostname"`
}

64
server/model/cluster_message.go Обычный файл
Просмотреть файл

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type ClusterEvent string
const (
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"
ClusterEventInvalidateCacheForUserTeams ClusterEvent = "inv_user_teams"
ClusterEventClearSessionCacheForUser ClusterEvent = "clear_session_user"
ClusterEventInvalidateCacheForRoles ClusterEvent = "inv_roles"
ClusterEventInvalidateCacheForRolePermissions ClusterEvent = "inv_role_permissions"
ClusterEventInvalidateCacheForProfileByIds ClusterEvent = "inv_profile_ids"
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"
ClusterEventInvalidateCacheForLastPosts ClusterEvent = "inv_last_posts"
ClusterEventInvalidateCacheForLastPostTime ClusterEvent = "inv_last_post_time"
ClusterEventInvalidateCacheForPostsUsage ClusterEvent = "inv_posts_usage"
ClusterEventInvalidateCacheForTeams ClusterEvent = "inv_teams"
ClusterEventClearSessionCacheForAllUsers ClusterEvent = "inv_all_user_sessions"
ClusterEventInstallPlugin ClusterEvent = "install_plugin"
ClusterEventRemovePlugin ClusterEvent = "remove_plugin"
ClusterEventPluginEvent ClusterEvent = "plugin_event"
ClusterEventInvalidateCacheForTermsOfService ClusterEvent = "inv_terms_of_service"
ClusterEventBusyStateChanged ClusterEvent = "busy_state_change"
// Gossip communication
ClusterGossipEventRequestGetLogs = "gossip_request_get_logs"
ClusterGossipEventResponseGetLogs = "gossip_response_get_logs"
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"
// 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"`
}

11
server/model/cluster_stats.go Обычный файл
Просмотреть файл

@@ -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"`
}

20
server/model/collection.go Обычный файл
Просмотреть файл

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type CollectionMetadata struct {
Id string `json:"id"`
TeamId string `json:"team_id"`
CollectionType string `json:"collection_type"`
Name string `json:"name"`
RelativeURL string `json:"relative_url"`
}
type TopicMetadata struct {
Id string `json:"id"`
TeamId string `json:"team_id"`
TopicType string `json:"topic_type"`
CollectionType string `json:"collection_type"`
CollectionId string `json:"collection_id"`
}

156
server/model/command.go Обычный файл
Просмотреть файл

@@ -0,0 +1,156 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"strings"
)
const (
CommandMethodPost = "P"
CommandMethodGet = "G"
MinTriggerLength = 1
MaxTriggerLength = 128
)
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]interface{} {
return map[string]interface{}{
"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 || strings.Contains(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 = ""
}

55
server/model/command_args.go Обычный файл
Просмотреть файл

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"github.com/mattermost/mattermost-server/server/v8/platform/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"`
Command string `json:"command"`
SiteURL string `json:"-"`
T i18n.TranslateFunc `json:"-"`
UserMentions UserMentionMap `json:"-"`
ChannelMentions ChannelMentionMap `json:"-"`
}
func (o *CommandArgs) Auditable() map[string]interface{} {
return map[string]interface{}{
"user_id": o.UserId,
"channel_id": o.ChannelId,
"team_id": o.TeamId,
"root_id": o.RootId,
"parent_id": o.ParentId,
"trigger_id": o.TriggerId,
"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
}

108
server/model/command_args_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,108 @@
// 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_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)
}
}

410
server/model/command_autocomplete.go Обычный файл
Просмотреть файл

@@ -0,0 +1,410 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"net/url"
"path"
"reflect"
"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 stringNotInSlice(ad.RoleID, roles) {
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
}
func stringNotInSlice(a string, slice []string) bool {
for _, b := range slice {
if b == a {
return false
}
}
return true
}

99
server/model/command_autocomplete_test.go Обычный файл
Просмотреть файл

@@ -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/model/command_request.go Обычный файл
Просмотреть файл

@@ -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"`
}

71
server/model/command_response.go Обычный файл
Просмотреть файл

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"io"
"strings"
"github.com/mattermost/mattermost-server/server/v8/channels/utils/jsonutils"
)
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 []*SlackAttachment `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, jsonutils.HumanizeJSONError(err, b)
}
o.Attachments = StringifySlackFieldValue(o.Attachments)
if o.ExtraResponses != nil {
for _, resp := range o.ExtraResponses {
resp.Attachments = StringifySlackFieldValue(resp.Attachments)
}
}
return &o, nil
}

222
server/model/command_response_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,222 @@
// 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
}{
{
"empty response",
"",
nil,
true,
},
{
"malformed response",
`{"text": }`,
nil,
true,
},
{
"invalid response",
`{"text": "test", "response_type": 5}`,
nil,
true,
},
{
"ephemeral response",
`{
"response_type": "ephemeral",
"text": "response text",
"username": "response username",
"channel_id": "response channel id",
"icon_url": "response icon url",
"goto_location": "response goto location",
"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: "response icon url",
GotoLocation: "response goto location",
Attachments: []*SlackAttachment{
{
Text: "attachment 1 text",
Pretext: "attachment 1 pretext",
},
{
Text: "attachment 2 text",
Fields: []*SlackAttachmentField{
{
Title: "field 1",
Value: "value 1",
Short: true,
},
{
Title: "field 2",
Value: "[]",
Short: false,
},
},
},
},
},
false,
},
{
"null array items",
`{"attachments":[{"fields":[{"title":"foo","value":"bar","short":true}, null]}, null]}`,
&CommandResponse{
Attachments: []*SlackAttachment{
{
Fields: []*SlackAttachmentField{
{
Title: "foo",
Value: "bar",
Short: true,
},
},
},
},
},
false,
},
{
"multiple responses returned",
`
{
"text": "message 1",
"extra_responses": [
{"text": "message 2"}
]
}
`,
&CommandResponse{
Text: "message 1",
ExtraResponses: []*CommandResponse{
{
Text: "message 2",
},
},
},
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: []*SlackAttachment{
{
Fields: []*SlackAttachmentField{
{
Title: "foo",
Value: "bar",
Short: true,
},
},
},
},
ExtraResponses: []*CommandResponse{
{
Text: "message 2",
Attachments: []*SlackAttachment{
{
Fields: []*SlackAttachmentField{
{
Title: "foo 2",
Value: "bar 2",
Short: false,
},
},
},
},
},
},
},
false,
},
}
for _, testCase := range testCases {
testCase := testCase
t.Run(testCase.Description, func(t *testing.T) {
t.Parallel()
response, err := CommandResponseFromJSON(strings.NewReader(testCase.Json))
if testCase.ShouldError {
assert.Nil(t, response)
} else {
assert.NoError(t, err)
if assert.NotNil(t, response) {
assert.Equal(t, testCase.ExpectedCommandResponse, response)
}
}
})
}
}

117
server/model/command_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,117 @@
// 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())
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/model/command_webhook.go Обычный файл
Просмотреть файл

@@ -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/model/command_webhook_test.go Обычный файл
Просмотреть файл

@@ -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
}
}

125
server/model/compliance.go Обычный файл
Просмотреть файл

@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"strings"
)
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]interface{} {
return map[string]interface{}{
"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 {
copy := *c
return &copy
}
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
}

121
server/model/compliance_post.go Обычный файл
Просмотреть файл

@@ -0,0 +1,121 @@
// 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/model/compliance_post_test.go Обычный файл
Просмотреть файл

@@ -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)
}
}
}

4117
server/model/config.go Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

1479
server/model/config_test.go Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

148
server/model/custom_status.go Обычный файл
Просмотреть файл

@@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"bytes"
"encoding/json"
"fmt"
"time"
"github.com/graph-gophers/graphql-go"
)
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.Emoji == "" {
cs.Emoji = DefaultCustomStatusEmoji
}
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
}
// ExpiresAt_ returns the time in a type that has the marshal/unmarshal methods
// attached to it.
func (cs *CustomStatus) ExpiresAt_() graphql.Time {
return graphql.Time{Time: cs.ExpiresAt}
}
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
}

88
server/model/data_retention_policy.go Обычный файл
Просмотреть файл

@@ -0,0 +1,88 @@
// 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"`
BoardsDeletionEnabled bool `json:"boards_deletion_enabled"`
MessageRetentionCutoff int64 `json:"message_retention_cutoff"`
FileRetentionCutoff int64 `json:"file_retention_cutoff"`
BoardsRetentionCutoff int64 `json:"boards_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]interface{} {
return map[string]interface{}{
"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]interface{} {
return map[string]interface{}{
"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
}

104
server/model/draft.go Обычный файл
Просмотреть файл

@@ -0,0 +1,104 @@
// 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"`
UserId string `json:"user_id"`
ChannelId string `json:"channel_id"`
RootId string `json:"root_id"`
Message string `json:"message"`
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 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(o.Message) > maxDraftSize {
return NewAppError("Drafts.IsValid", "model.draft.is_valid.msg.app_error", nil, "channelid="+o.ChannelId, 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]interface{}))
}
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/model/draft_test.go Обычный файл
Просмотреть файл

@@ -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)
}

109
server/model/emoji.go Обычный файл
Просмотреть файл

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"regexp"
"sort"
)
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]interface{} {
return map[string]interface{}{
"id": emoji.Id,
"create_at": emoji.CreateAt,
"update_at": emoji.UpdateAt,
"delete_at": emoji.CreateAt,
"creator_id": emoji.CreatorId,
"name": emoji.Name,
}
}
func inSystemEmoji(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)
sort.Strings(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 inSystemEmoji(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.CreateAt = GetMillis()
emoji.UpdateAt = emoji.CreateAt
}

4472
server/model/emoji_data.go Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

9
server/model/emoji_search.go Обычный файл
Просмотреть файл

@@ -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/model/emoji_test.go Обычный файл
Просмотреть файл

@@ -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())
}

157
server/model/feature_flags.go Обычный файл
Просмотреть файл

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"reflect"
"strconv"
)
type FeatureFlags struct {
// Exists only for unit and manual testing.
// When set to a value, will be returned by the ping endpoint.
TestFeature string
// Exists only for testing bool functionality. Boolean feature flags interpret "on" or "true" as true and
// all other values as false.
TestBoolFeature bool
// Enable the remote cluster service for shared channels.
EnableRemoteClusterService bool
// AppsEnabled toggles the Apps framework functionalities both in server and client side
AppsEnabled bool
// Feature flags to control plugin versions
PluginPlaybooks string `plugin_id:"playbooks"`
PluginApps string `plugin_id:"com.mattermost.apps"`
PluginFocalboard string `plugin_id:"focalboard"`
PluginCalls string `plugin_id:"com.mattermost.calls"`
PermalinkPreviews bool
// CallsEnabled controls whether or not the Calls plugin should be enabled
CallsEnabled bool
// A dash separated list for feature flags to turn on for Boards
BoardsFeatureFlags string
// Enable DataRetention for Boards
BoardsDataRetention bool
NormalizeLdapDNs bool
// Enable special onboarding flow for first admin
UseCaseOnboarding bool
// Enable GraphQL feature
GraphQL bool
InsightsEnabled bool
CommandPalette bool
// A/B Test on posting a welcome message
SendWelcomePost bool
WorkTemplate bool
PostPriority bool
// Enable WYSIWYG text editor
WysiwygEditor bool
PeopleProduct bool
// A/B Test on reduced onboarding task list item
ReduceOnBoardingTaskList bool
// A/B Test to control when to show onboarding linked board
OnboardingAutoShowLinkedBoard bool
ThreadsEverywhere bool
GlobalDrafts bool
OnboardingTourTips bool
DeprecateCloudFree bool
AppsSidebarCategory bool
CloudReverseTrial bool
}
func (f *FeatureFlags) SetDefaults() {
f.TestFeature = "off"
f.TestBoolFeature = false
f.EnableRemoteClusterService = false
f.AppsEnabled = true
f.PluginApps = ""
f.PluginFocalboard = ""
f.PermalinkPreviews = true
f.BoardsFeatureFlags = ""
f.BoardsDataRetention = false
f.NormalizeLdapDNs = false
f.UseCaseOnboarding = true
f.GraphQL = false
f.InsightsEnabled = true
f.CommandPalette = false
f.CallsEnabled = true
f.SendWelcomePost = true
f.PostPriority = true
f.PeopleProduct = false
f.WorkTemplate = true
f.ReduceOnBoardingTaskList = false
f.ThreadsEverywhere = false
f.GlobalDrafts = true
f.DeprecateCloudFree = false
f.WysiwygEditor = false
f.OnboardingAutoShowLinkedBoard = false
f.OnboardingTourTips = true
f.AppsSidebarCategory = false
f.CloudReverseTrial = false
}
func (f *FeatureFlags) Plugins() map[string]string {
rFFVal := reflect.ValueOf(f).Elem()
rFFType := reflect.TypeOf(f).Elem()
pluginVersions := make(map[string]string)
for i := 0; i < rFFVal.NumField(); i++ {
rFieldVal := rFFVal.Field(i)
rFieldType := rFFType.Field(i)
pluginId, hasPluginId := rFieldType.Tag.Lookup("plugin_id")
if !hasPluginId {
continue
}
pluginVersions[pluginId] = rFieldVal.String()
}
return pluginVersions
}
// ToMap returns the feature flags as a map[string]string
// Supports boolean and string feature flags.
func (f *FeatureFlags) ToMap() map[string]string {
refStructVal := reflect.ValueOf(*f)
refStructType := reflect.TypeOf(*f)
ret := make(map[string]string)
for i := 0; i < refStructVal.NumField(); i++ {
refFieldVal := refStructVal.Field(i)
if !refFieldVal.IsValid() {
continue
}
refFieldType := refStructType.Field(i)
switch refFieldType.Type.Kind() {
case reflect.Bool:
ret[refFieldType.Name] = strconv.FormatBool(refFieldVal.Bool())
default:
ret[refFieldType.Name] = refFieldVal.String()
}
}
return ret
}

54
server/model/feature_flags_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,54 @@
// 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 TestFeatureFlagsToMap(t *testing.T) {
for name, tc := range map[string]struct {
Flags FeatureFlags
TestFeatureValue string
}{
"empty": {
TestFeatureValue: "",
Flags: FeatureFlags{},
},
"simple value": {
TestFeatureValue: "expectedvalue",
Flags: FeatureFlags{TestFeature: "expectedvalue"},
},
"empty value": {
TestFeatureValue: "",
Flags: FeatureFlags{TestFeature: ""},
},
} {
t.Run(name, func(t *testing.T) {
require.Equal(t, tc.TestFeatureValue, tc.Flags.ToMap()["TestFeature"])
})
}
}
func TestFeatureFlagsToMapBool(t *testing.T) {
for name, tc := range map[string]struct {
Flags FeatureFlags
TestFeatureValue string
}{
"false": {
TestFeatureValue: "false",
Flags: FeatureFlags{},
},
"true": {
TestFeatureValue: "true",
Flags: FeatureFlags{TestBoolFeature: true},
},
} {
t.Run(name, func(t *testing.T) {
require.Equal(t, tc.TestFeatureValue, tc.Flags.ToMap()["TestBoolFeature"])
})
}
}

13
server/model/file.go Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const (
MaxImageSize = int64(6048 * 4032) // 24 megapixels, roughly 36MB as a raw image
)
type FileUploadResponse struct {
FileInfos []*FileInfo `json:"file_infos"`
ClientIds []string `json:"client_ids"`
}

223
server/model/file_info.go Обычный файл
Просмотреть файл

@@ -0,0 +1,223 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"image"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/mattermost/mattermost-server/server/v8/channels/utils/imgutils"
)
const (
FileinfoSortByCreated = "CreateAt"
FileinfoSortBySize = "Size"
)
// GetFileInfosOptions contains options for getting FileInfos
type GetFileInfosOptions struct {
// UserIds optionally limits the FileInfos to those created by the given users.
UserIds []string `json:"user_ids"`
// ChannelIds optionally limits the FileInfos to those created in the given channels.
ChannelIds []string `json:"channel_ids"`
// Since optionally limits FileInfos to those created at or after the given time, specified as Unix time in milliseconds.
Since int64 `json:"since"`
// IncludeDeleted if set includes deleted FileInfos.
IncludeDeleted bool `json:"include_deleted"`
// SortBy sorts the FileInfos by this field. The default is to sort by date created.
SortBy string `json:"sort_by"`
// SortDescending changes the sort direction to descending order when true.
SortDescending bool `json:"sort_descending"`
}
type FileInfo struct {
Id string `json:"id"`
CreatorId string `json:"user_id"`
PostId string `json:"post_id,omitempty"`
// ChannelId is the denormalized value from the corresponding post. Note that this value is
// potentially distinct from the ChannelId provided when the file is first uploaded and
// used to organize the directories in the file store, since in theory that same file
// could be attached to a post from a different channel (or not attached to a post at all).
ChannelId string `json:"channel_id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
Path string `json:"-"` // not sent back to the client
ThumbnailPath string `json:"-"` // not sent back to the client
PreviewPath string `json:"-"` // not sent back to the client
Name string `json:"name"`
Extension string `json:"extension"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
HasPreviewImage bool `json:"has_preview_image,omitempty"`
MiniPreview *[]byte `json:"mini_preview"` // declared as *[]byte to avoid postgres/mysql differences in deserialization
Content string `json:"-"`
RemoteId *string `json:"remote_id"`
Archived bool `json:"archived"`
}
func (fi *FileInfo) Auditable() map[string]interface{} {
return map[string]interface{}{
"id": fi.Id,
"creator_id": fi.CreatorId,
"post_id": fi.PostId,
"channel_id": fi.ChannelId,
"create_at": fi.CreateAt,
"update_at": fi.UpdateAt,
"delete_at": fi.DeleteAt,
"name": fi.Name,
"extension": fi.Extension,
"size": fi.Size,
}
}
func (fi *FileInfo) PreSave() {
if fi.Id == "" {
fi.Id = NewId()
}
if fi.CreateAt == 0 {
fi.CreateAt = GetMillis()
}
if fi.UpdateAt < fi.CreateAt {
fi.UpdateAt = fi.CreateAt
}
if fi.RemoteId == nil {
fi.RemoteId = NewString("")
}
}
func (fi *FileInfo) IsValid() *AppError {
if !IsValidId(fi.Id) {
return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(fi.CreatorId) && fi.CreatorId != "nouser" {
return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.user_id.app_error", nil, "id="+fi.Id, http.StatusBadRequest)
}
if fi.PostId != "" && !IsValidId(fi.PostId) {
return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.post_id.app_error", nil, "id="+fi.Id, http.StatusBadRequest)
}
if fi.CreateAt == 0 {
return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.create_at.app_error", nil, "id="+fi.Id, http.StatusBadRequest)
}
if fi.UpdateAt == 0 {
return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.update_at.app_error", nil, "id="+fi.Id, http.StatusBadRequest)
}
if fi.Path == "" {
return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.path.app_error", nil, "id="+fi.Id, http.StatusBadRequest)
}
return nil
}
func (fi *FileInfo) IsImage() bool {
return strings.HasPrefix(fi.MimeType, "image")
}
func (fi *FileInfo) IsSvg() bool {
return fi.MimeType == "image/svg+xml"
}
func NewInfo(name string) *FileInfo {
info := &FileInfo{
Name: name,
}
extension := strings.ToLower(filepath.Ext(name))
info.MimeType = mime.TypeByExtension(extension)
if extension != "" && extension[0] == '.' {
// The client expects a file extension without the leading period
info.Extension = extension[1:]
} else {
info.Extension = extension
}
return info
}
func GetInfoForBytes(name string, data io.ReadSeeker, size int) (*FileInfo, *AppError) {
info := &FileInfo{
Name: name,
Size: int64(size),
}
var err *AppError
extension := strings.ToLower(filepath.Ext(name))
info.MimeType = mime.TypeByExtension(extension)
if extension != "" {
// The client expects a file extension without the leading period
info.Extension = extension[1:]
} else {
info.Extension = extension
}
if info.IsImage() {
// Only set the width and height if it's actually an image that we can understand
if config, _, err := image.DecodeConfig(data); err == nil {
info.Width = config.Width
info.Height = config.Height
if info.MimeType == "image/gif" {
// Just show the gif itself instead of a preview image for animated gifs
data.Seek(0, io.SeekStart)
frameCount, err := imgutils.CountGIFFrames(data)
if err != nil {
// Still return the rest of the info even though it doesn't appear to be an actual gif
info.HasPreviewImage = true
return info, NewAppError("GetInfoForBytes", "model.file_info.get.gif.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
info.HasPreviewImage = frameCount == 1
} else {
info.HasPreviewImage = true
}
}
}
return info, err
}
func GetEtagForFileInfos(infos []*FileInfo) string {
if len(infos) == 0 {
return Etag()
}
var maxUpdateAt int64
for _, info := range infos {
if info.UpdateAt > maxUpdateAt {
maxUpdateAt = info.UpdateAt
}
}
return Etag(infos[0].PostId, maxUpdateAt)
}
func (fi *FileInfo) MakeContentInaccessible() {
if fi == nil {
return
}
fi.Archived = true
fi.Content = ""
fi.HasPreviewImage = false
fi.MiniPreview = nil
fi.Path = ""
fi.PreviewPath = ""
fi.ThumbnailPath = ""
}

113
server/model/file_info_list.go Обычный файл
Просмотреть файл

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"sort"
)
type FileInfoList struct {
Order []string `json:"order"`
FileInfos map[string]*FileInfo `json:"file_infos"`
NextFileInfoId string `json:"next_file_info_id"`
PrevFileInfoId string `json:"prev_file_info_id"`
// If there are inaccessible files, FirstInaccessibleFileTime is the time of the latest inaccessible file
FirstInaccessibleFileTime int64 `json:"first_inaccessible_file_time"`
}
func NewFileInfoList() *FileInfoList {
return &FileInfoList{
Order: make([]string, 0),
FileInfos: make(map[string]*FileInfo),
NextFileInfoId: "",
PrevFileInfoId: "",
}
}
func (o *FileInfoList) ToSlice() []*FileInfo {
var fileInfos []*FileInfo
for _, id := range o.Order {
fileInfos = append(fileInfos, o.FileInfos[id])
}
return fileInfos
}
func (o *FileInfoList) MakeNonNil() {
if o.Order == nil {
o.Order = make([]string, 0)
}
if o.FileInfos == nil {
o.FileInfos = make(map[string]*FileInfo)
}
}
func (o *FileInfoList) AddOrder(id string) {
if o.Order == nil {
o.Order = make([]string, 0, 128)
}
o.Order = append(o.Order, id)
}
func (o *FileInfoList) AddFileInfo(fileInfo *FileInfo) {
if o.FileInfos == nil {
o.FileInfos = make(map[string]*FileInfo)
}
o.FileInfos[fileInfo.Id] = fileInfo
}
func (o *FileInfoList) UniqueOrder() {
keys := make(map[string]bool)
order := []string{}
for _, fileInfoId := range o.Order {
if _, value := keys[fileInfoId]; !value {
keys[fileInfoId] = true
order = append(order, fileInfoId)
}
}
o.Order = order
}
func (o *FileInfoList) Extend(other *FileInfoList) {
for fileInfoId := range other.FileInfos {
o.AddFileInfo(other.FileInfos[fileInfoId])
}
for _, fileInfoId := range other.Order {
o.AddOrder(fileInfoId)
}
o.UniqueOrder()
}
func (o *FileInfoList) SortByCreateAt() {
sort.Slice(o.Order, func(i, j int) bool {
return o.FileInfos[o.Order[i]].CreateAt > o.FileInfos[o.Order[j]].CreateAt
})
}
func (o *FileInfoList) Etag() string {
id := "0"
var t int64 = 0
for _, v := range o.FileInfos {
if v.UpdateAt > t {
t = v.UpdateAt
id = v.Id
} else if v.UpdateAt == t && v.Id > id {
t = v.UpdateAt
id = v.Id
}
}
orderId := ""
if len(o.Order) > 0 {
orderId = o.Order[0]
}
return Etag(orderId, id, t)
}

18
server/model/file_info_search_results.go Обычный файл
Просмотреть файл

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type FileInfoSearchMatches map[string][]string
type FileInfoSearchResults struct {
*FileInfoList
Matches FileInfoSearchMatches `json:"matches"`
}
func MakeFileInfoSearchResults(fileInfos *FileInfoList, matches FileInfoSearchMatches) *FileInfoSearchResults {
return &FileInfoSearchResults{
fileInfos,
matches,
}
}

207
server/model/file_info_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,207 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"bytes"
"encoding/base64"
_ "image/gif"
_ "image/png"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFileInfoIsValid(t *testing.T) {
info := &FileInfo{
Id: NewId(),
CreatorId: NewId(),
CreateAt: 1234,
UpdateAt: 1234,
PostId: "",
Path: "fake/path.png",
}
t.Run("Valid File Info", func(t *testing.T) {
assert.Nil(t, info.IsValid())
})
t.Run("Empty ID is not valid", func(t *testing.T) {
info.Id = ""
assert.NotNil(t, info.IsValid(), "empty Id isn't valid")
info.Id = NewId()
})
t.Run("CreateAt 0 is not valid", func(t *testing.T) {
info.CreateAt = 0
assert.NotNil(t, info.IsValid(), "empty CreateAt isn't valid")
info.CreateAt = 1234
})
t.Run("UpdateAt 0 is not valid", func(t *testing.T) {
info.UpdateAt = 0
assert.NotNil(t, info.IsValid(), "empty UpdateAt isn't valid")
info.UpdateAt = 1234
})
t.Run("New Post ID is valid", func(t *testing.T) {
info.PostId = NewId()
assert.Nil(t, info.IsValid())
})
t.Run("Empty path is not valid", func(t *testing.T) {
info.Path = ""
assert.NotNil(t, info.IsValid(), "empty Path isn't valid")
info.Path = "fake/path.png"
})
}
func TestFileInfoIsImage(t *testing.T) {
info := &FileInfo{}
t.Run("MimeType set to image/png is considered an image", func(t *testing.T) {
info.MimeType = "image/png"
assert.True(t, info.IsImage(), "PNG file should be considered as an image")
})
t.Run("MimeType set to text/plain is not considered an image", func(t *testing.T) {
info.MimeType = "text/plain"
assert.False(t, info.IsImage(), "Text file should not be considered as an image")
})
}
func TestGetInfoForFile(t *testing.T) {
fakeFile := make([]byte, 1000)
pngFile, err := os.ReadFile("../tests/test.png")
require.NoError(t, err, "Failed to load test.png")
// base 64 encoded version of handtinywhite.gif from http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever
gifFile, _ := base64.StdEncoding.DecodeString("R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")
animatedGifFile, err := os.ReadFile("../tests/testgif.gif")
require.NoError(t, err, "Failed to load testgif.gif")
var ttc = []struct {
testName string
filename string
file []byte
usePrefixForMime bool
expectedExtension string
expectedSize int
expectedMime string
expectedWidth int
expectedHeight int
expectedHasPreviewImage bool
}{
{
testName: "Text File",
filename: "file.txt",
file: fakeFile,
usePrefixForMime: true,
expectedExtension: "txt",
expectedSize: 1000,
expectedMime: "text/plain",
expectedWidth: 0,
expectedHeight: 0,
expectedHasPreviewImage: false,
},
{
testName: "PNG file",
filename: "test.png",
file: pngFile,
usePrefixForMime: false,
expectedExtension: "png",
expectedSize: 279591,
expectedMime: "image/png",
expectedWidth: 408,
expectedHeight: 336,
expectedHasPreviewImage: true,
},
{
testName: "Static Gif File",
filename: "handtinywhite.gif",
file: gifFile,
usePrefixForMime: false,
expectedExtension: "gif",
expectedSize: 35,
expectedMime: "image/gif",
expectedWidth: 1,
expectedHeight: 1,
expectedHasPreviewImage: true,
},
{
testName: "Animated Gif File",
filename: "testgif.gif",
file: animatedGifFile,
usePrefixForMime: false,
expectedExtension: "gif",
expectedSize: 38689,
expectedMime: "image/gif",
expectedWidth: 118,
expectedHeight: 118,
expectedHasPreviewImage: false,
},
{
testName: "No extension File",
filename: "filewithoutextension",
file: fakeFile,
usePrefixForMime: false,
expectedExtension: "",
expectedSize: 1000,
expectedMime: "",
expectedWidth: 0,
expectedHeight: 0,
expectedHasPreviewImage: false,
},
{
// Always make the extension lower case to make it easier to use in other places
testName: "Uppercase extension File",
filename: "file.TXT",
file: fakeFile,
usePrefixForMime: true,
expectedExtension: "txt",
expectedSize: 1000,
expectedMime: "text/plain",
expectedWidth: 0,
expectedHeight: 0,
expectedHasPreviewImage: false,
},
{
// Don't error out for image formats we don't support
testName: "Not supported File",
filename: "file.tif",
file: fakeFile,
usePrefixForMime: false,
expectedExtension: "tif",
expectedSize: 1000,
expectedMime: "image/tiff",
expectedWidth: 0,
expectedHeight: 0,
expectedHasPreviewImage: false,
},
}
for _, tc := range ttc {
t.Run(tc.testName, func(t *testing.T) {
info, appErr := GetInfoForBytes(tc.filename, bytes.NewReader(tc.file), len(tc.file))
require.Nil(t, appErr)
assert.Equalf(t, tc.filename, info.Name, "Got incorrect filename: %v", info.Name)
assert.Equalf(t, tc.expectedExtension, info.Extension, "Got incorrect extension: %v", info.Extension)
assert.EqualValuesf(t, tc.expectedSize, info.Size, "Got incorrect size: %v", info.Size)
assert.Equalf(t, tc.expectedWidth, info.Width, "Got incorrect width: %v", info.Width)
assert.Equalf(t, tc.expectedHeight, info.Height, "Got incorrect height: %v", info.Height)
assert.Equalf(t, tc.expectedHasPreviewImage, info.HasPreviewImage, "Got incorrect has preview image: %v", info.HasPreviewImage)
if tc.usePrefixForMime {
assert.Truef(t, strings.HasPrefix(info.MimeType, tc.expectedMime), "Got incorrect mime type: %v", info.MimeType)
} else {
assert.Equalf(t, tc.expectedMime, info.MimeType, "Got incorrect mime type: %v", info.MimeType)
}
})
}
}

26
server/model/github_release.go Обычный файл
Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
)
type GithubReleaseInfo struct {
Id int `json:"id"`
TagName string `json:"tag_name"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
PublishedAt string `json:"published_at"`
Body string `json:"body"`
Url string `json:"html_url"`
}
func (g *GithubReleaseInfo) IsValid() *AppError {
if g.Id == 0 {
return NewAppError("GithubReleaseInfo.IsValid", NoTranslation, nil, "empty ID", http.StatusInternalServerError)
}
return nil
}

8
server/model/gitlab.go Обычный файл
Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const (
UserAuthServiceGitlab = "gitlab"
)

283
server/model/group.go Обычный файл
Просмотреть файл

@@ -0,0 +1,283 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"regexp"
)
const (
GroupSourceLdap GroupSource = "ldap"
GroupSourceCustom GroupSource = "custom"
GroupNameMaxLength = 64
GroupSourceMaxLength = 64
GroupDisplayNameMaxLength = 128
GroupDescriptionMaxLength = 1024
GroupRemoteIDMaxLength = 48
)
type GroupSource string
var allGroupSources = []GroupSource{
GroupSourceLdap,
GroupSourceCustom,
}
var groupSourcesRequiringRemoteID = []GroupSource{
GroupSourceLdap,
}
type Group struct {
Id string `json:"id"`
Name *string `json:"name,omitempty"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Source GroupSource `json:"source"`
RemoteId *string `json:"remote_id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
HasSyncables bool `db:"-" json:"has_syncables"`
MemberCount *int `db:"-" json:"member_count,omitempty"`
AllowReference bool `json:"allow_reference"`
ChannelMemberCount *int `db:"-" json:"channel_member_count,omitempty"`
ChannelMemberTimezonesCount *int `db:"-" json:"channel_member_timezones_count,omitempty"`
}
func (group *Group) Auditable() map[string]interface{} {
return map[string]interface{}{
"id": group.Id,
"source": group.Source,
"remote_id": group.RemoteId,
"create_at": group.CreateAt,
"update_at": group.UpdateAt,
"delete_at": group.DeleteAt,
"has_syncables": group.HasSyncables,
"member_count": group.MemberCount,
"allow_reference": group.AllowReference,
}
}
type GroupWithUserIds struct {
Group
UserIds []string `json:"user_ids"`
}
func (group *GroupWithUserIds) Auditable() map[string]interface{} {
return map[string]interface{}{
"id": group.Id,
"source": group.Source,
"remote_id": group.RemoteId,
"create_at": group.CreateAt,
"update_at": group.UpdateAt,
"delete_at": group.DeleteAt,
"has_syncables": group.HasSyncables,
"member_count": group.MemberCount,
"allow_reference": group.AllowReference,
"user_ids": group.UserIds,
}
}
type GroupWithSchemeAdmin struct {
Group
SchemeAdmin *bool `db:"SyncableSchemeAdmin" json:"scheme_admin,omitempty"`
}
type GroupsAssociatedToChannelWithSchemeAdmin struct {
ChannelId string `json:"channel_id"`
Group
SchemeAdmin *bool `db:"SyncableSchemeAdmin" json:"scheme_admin,omitempty"`
}
type GroupsAssociatedToChannel struct {
ChannelId string `json:"channel_id"`
Groups []*GroupWithSchemeAdmin `json:"groups"`
}
type GroupPatch struct {
Name *string `json:"name"`
DisplayName *string `json:"display_name"`
Description *string `json:"description"`
AllowReference *bool `json:"allow_reference"`
// For security reasons (including preventing unintended LDAP group synchronization) do no allow a Group's RemoteId or Source field to be
// included in patches.
}
type LdapGroupSearchOpts struct {
Q string
IsLinked *bool
IsConfigured *bool
}
type GroupSearchOpts struct {
Q string
NotAssociatedToTeam string
NotAssociatedToChannel string
IncludeMemberCount bool
FilterAllowReference bool
PageOpts *PageOpts
Since int64
Source GroupSource
// FilterParentTeamPermitted filters the groups to the intersect of the
// set associated to the parent team and those returned by the query.
// If the parent team is not group-constrained or if NotAssociatedToChannel
// is not set then this option is ignored.
FilterParentTeamPermitted bool
// FilterHasMember filters the groups to the intersect of the
// set returned by the query and those that have the given user as a member.
FilterHasMember string
IncludeChannelMemberCount string
IncludeTimezones bool
}
type GetGroupOpts struct {
IncludeMemberCount bool
}
type PageOpts struct {
Page int
PerPage int
}
type GroupStats struct {
GroupID string `json:"group_id"`
TotalMemberCount int64 `json:"total_member_count"`
}
type GroupModifyMembers struct {
UserIds []string `json:"user_ids"`
}
func (group *GroupModifyMembers) Auditable() map[string]interface{} {
return map[string]interface{}{
"user_ids": group.UserIds,
}
}
func (group *Group) Patch(patch *GroupPatch) {
if patch.Name != nil {
group.Name = patch.Name
}
if patch.DisplayName != nil {
group.DisplayName = *patch.DisplayName
}
if patch.Description != nil {
group.Description = *patch.Description
}
if patch.AllowReference != nil {
group.AllowReference = *patch.AllowReference
}
}
func (group *Group) IsValidForCreate() *AppError {
appErr := group.IsValidName()
if appErr != nil {
return appErr
}
if l := len(group.DisplayName); l == 0 || l > GroupDisplayNameMaxLength {
return NewAppError("Group.IsValidForCreate", "model.group.display_name.app_error", map[string]any{"GroupDisplayNameMaxLength": GroupDisplayNameMaxLength}, "", http.StatusBadRequest)
}
if len(group.Description) > GroupDescriptionMaxLength {
return NewAppError("Group.IsValidForCreate", "model.group.description.app_error", map[string]any{"GroupDescriptionMaxLength": GroupDescriptionMaxLength}, "", http.StatusBadRequest)
}
isValidSource := false
for _, groupSource := range allGroupSources {
if group.Source == groupSource {
isValidSource = true
break
}
}
if !isValidSource {
return NewAppError("Group.IsValidForCreate", "model.group.source.app_error", nil, "", http.StatusBadRequest)
}
if (group.GetRemoteId() == "" && group.requiresRemoteId()) || len(group.GetRemoteId()) > GroupRemoteIDMaxLength {
return NewAppError("Group.IsValidForCreate", "model.group.remote_id.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
func (group *Group) requiresRemoteId() bool {
for _, groupSource := range groupSourcesRequiringRemoteID {
if groupSource == group.Source {
return true
}
}
return false
}
func (group *Group) IsValidForUpdate() *AppError {
if !IsValidId(group.Id) {
return NewAppError("Group.IsValidForUpdate", "app.group.id.app_error", nil, "", http.StatusBadRequest)
}
if group.CreateAt == 0 {
return NewAppError("Group.IsValidForUpdate", "model.group.create_at.app_error", nil, "", http.StatusBadRequest)
}
if group.UpdateAt == 0 {
return NewAppError("Group.IsValidForUpdate", "model.group.update_at.app_error", nil, "", http.StatusBadRequest)
}
if appErr := group.IsValidForCreate(); appErr != nil {
return appErr
}
return nil
}
var validGroupnameChars = regexp.MustCompile(`^[a-z0-9\.\-_]+$`)
func (group *Group) IsValidName() *AppError {
if group.Name == nil {
if group.AllowReference {
return NewAppError("Group.IsValidName", "model.group.name.app_error", map[string]any{"GroupNameMaxLength": GroupNameMaxLength}, "", http.StatusBadRequest)
}
} else {
if l := len(*group.Name); l == 0 || l > GroupNameMaxLength {
return NewAppError("Group.IsValidName", "model.group.name.invalid_length.app_error", map[string]any{"GroupNameMaxLength": GroupNameMaxLength}, "", http.StatusBadRequest)
}
if *group.Name == UserNotifyAll || *group.Name == ChannelMentionsNotifyProp || *group.Name == UserNotifyHere {
return NewAppError("IsValidName", "model.group.name.reserved_name.app_error", nil, "", http.StatusBadRequest)
}
if !validGroupnameChars.MatchString(*group.Name) {
return NewAppError("Group.IsValidName", "model.group.name.invalid_chars.app_error", nil, "", http.StatusBadRequest)
}
}
return nil
}
func (group *Group) GetName() string {
if group.Name == nil {
return ""
}
return *group.Name
}
func (group *Group) GetRemoteId() string {
if group.RemoteId == nil {
return ""
}
return *group.RemoteId
}
type GroupsWithCount struct {
Groups []*Group `json:"groups"`
TotalCount int64 `json:"total_count"`
}
type CreateDefaultMembershipParams struct {
Since int64
ReAddRemovedMembers bool
ScopedUserID *string
ScopedTeamID *string
ScopedChannelID *string
}

23
server/model/group_member.go Обычный файл
Просмотреть файл

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import "net/http"
type GroupMember struct {
GroupId string `json:"group_id"`
UserId string `json:"user_id"`
CreateAt int64 `json:"create_at"`
DeleteAt int64 `json:"delete_at"`
}
func (gm *GroupMember) IsValid() *AppError {
if !IsValidId(gm.GroupId) {
return NewAppError("GroupMember.IsValid", "model.group_member.group_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(gm.UserId) {
return NewAppError("GroupMember.IsValid", "model.group_member.user_id.app_error", nil, "", http.StatusBadRequest)
}
return nil
}

198
server/model/group_syncable.go Обычный файл
Просмотреть файл

@@ -0,0 +1,198 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"fmt"
"net/http"
)
type GroupSyncableType string
const (
GroupSyncableTypeTeam GroupSyncableType = "Team"
GroupSyncableTypeChannel GroupSyncableType = "Channel"
)
func (gst GroupSyncableType) String() string {
return string(gst)
}
type GroupSyncable struct {
GroupId string `json:"group_id"`
// SyncableId represents the Id of the model that is being synced with the group, for example a ChannelId or
// TeamId.
SyncableId string `db:"-" json:"-"`
AutoAdd bool `json:"auto_add"`
SchemeAdmin bool `json:"scheme_admin"`
CreateAt int64 `json:"create_at"`
DeleteAt int64 `json:"delete_at"`
UpdateAt int64 `json:"update_at"`
Type GroupSyncableType `db:"-" json:"-"`
// Values joined in from the associated team and/or channel
ChannelDisplayName string `db:"-" json:"-"`
TeamDisplayName string `db:"-" json:"-"`
TeamType string `db:"-" json:"-"`
ChannelType string `db:"-" json:"-"`
TeamID string `db:"-" json:"-"`
}
func (syncable *GroupSyncable) Auditable() map[string]interface{} {
return map[string]interface{}{
"group_id": syncable.GroupId,
"syncable_id": syncable.SyncableId,
"auto_add": syncable.AutoAdd,
"scheme_admin": syncable.SchemeAdmin,
"create_at": syncable.CreateAt,
"delete_at": syncable.DeleteAt,
"update_at": syncable.UpdateAt,
"type": syncable.Type,
"channel_display_name": syncable.ChannelDisplayName,
"team_display_name": syncable.TeamDisplayName,
"team_type": syncable.TeamType,
"channel_type": syncable.ChannelType,
"team_id": syncable.TeamID,
}
}
func (syncable *GroupSyncable) IsValid() *AppError {
if !IsValidId(syncable.GroupId) {
return NewAppError("GroupSyncable.SyncableIsValid", "model.group_syncable.group_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(syncable.SyncableId) {
return NewAppError("GroupSyncable.SyncableIsValid", "model.group_syncable.syncable_id.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
func (syncable *GroupSyncable) UnmarshalJSON(b []byte) error {
var kvp map[string]any
err := json.Unmarshal(b, &kvp)
if err != nil {
return err
}
var channelId string
var teamId string
for key, value := range kvp {
switch key {
case "team_id":
teamId = value.(string)
case "channel_id":
channelId = value.(string)
case "group_id":
syncable.GroupId = value.(string)
case "auto_add":
syncable.AutoAdd = value.(bool)
default:
}
}
if channelId != "" {
syncable.TeamID = teamId
syncable.SyncableId = channelId
syncable.Type = GroupSyncableTypeChannel
} else {
syncable.SyncableId = teamId
syncable.Type = GroupSyncableTypeTeam
}
return nil
}
func (syncable *GroupSyncable) MarshalJSON() ([]byte, error) {
type Alias GroupSyncable
switch syncable.Type {
case GroupSyncableTypeTeam:
return json.Marshal(&struct {
TeamID string `json:"team_id"`
TeamDisplayName string `json:"team_display_name,omitempty"`
TeamType string `json:"team_type,omitempty"`
Type GroupSyncableType `json:"type,omitempty"`
*Alias
}{
TeamDisplayName: syncable.TeamDisplayName,
TeamType: syncable.TeamType,
TeamID: syncable.SyncableId,
Type: syncable.Type,
Alias: (*Alias)(syncable),
})
case GroupSyncableTypeChannel:
return json.Marshal(&struct {
ChannelID string `json:"channel_id"`
ChannelDisplayName string `json:"channel_display_name,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
Type GroupSyncableType `json:"type,omitempty"`
TeamID string `json:"team_id,omitempty"`
TeamDisplayName string `json:"team_display_name,omitempty"`
TeamType string `json:"team_type,omitempty"`
*Alias
}{
ChannelID: syncable.SyncableId,
ChannelDisplayName: syncable.ChannelDisplayName,
ChannelType: syncable.ChannelType,
Type: syncable.Type,
TeamID: syncable.TeamID,
TeamDisplayName: syncable.TeamDisplayName,
TeamType: syncable.TeamType,
Alias: (*Alias)(syncable),
})
default:
return nil, fmt.Errorf("unknown syncable type: %s", syncable.Type)
}
}
type GroupSyncablePatch struct {
AutoAdd *bool `json:"auto_add"`
SchemeAdmin *bool `json:"scheme_admin"`
}
func (syncable *GroupSyncablePatch) Auditable() map[string]interface{} {
return map[string]interface{}{
"auto_add": syncable.AutoAdd,
"scheme_admin": syncable.SchemeAdmin,
}
}
func (syncable *GroupSyncable) Patch(patch *GroupSyncablePatch) {
if patch.AutoAdd != nil {
syncable.AutoAdd = *patch.AutoAdd
}
if patch.SchemeAdmin != nil {
syncable.SchemeAdmin = *patch.SchemeAdmin
}
}
type UserTeamIDPair struct {
UserID string
TeamID string
}
type UserChannelIDPair struct {
UserID string
ChannelID string
}
func NewGroupTeam(groupID, teamID string, autoAdd bool) *GroupSyncable {
return &GroupSyncable{
GroupId: groupID,
SyncableId: teamID,
Type: GroupSyncableTypeTeam,
AutoAdd: autoAdd,
}
}
func NewGroupChannel(groupID, channelID string, autoAdd bool) *GroupSyncable {
return &GroupSyncable{
GroupId: groupID,
SyncableId: channelID,
Type: GroupSyncableTypeChannel,
AutoAdd: autoAdd,
}
}

20
server/model/group_syncable_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,20 @@
// 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 TestGroupSyncableMarshal(t *testing.T) {
require.NotPanics(t, func() {
var syncable GroupSyncable
_, err := json.Marshal(&syncable)
require.Error(t, err)
t.Log(err.Error())
}, "marshaling groupsyncable should not panic")
}

46
server/model/guest_invite.go Обычный файл
Просмотреть файл

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
)
type GuestsInvite struct {
Emails []string `json:"emails"`
Channels []string `json:"channels"`
Message string `json:"message"`
}
func (i *GuestsInvite) Auditable() map[string]interface{} {
return map[string]interface{}{
"emails": i.Emails,
"channels": i.Channels,
}
}
// IsValid validates the user and returns an error if it isn't configured
// correctly.
func (i *GuestsInvite) IsValid() *AppError {
if len(i.Emails) == 0 {
return NewAppError("GuestsInvite.IsValid", "model.guest.is_valid.emails.app_error", nil, "", http.StatusBadRequest)
}
for _, email := range i.Emails {
if len(email) > UserEmailMaxLength || email == "" || !IsValidEmail(email) {
return NewAppError("GuestsInvite.IsValid", "model.guest.is_valid.email.app_error", nil, "email="+email, http.StatusBadRequest)
}
}
if len(i.Channels) == 0 {
return NewAppError("GuestsInvite.IsValid", "model.guest.is_valid.channels.app_error", nil, "", http.StatusBadRequest)
}
for _, channel := range i.Channels {
if len(channel) != 26 {
return NewAppError("GuestsInvite.IsValid", "model.guest.is_valid.channel.app_error", nil, "channel="+channel, http.StatusBadRequest)
}
}
return nil
}

67
server/model/hosted_customer.go Обычный файл
Просмотреть файл

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type BootstrapSelfHostedSignupRequest struct {
Email string `json:"email"`
Reset bool `json:"reset"`
}
type SubscribeNewsletterRequest struct {
Email string `json:"email"`
ServerID string `json:"server_id"`
SubscribedContent string `json:"subscribed_content"`
}
type BootstrapSelfHostedSignupResponse struct {
Progress string `json:"progress"`
// email listed on the JWT claim
Email string `json:"email"`
}
type BootstrapSelfHostedSignupResponseInternal struct {
Progress string `json:"progress"`
License string `json:"license"`
}
// email contained in token, so not in the request body.
type SelfHostedCustomerForm struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
BillingAddress *Address `json:"billing_address"`
ShippingAddress *Address `json:"shipping_address"`
Organization string `json:"organization"`
}
type SelfHostedConfirmPaymentMethodRequest struct {
StripeSetupIntentID string `json:"stripe_setup_intent_id"`
Subscription CreateSubscriptionRequest `json:"subscription"`
}
// SelfHostedSignupPaymentResponse contains feels needed for self hosted signup to confirm payment and receive license.
type SelfHostedSignupCustomerResponse struct {
CustomerId string `json:"customer_id"`
SetupIntentId string `json:"setup_intent_id"`
SetupIntentSecret string `json:"setup_intent_secret"`
Progress string `json:"progress"`
}
// SelfHostedSignupConfirmResponse contains data received on successful self hosted signup
type SelfHostedSignupConfirmResponse struct {
License string `json:"license"`
Progress string `json:"progress"`
}
type SelfHostedSignupConfirmClientResponse struct {
License map[string]string `json:"license"`
Progress string `json:"progress"`
}
type SelfHostedBillingAccessRequest struct {
LicenseId string `json:"license_id"`
}
type SelfHostedBillingAccessResponse struct {
Token string `json:"token"`
}

203
server/model/incoming_webhook.go Обычный файл
Просмотреть файл

@@ -0,0 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"bytes"
"encoding/json"
"io"
"net/http"
"regexp"
)
const (
DefaultWebhookUsername = "webhook"
)
type IncomingWebhook struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
UserId string `json:"user_id"`
ChannelId string `json:"channel_id"`
TeamId string `json:"team_id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
ChannelLocked bool `json:"channel_locked"`
}
func (o *IncomingWebhook) Auditable() map[string]interface{} {
return map[string]interface{}{
"id": o.Id,
"create_at": o.CreateAt,
"update_at": o.UpdateAt,
"delete_at": o.DeleteAt,
"user_id": o.UserId,
"channel_id": o.ChannelId,
"team_id": o.TeamId,
"display_name": o.DisplayName,
"description": o.Description,
"username": o.Username,
"icon_url:": o.IconURL,
"channel_locked": o.ChannelLocked,
}
}
type IncomingWebhookRequest struct {
Text string `json:"text"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
ChannelName string `json:"channel"`
Props StringInterface `json:"props"`
Attachments []*SlackAttachment `json:"attachments"`
Type string `json:"type"`
IconEmoji string `json:"icon_emoji"`
}
func (o *IncomingWebhook) IsValid() *AppError {
if !IsValidId(o.Id) {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.id.app_error", nil, "", http.StatusBadRequest)
}
if o.CreateAt == 0 {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.UpdateAt == 0 {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.update_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if !IsValidId(o.UserId) {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.user_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.ChannelId) {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.channel_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.TeamId) {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.team_id.app_error", nil, "", http.StatusBadRequest)
}
if len(o.DisplayName) > 64 {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.display_name.app_error", nil, "", http.StatusBadRequest)
}
if len(o.Description) > 500 {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.description.app_error", nil, "", http.StatusBadRequest)
}
if len(o.Username) > 64 {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.username.app_error", nil, "", http.StatusBadRequest)
}
if len(o.IconURL) > 1024 {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.icon_url.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
func (o *IncomingWebhook) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
o.CreateAt = GetMillis()
o.UpdateAt = o.CreateAt
}
func (o *IncomingWebhook) PreUpdate() {
o.UpdateAt = GetMillis()
}
// escapeControlCharsFromPayload escapes control chars (\n, \t) from a byte slice.
// Context:
// JSON strings are not supposed to contain control characters such as \n, \t,
// ... but some incoming webhooks might still send invalid JSON and we want to
// try to handle that. An example invalid JSON string from an incoming webhook
// might look like this (strings for both "text" and "fallback" attributes are
// invalid JSON strings because they contain unescaped newlines and tabs):
//
// `{
// "text": "this is a test
// that contains a newline and tabs",
// "attachments": [
// {
// "fallback": "Required plain-text summary of the attachment
// that contains a newline and tabs",
// "color": "#36a64f",
// ...
// "text": "Optional text that appears within the attachment
// that contains a newline and tabs",
// ...
// "thumb_url": "http://example.com/path/to/thumb.png"
// }
// ]
// }`
//
// This function will search for `"key": "value"` pairs, and escape \n, \t
// from the value.
func escapeControlCharsFromPayload(by []byte) []byte {
// we'll search for `"text": "..."` or `"fallback": "..."`, ...
keys := "text|fallback|pretext|author_name|title|value"
// the regexp reads like this:
// (?s): this flag let . match \n (default is false)
// "(keys)": we search for the keys defined above
// \s*:\s*: followed by 0..n spaces/tabs, a colon then 0..n spaces/tabs
// ": a double-quote
// (\\"|[^"])*: any number of times the `\"` string or any char but a double-quote
// ": a double-quote
r := `(?s)"(` + keys + `)"\s*:\s*"(\\"|[^"])*"`
re := regexp.MustCompile(r)
// the function that will escape \n and \t on the regexp matches
repl := func(b []byte) []byte {
if bytes.Contains(b, []byte("\n")) {
b = bytes.Replace(b, []byte("\n"), []byte("\\n"), -1)
}
if bytes.Contains(b, []byte("\t")) {
b = bytes.Replace(b, []byte("\t"), []byte("\\t"), -1)
}
return b
}
return re.ReplaceAllFunc(by, repl)
}
func decodeIncomingWebhookRequest(by []byte) (*IncomingWebhookRequest, error) {
decoder := json.NewDecoder(bytes.NewReader(by))
var o IncomingWebhookRequest
err := decoder.Decode(&o)
if err == nil {
return &o, nil
}
return nil, err
}
func IncomingWebhookRequestFromJSON(data io.Reader) (*IncomingWebhookRequest, *AppError) {
buf := new(bytes.Buffer)
buf.ReadFrom(data)
by := buf.Bytes()
// Try to decode the JSON data. Only if it fails, try to escape control
// characters from the strings contained in the JSON data.
o, err := decodeIncomingWebhookRequest(by)
if err != nil {
o, err = decodeIncomingWebhookRequest(escapeControlCharsFromPayload(by))
if err != nil {
return nil, NewAppError("IncomingWebhookRequestFromJSON", "model.incoming_hook.parse_data.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
}
o.Attachments = StringifySlackFieldValue(o.Attachments)
return o, nil
}

152
server/model/incoming_webhook_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,152 @@
// 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 TestIncomingWebhookIsValid(t *testing.T) {
o := IncomingWebhook{}
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.UserId = "123"
require.NotNil(t, o.IsValid())
o.UserId = NewId()
require.NotNil(t, o.IsValid())
o.ChannelId = "123"
require.NotNil(t, o.IsValid())
o.ChannelId = NewId()
require.NotNil(t, o.IsValid())
o.TeamId = "123"
require.NotNil(t, o.IsValid())
o.TeamId = NewId()
require.Nil(t, o.IsValid())
o.DisplayName = strings.Repeat("1", 65)
require.NotNil(t, o.IsValid())
o.DisplayName = strings.Repeat("1", 64)
require.Nil(t, o.IsValid())
o.Description = strings.Repeat("1", 501)
require.NotNil(t, o.IsValid())
o.Description = strings.Repeat("1", 500)
require.Nil(t, o.IsValid())
o.Username = strings.Repeat("1", 65)
require.NotNil(t, o.IsValid())
o.Username = strings.Repeat("1", 64)
require.Nil(t, o.IsValid())
o.IconURL = strings.Repeat("1", 1025)
require.NotNil(t, o.IsValid())
o.IconURL = strings.Repeat("1", 1024)
require.Nil(t, o.IsValid())
}
func TestIncomingWebhookPreSave(t *testing.T) {
o := IncomingWebhook{}
o.PreSave()
}
func TestIncomingWebhookPreUpdate(t *testing.T) {
o := IncomingWebhook{}
o.PreUpdate()
}
func TestIncomingWebhookRequestFromJSON(t *testing.T) {
texts := []string{
`this is a test`,
`this is a test
that contains a newline and tabs`,
`this is a test \"foo
that contains a newline and tabs`,
`this is a test \"foo\"
that contains a newline and tabs`,
`this is a test \"foo\"
\" that contains a newline and tabs`,
`this is a test \"foo\"
\" that contains a newline and tabs
`,
}
for _, text := range texts {
// build a sample payload with the text
payload := `{
"text": "` + text + `",
"attachments": [
{
"fallback": "` + text + `",
"color": "#36a64f",
"pretext": "` + text + `",
"author_name": "` + text + `",
"author_link": "http://flickr.com/bobby/",
"author_icon": "http://flickr.com/icons/bobby.jpg",
"title": "` + text + `",
"title_link": "https://api.slack.com/",
"text": "` + text + `",
"fields": [
{
"title": "` + text + `",
"value": "` + text + `",
"short": false
}
],
"image_url": "http://my-website.com/path/to/image.jpg",
"thumb_url": "http://example.com/path/to/thumb.png"
}
]
}`
// try to create an IncomingWebhookRequest from the payload
data := strings.NewReader(payload)
iwr, _ := IncomingWebhookRequestFromJSON(data)
// After it has been decoded, the JSON string won't contain the escape char anymore
expected := strings.Replace(text, `\"`, `"`, -1)
require.NotNil(t, iwr)
require.Equal(t, expected, iwr.Text)
attachment := iwr.Attachments[0]
require.Equal(t, expected, attachment.Text)
}
}
func TestIncomingWebhookNullArrayItems(t *testing.T) {
payload := `{"attachments":[{"fields":[{"title":"foo","value":"bar","short":true}, null]}, null]}`
iwr, _ := IncomingWebhookRequestFromJSON(strings.NewReader(payload))
require.NotNil(t, iwr)
require.Len(t, iwr.Attachments, 1)
require.Len(t, iwr.Attachments[0].Fields, 1)
}

14
server/model/initial_load.go Обычный файл
Просмотреть файл

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type InitialLoad struct {
User *User `json:"user"`
TeamMembers []*TeamMember `json:"team_members"`
Teams []*Team `json:"teams"`
Preferences Preferences `json:"preferences"`
ClientCfg map[string]string `json:"client_cfg"`
LicenseCfg map[string]string `json:"license_cfg"`
NoAccounts bool `json:"no_accounts"`
}

363
server/model/insights.go Обычный файл
Просмотреть файл

@@ -0,0 +1,363 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"time"
)
type PostCountGrouping string
const (
TimeRangeToday string = "today"
TimeRange7Day string = "7_day"
TimeRange28Day string = "28_day"
PostsByHour PostCountGrouping = "hour"
PostsByDay PostCountGrouping = "day"
)
type InsightsOpts struct {
StartUnixMilli int64
Page int
PerPage int
}
type InsightsListData struct {
HasNext bool `json:"has_next"`
}
// Top Reactions
type TopReactionList struct {
InsightsListData
Items []*TopReaction `json:"items"`
}
type TopReaction struct {
EmojiName string `json:"emoji_name"`
Count int64 `json:"count"`
}
// Top Channels
type TopChannelList struct {
InsightsListData
Items []*TopChannel `json:"items"`
PostCountByDuration ChannelPostCountByDuration `json:"channel_post_counts_by_duration"`
}
func (t *TopChannelList) ChannelIDs() []string {
var ids []string
for _, item := range t.Items {
ids = append(ids, item.ID)
}
return ids
}
type TopChannel struct {
ID string `json:"id"`
Type ChannelType `json:"type"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
TeamID string `json:"team_id"`
MessageCount int64 `json:"message_count"`
}
// Top Channels
type TopInactiveChannelList struct {
InsightsListData
Items []*TopInactiveChannel `json:"items"`
}
type TopInactiveChannel struct {
ID string `json:"id"`
Type ChannelType `json:"type"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
LastActivityAt int64 `json:"last_activity_at"`
Participants StringArray `json:"participants"`
MessageCount int64 `json:"-"`
}
// Top Threads
type TopThreadList struct {
InsightsListData
Items []*TopThread `json:"items"`
}
type TopThread struct {
PostId string `json:"-"`
ReplyCount int64 `json:"-"`
ChannelId string `json:"channel_id"`
DisplayName string `json:"channel_display_name"`
Name string `json:"channel_name"`
Participants StringArray `json:"participants"`
UserId string `json:"-"`
UserInformation *InsightUserInformation `json:"user_information"`
Post *Post `json:"post"`
}
type InsightUserInformation struct {
Id string `json:"id"`
LastPictureUpdate int64 `json:"last_picture_update"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
NickName string `json:"nickname"`
Username string `json:"username"`
}
type TopDMInsightUserInformation struct {
InsightUserInformation
Position string `json:"position"`
}
type NewTeamMembersList struct {
InsightsListData
Items []*NewTeamMember `json:"items"`
TotalCount int64 `json:"total_count"`
}
type NewTeamMember struct {
Id string `json:"id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Position string `json:"position"`
Nickname string `json:"nickname"`
LastPictureUpdate int64 `json:"last_picture_update,omitempty"`
CreateAt int64 `json:"create_at"`
}
type DurationPostCount struct {
ChannelID string `db:"channelid"`
// Duration is an ISO8601 date string.
Duration string `db:"duration"`
PostCount int `db:"postcount"`
}
// Top DMs
type TopDM struct {
MessageCount int64 `json:"post_count"`
OutgoingMessageCount int64 `json:"outgoing_message_count"`
Participants string `json:"-"`
ChannelId string `json:"-"`
SecondParticipant *TopDMInsightUserInformation `json:"second_participant"`
}
type OutgoingMessageQueryResult struct {
ChannelId string
MessageCount int
}
type TopDMList struct {
InsightsListData
Items []*TopDM `json:"items"`
}
func TimeRangeToNumberDays(timeRange string) int {
var n int
switch timeRange {
case TimeRangeToday:
n = 1
case TimeRange7Day:
n = 7
case TimeRange28Day:
n = 28
}
return n
}
// ChannelPostCountByDuration contains a count of posts by channel id, grouped by ISO8601 date string.
// Example 1 (grouped by day):
//
// cpc := model.ChannelPostCountByDuration{
// "2009-11-11": {
// "ezbp7nqxzjgdir8riodyafr9ww": 90,
// "p949c1xdojfgzffxma3p3s3ikr": 201,
// },
// "2009-11-12": {
// "ezbp7nqxzjgdir8riodyafr9ww": 45,
// "p949c1xdojfgzffxma3p3s3ikr": 68,
// },
// }
//
// Example 2 (grouped by hour):
//
// cpc := model.ChannelPostCountByDuration{
// "2009-11-11T01": {
// "ezbp7nqxzjgdir8riodyafr9ww": 90,
// "p949c1xdojfgzffxma3p3s3ikr": 201,
// },
// "2009-11-11T02": {
// "ezbp7nqxzjgdir8riodyafr9ww": 45,
// "p949c1xdojfgzffxma3p3s3ikr": 68,
// },
// }
type ChannelPostCountByDuration map[string]map[string]int
func blankChannelCountsMap(channelIDs []string) map[string]int {
blankChannelCounts := map[string]int{}
for _, id := range channelIDs {
blankChannelCounts[id] = 0
}
return blankChannelCounts
}
func ToDailyPostCountViewModel(dpc []*DurationPostCount, startTime *time.Time, numDays int, channelIDs []string) ChannelPostCountByDuration {
viewModel := ChannelPostCountByDuration{}
keyTime := *startTime
nowAtLocation := time.Now().In(startTime.Location())
if numDays == 1 {
for keyTime.Before(nowAtLocation) {
dateTimeKey := keyTime.Format(time.RFC3339)
viewModel[dateTimeKey] = blankChannelCountsMap(channelIDs)
keyTime = keyTime.Add(time.Hour)
}
} else {
for keyTime.Before(nowAtLocation) {
dateTimeKey := keyTime.Format("2006-01-02")
viewModel[dateTimeKey] = blankChannelCountsMap(channelIDs)
keyTime = keyTime.Add(24 * time.Hour)
}
}
for _, item := range dpc {
var parseFormat string
var keyFormat string
if numDays == 1 {
parseFormat = "2006-01-02T15 "
keyFormat = time.RFC3339
} else {
parseFormat = "2006-01-02"
keyFormat = parseFormat
}
durTime, err := time.ParseInLocation(parseFormat, item.Duration, startTime.Location())
if err != nil {
continue
}
localizedKey := durTime.Format(keyFormat)
_, hasKey := viewModel[localizedKey]
if !hasKey {
viewModel[localizedKey] = map[string]int{}
}
viewModel[localizedKey][item.ChannelID] = item.PostCount
}
return viewModel
}
// Deprecated: This method doesn't perform error checking.
// Use GetStartOfDayForTimeRange instead.
//
// StartOfDayForTimeRange gets the unix start time in milliseconds from the given time range.
// Time range can be one of: "today", "7_day", or "28_day".
func StartOfDayForTimeRange(timeRange string, location *time.Location) *time.Time {
now := time.Now().In(location)
resultTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
switch timeRange {
case TimeRange7Day:
resultTime = resultTime.Add(time.Hour * time.Duration(-144))
case TimeRange28Day:
resultTime = resultTime.Add(time.Hour * time.Duration(-648))
}
return &resultTime
}
// GetStartOfDayForTimeRange gets the unix start time in milliseconds from the given time range.
// Time range can be one of: "today", "7_day", or "28_day".
func GetStartOfDayForTimeRange(timeRange string, location *time.Location) (*time.Time, *AppError) {
now := time.Now().In(location)
resultTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
switch timeRange {
case TimeRangeToday:
case TimeRange7Day:
resultTime = resultTime.Add(time.Hour * time.Duration(-144))
case TimeRange28Day:
resultTime = resultTime.Add(time.Hour * time.Duration(-648))
default:
return nil, NewAppError("GetStartOfDayForTimeRange", "model.insights.get_start_of_day_for_time_range.time_range.app_error", nil, "", http.StatusBadRequest)
}
return &resultTime, nil
}
// GetTopReactionListWithPagination adds a rank to each item in the given list of TopReaction and checks if there is
// another page that can be fetched based on the given limit and offset. The given list of TopReaction is assumed to be
// sorted by Count. Returns a TopReactionList.
func GetTopReactionListWithPagination(reactions []*TopReaction, limit int) *TopReactionList {
// Add pagination support
var hasNext bool
if (limit != 0) && (len(reactions) == limit+1) {
hasNext = true
reactions = reactions[:len(reactions)-1]
}
return &TopReactionList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: reactions}
}
// GetTopChannelListWithPagination adds a rank to each item in the given list of TopChannel and checks if there is
// another page that can be fetched based on the given limit and offset. The given list of TopChannel is assumed to be
// sorted by Score. Returns a TopChannelList.
func GetTopChannelListWithPagination(channels []*TopChannel, limit int) *TopChannelList {
// Add pagination support
var hasNext bool
if (limit != 0) && (len(channels) == limit+1) {
hasNext = true
channels = channels[:len(channels)-1]
}
return &TopChannelList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: channels}
}
// GetTopThreadListWithPagination adds a rank to each item in the given list of TopThread and checks if there is
// another page that can be fetched based on the given limit and offset. The given list of TopThread is assumed to be
// sorted by ReplyCount(score). Returns a TopThreadList.
func GetTopThreadListWithPagination(threads []*TopThread, limit int) *TopThreadList {
// Add pagination support
var hasNext bool
if (limit != 0) && (len(threads) == limit+1) {
hasNext = true
threads = threads[:len(threads)-1]
}
return &TopThreadList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: threads}
}
// GetTopInactiveChannelListWithPagination adds a rank to each item in the given list of TopInactiveChannel and checks if there is
// another page that can be fetched based on the given limit and offset. The given list of TopInactiveChannel is assumed to be
// sorted by Score. Returns a TopInactiveChannelList.
func GetTopInactiveChannelListWithPagination(channels []*TopInactiveChannel, limit int) *TopInactiveChannelList {
// Add pagination support
var hasNext bool
if (limit != 0) && (len(channels) == limit+1) {
hasNext = true
channels = channels[:len(channels)-1]
}
return &TopInactiveChannelList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: channels}
}
// GetTopDMListWithPagination adds a rank to each item in the given list of TopDM and checks if there is
// another page that can be fetched based on the given limit and offset. The given list of TopDM is assumed to be
// sorted by MessageCount(score). Returns a TopDMList.
func GetTopDMListWithPagination(dms []*TopDM, limit int) *TopDMList {
// Add pagination support
var hasNext bool
if (limit != 0) && (len(dms) == limit+1) {
hasNext = true
dms = dms[:len(dms)-1]
}
return &TopDMList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: dms}
}
func GetNewTeamMembersListWithPagination(teamMembers []*NewTeamMember, limit int) *NewTeamMembersList {
var hasNext bool
if (limit != 0) && (len(teamMembers) == limit+1) {
hasNext = true
teamMembers = teamMembers[:len(teamMembers)-1]
}
return &NewTeamMembersList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: teamMembers}
}

197
server/model/insights_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,197 @@
// 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 TestGetTopReactionListWithPagination(t *testing.T) {
reactions := []*TopReaction{
{EmojiName: "smile", Count: 200},
{EmojiName: "+1", Count: 190},
{EmojiName: "100", Count: 100},
{EmojiName: "-1", Count: 75},
{EmojiName: "checkmark", Count: 50},
{EmojiName: "mattermost", Count: 49}}
hasNextTC := []struct {
Description string
Limit int
Offset int
Expected *TopReactionList
}{
{
Description: "has one page",
Limit: len(reactions),
Offset: 0,
Expected: &TopReactionList{InsightsListData: InsightsListData{HasNext: false}, Items: reactions},
},
{
Description: "has more than one page",
Limit: len(reactions) - 1,
Offset: 0,
Expected: &TopReactionList{InsightsListData: InsightsListData{HasNext: true}, Items: reactions},
},
}
for _, test := range hasNextTC {
t.Run(test.Description, func(t *testing.T) {
actual := GetTopReactionListWithPagination(reactions, test.Limit)
assert.Equal(t, test.Expected.HasNext, actual.HasNext)
})
}
}
func TestGetTopChannelListWithPagination(t *testing.T) {
channels := []*TopChannel{
{ID: NewId(), MessageCount: 200},
{ID: NewId(), MessageCount: 150},
{ID: NewId(), MessageCount: 120},
{ID: NewId(), MessageCount: 105},
{ID: NewId(), MessageCount: 5},
{ID: NewId(), MessageCount: 2}}
hasNextTC := []struct {
Description string
Limit int
Offset int
Expected *TopChannelList
}{
{
Description: "has one page",
Limit: len(channels),
Offset: 0,
Expected: &TopChannelList{InsightsListData: InsightsListData{HasNext: false}, Items: channels},
},
{
Description: "has more than one page",
Limit: len(channels) - 1,
Offset: 0,
Expected: &TopChannelList{InsightsListData: InsightsListData{HasNext: true}, Items: channels},
},
}
for _, test := range hasNextTC {
t.Run(test.Description, func(t *testing.T) {
actual := GetTopChannelListWithPagination(channels, test.Limit)
assert.Equal(t, test.Expected.HasNext, actual.HasNext)
})
}
}
func TestGetTopThreadListWithPagination(t *testing.T) {
threads := []*TopThread{
{PostId: NewId(), ReplyCount: 100},
{PostId: NewId(), ReplyCount: 80},
{PostId: NewId(), ReplyCount: 90},
{PostId: NewId(), ReplyCount: 76},
{PostId: NewId(), ReplyCount: 43},
{PostId: NewId(), ReplyCount: 2},
{PostId: NewId(), ReplyCount: 1},
}
hasNextTT := []struct {
Description string
Limit int
Offset int
Expected *TopThreadList
}{
{
Description: "has one page",
Limit: len(threads),
Offset: 0,
Expected: &TopThreadList{InsightsListData: InsightsListData{HasNext: false}, Items: threads},
},
{
Description: "has more than one page",
Limit: len(threads) - 1,
Offset: 0,
Expected: &TopThreadList{InsightsListData: InsightsListData{HasNext: true}, Items: threads},
},
}
for _, test := range hasNextTT {
t.Run(test.Description, func(t *testing.T) {
actual := GetTopThreadListWithPagination(threads, test.Limit)
assert.Equal(t, test.Expected.HasNext, actual.HasNext)
})
}
}
func TestGetTopInactiveChannelListWithPagination(t *testing.T) {
channels := []*TopInactiveChannel{
{ID: NewId(), MessageCount: 2},
{ID: NewId(), MessageCount: 5},
{ID: NewId(), MessageCount: 7},
{ID: NewId(), MessageCount: 80},
{ID: NewId(), MessageCount: 85},
{ID: NewId(), MessageCount: 92}}
hasNextTC := []struct {
Description string
Limit int
Offset int
Expected *TopInactiveChannelList
}{
{
Description: "has one page",
Limit: len(channels),
Offset: 0,
Expected: &TopInactiveChannelList{InsightsListData: InsightsListData{HasNext: false}, Items: channels},
},
{
Description: "has more than one page",
Limit: len(channels) - 1,
Offset: 0,
Expected: &TopInactiveChannelList{InsightsListData: InsightsListData{HasNext: true}, Items: channels},
},
}
for _, test := range hasNextTC {
t.Run(test.Description, func(t *testing.T) {
actual := GetTopInactiveChannelListWithPagination(channels, test.Limit)
assert.Equal(t, test.Expected.HasNext, actual.HasNext)
})
}
}
func TestGetTopDMsListWithPagination(t *testing.T) {
dms := []*TopDM{
{SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 100},
{SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 80},
{SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 90},
{SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 76},
{SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 43},
{SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 2},
{SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 1},
}
hasNextTT := []struct {
Description string
Limit int
Offset int
Expected *TopDMList
}{
{
Description: "has one page",
Limit: len(dms),
Offset: 0,
Expected: &TopDMList{InsightsListData: InsightsListData{HasNext: false}, Items: dms},
},
{
Description: "has more than one page",
Limit: len(dms) - 1,
Offset: 0,
Expected: &TopDMList{InsightsListData: InsightsListData{HasNext: true}, Items: dms},
},
}
for _, test := range hasNextTT {
t.Run(test.Description, func(t *testing.T) {
actual := GetTopDMListWithPagination(dms, test.Limit)
assert.Equal(t, test.Expected.HasNext, actual.HasNext)
})
}
}

478
server/model/integration_action.go Обычный файл
Просмотреть файл

@@ -0,0 +1,478 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/rand"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"reflect"
"strconv"
"strings"
)
const (
PostActionTypeButton = "button"
PostActionTypeSelect = "select"
InteractiveDialogTriggerTimeoutMilliseconds = 3000
)
var PostActionRetainPropKeys = []string{"from_webhook", "override_username", "override_icon_url"}
type DoPostActionRequest struct {
SelectedOption string `json:"selected_option,omitempty"`
Cookie string `json:"cookie,omitempty"`
}
type PostAction struct {
// A unique Action ID. If not set, generated automatically.
Id string `json:"id,omitempty"`
// The type of the interactive element. Currently supported are
// "select" and "button".
Type string `json:"type,omitempty"`
// The text on the button, or in the select placeholder.
Name string `json:"name,omitempty"`
// If the action is disabled.
Disabled bool `json:"disabled,omitempty"`
// Style defines a text and border style.
// Supported values are "default", "primary", "success", "good", "warning", "danger"
// and any hex color.
Style string `json:"style,omitempty"`
// DataSource indicates the data source for the select action. If left
// empty, the select is populated from Options. Other supported values
// are "users" and "channels".
DataSource string `json:"data_source,omitempty"`
// Options contains the values listed in a select dropdown on the post.
Options []*PostActionOptions `json:"options,omitempty"`
// DefaultOption contains the option, if any, that will appear as the
// default selection in a select box. It has no effect when used with
// other types of actions.
DefaultOption string `json:"default_option,omitempty"`
// Defines the interaction with the backend upon a user action.
// Integration contains Context, which is private plugin data;
// Integrations are stripped from Posts when they are sent to the
// client, or are encrypted in a Cookie.
Integration *PostActionIntegration `json:"integration,omitempty"`
Cookie string `json:"cookie,omitempty" db:"-"`
}
func (p *PostAction) Equals(input *PostAction) bool {
if p.Id != input.Id {
return false
}
if p.Type != input.Type {
return false
}
if p.Name != input.Name {
return false
}
if p.DataSource != input.DataSource {
return false
}
if p.DefaultOption != input.DefaultOption {
return false
}
if p.Cookie != input.Cookie {
return false
}
// Compare PostActionOptions
if len(p.Options) != len(input.Options) {
return false
}
for k := range p.Options {
if p.Options[k].Text != input.Options[k].Text {
return false
}
if p.Options[k].Value != input.Options[k].Value {
return false
}
}
// Compare PostActionIntegration
// If input is nil, then return true if original is also nil.
// Else return false.
if input.Integration == nil {
return p.Integration == nil
}
// Both are unequal and not nil.
if p.Integration.URL != input.Integration.URL {
return false
}
if len(p.Integration.Context) != len(input.Integration.Context) {
return false
}
for key, value := range p.Integration.Context {
inputValue, ok := input.Integration.Context[key]
if !ok {
return false
}
switch inputValue.(type) {
case string, bool, int, float64:
if value != inputValue {
return false
}
default:
if !reflect.DeepEqual(value, inputValue) {
return false
}
}
}
return true
}
// PostActionCookie is set by the server, serialized and encrypted into
// PostAction.Cookie. The clients should hold on to it, and include it with
// subsequent DoPostAction requests. This allows the server to access the
// action metadata even when it's not available in the database, for ephemeral
// posts.
type PostActionCookie struct {
Type string `json:"type,omitempty"`
PostId string `json:"post_id,omitempty"`
RootPostId string `json:"root_post_id,omitempty"`
ChannelId string `json:"channel_id,omitempty"`
DataSource string `json:"data_source,omitempty"`
Integration *PostActionIntegration `json:"integration,omitempty"`
RetainProps map[string]any `json:"retain_props,omitempty"`
RemoveProps []string `json:"remove_props,omitempty"`
}
type PostActionOptions struct {
Text string `json:"text"`
Value string `json:"value"`
}
type PostActionIntegration struct {
URL string `json:"url,omitempty"`
Context map[string]any `json:"context,omitempty"`
}
type PostActionIntegrationRequest struct {
UserId string `json:"user_id"`
UserName string `json:"user_name"`
ChannelId string `json:"channel_id"`
ChannelName string `json:"channel_name"`
TeamId string `json:"team_id"`
TeamName string `json:"team_domain"`
PostId string `json:"post_id"`
TriggerId string `json:"trigger_id"`
Type string `json:"type"`
DataSource string `json:"data_source"`
Context map[string]any `json:"context,omitempty"`
}
type PostActionIntegrationResponse struct {
Update *Post `json:"update"`
EphemeralText string `json:"ephemeral_text"`
SkipSlackParsing bool `json:"skip_slack_parsing"` // Set to `true` to skip the Slack-compatibility handling of Text.
}
type PostActionAPIResponse struct {
Status string `json:"status"` // needed to maintain backwards compatibility
TriggerId string `json:"trigger_id"`
}
type Dialog struct {
CallbackId string `json:"callback_id"`
Title string `json:"title"`
IntroductionText string `json:"introduction_text"`
IconURL string `json:"icon_url"`
Elements []DialogElement `json:"elements"`
SubmitLabel string `json:"submit_label"`
NotifyOnCancel bool `json:"notify_on_cancel"`
State string `json:"state"`
}
type DialogElement struct {
DisplayName string `json:"display_name"`
Name string `json:"name"`
Type string `json:"type"`
SubType string `json:"subtype"`
Default string `json:"default"`
Placeholder string `json:"placeholder"`
HelpText string `json:"help_text"`
Optional bool `json:"optional"`
MinLength int `json:"min_length"`
MaxLength int `json:"max_length"`
DataSource string `json:"data_source"`
Options []*PostActionOptions `json:"options"`
}
type OpenDialogRequest struct {
TriggerId string `json:"trigger_id"`
URL string `json:"url"`
Dialog Dialog `json:"dialog"`
}
type SubmitDialogRequest struct {
Type string `json:"type"`
URL string `json:"url,omitempty"`
CallbackId string `json:"callback_id"`
State string `json:"state"`
UserId string `json:"user_id"`
ChannelId string `json:"channel_id"`
TeamId string `json:"team_id"`
Submission map[string]any `json:"submission"`
Cancelled bool `json:"cancelled"`
}
type SubmitDialogResponse struct {
Error string `json:"error,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
}
func GenerateTriggerId(userId string, s crypto.Signer) (string, string, *AppError) {
clientTriggerId := NewId()
triggerData := strings.Join([]string{clientTriggerId, userId, strconv.FormatInt(GetMillis(), 10)}, ":") + ":"
h := crypto.SHA256
sum := h.New()
sum.Write([]byte(triggerData))
signature, err := s.Sign(rand.Reader, sum.Sum(nil), h)
if err != nil {
return "", "", NewAppError("GenerateTriggerId", "interactive_message.generate_trigger_id.signing_failed", nil, "", http.StatusInternalServerError).Wrap(err)
}
base64Sig := base64.StdEncoding.EncodeToString(signature)
triggerId := base64.StdEncoding.EncodeToString([]byte(triggerData + base64Sig))
return clientTriggerId, triggerId, nil
}
func (r *PostActionIntegrationRequest) GenerateTriggerId(s crypto.Signer) (string, string, *AppError) {
clientTriggerId, triggerId, appErr := GenerateTriggerId(r.UserId, s)
if appErr != nil {
return "", "", appErr
}
r.TriggerId = triggerId
return clientTriggerId, triggerId, nil
}
func DecodeAndVerifyTriggerId(triggerId string, s *ecdsa.PrivateKey) (string, string, *AppError) {
triggerIdBytes, err := base64.StdEncoding.DecodeString(triggerId)
if err != nil {
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed", nil, "", http.StatusBadRequest).Wrap(err)
}
split := strings.Split(string(triggerIdBytes), ":")
if len(split) != 4 {
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.missing_data", nil, "", http.StatusBadRequest)
}
clientTriggerId := split[0]
userId := split[1]
timestampStr := split[2]
timestamp, _ := strconv.ParseInt(timestampStr, 10, 64)
now := GetMillis()
if now-timestamp > InteractiveDialogTriggerTimeoutMilliseconds {
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.expired", map[string]any{"Seconds": InteractiveDialogTriggerTimeoutMilliseconds / 1000}, "", http.StatusBadRequest)
}
signature, err := base64.StdEncoding.DecodeString(split[3])
if err != nil {
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed_signature", nil, "", http.StatusBadRequest).Wrap(err)
}
var esig struct {
R, S *big.Int
}
if _, err := asn1.Unmarshal(signature, &esig); err != nil {
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.signature_decode_failed", nil, "", http.StatusBadRequest).Wrap(err)
}
triggerData := strings.Join([]string{clientTriggerId, userId, timestampStr}, ":") + ":"
h := crypto.SHA256
sum := h.New()
sum.Write([]byte(triggerData))
if !ecdsa.Verify(&s.PublicKey, sum.Sum(nil), esig.R, esig.S) {
return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.verify_signature_failed", nil, "", http.StatusBadRequest)
}
return clientTriggerId, userId, nil
}
func (r *OpenDialogRequest) DecodeAndVerifyTriggerId(s *ecdsa.PrivateKey) (string, string, *AppError) {
return DecodeAndVerifyTriggerId(r.TriggerId, s)
}
func (o *Post) StripActionIntegrations() {
attachments := o.Attachments()
if o.GetProp("attachments") != nil {
o.AddProp("attachments", attachments)
}
for _, attachment := range attachments {
for _, action := range attachment.Actions {
action.Integration = nil
}
}
}
func (o *Post) GetAction(id string) *PostAction {
for _, attachment := range o.Attachments() {
for _, action := range attachment.Actions {
if action != nil && action.Id == id {
return action
}
}
}
return nil
}
func (o *Post) GenerateActionIds() {
if o.GetProp("attachments") != nil {
o.AddProp("attachments", o.Attachments())
}
if attachments, ok := o.GetProp("attachments").([]*SlackAttachment); ok {
for _, attachment := range attachments {
for _, action := range attachment.Actions {
if action != nil && action.Id == "" {
action.Id = NewId()
}
}
}
}
}
func AddPostActionCookies(o *Post, secret []byte) *Post {
p := o.Clone()
// retainedProps carry over their value from the old post, including no value
retainProps := map[string]any{}
removeProps := []string{}
for _, key := range PostActionRetainPropKeys {
value, ok := p.GetProps()[key]
if ok {
retainProps[key] = value
} else {
removeProps = append(removeProps, key)
}
}
attachments := p.Attachments()
for _, attachment := range attachments {
for _, action := range attachment.Actions {
c := &PostActionCookie{
Type: action.Type,
ChannelId: p.ChannelId,
DataSource: action.DataSource,
Integration: action.Integration,
RetainProps: retainProps,
RemoveProps: removeProps,
}
c.PostId = p.Id
if p.RootId == "" {
c.RootPostId = p.Id
} else {
c.RootPostId = p.RootId
}
b, _ := json.Marshal(c)
action.Cookie, _ = encryptPostActionCookie(string(b), secret)
}
}
return p
}
func encryptPostActionCookie(plain string, secret []byte) (string, error) {
if len(secret) == 0 {
return plain, nil
}
block, err := aes.NewCipher(secret)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aesgcm.NonceSize())
_, err = io.ReadFull(rand.Reader, nonce)
if err != nil {
return "", err
}
sealed := aesgcm.Seal(nil, nonce, []byte(plain), nil)
combined := append(nonce, sealed...)
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(combined)))
base64.StdEncoding.Encode(encoded, combined)
return string(encoded), nil
}
func DecryptPostActionCookie(encoded string, secret []byte) (string, error) {
if len(secret) == 0 {
return encoded, nil
}
block, err := aes.NewCipher(secret)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(encoded)))
n, err := base64.StdEncoding.Decode(decoded, []byte(encoded))
if err != nil {
return "", err
}
decoded = decoded[:n]
nonceSize := aesgcm.NonceSize()
if len(decoded) < nonceSize {
return "", fmt.Errorf("cookie too short")
}
nonce, decoded := decoded[:nonceSize], decoded[nonceSize:]
plain, err := aesgcm.Open(nil, nonce, decoded, nil)
if err != nil {
return "", err
}
return string(plain), nil
}

157
server/model/integration_action_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTriggerIdDecodeAndVerification(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
t.Run("should succeed decoding and validation", func(t *testing.T) {
userId := NewId()
clientTriggerId, triggerId, appErr := GenerateTriggerId(userId, key)
require.Nil(t, appErr)
decodedClientTriggerId, decodedUserId, appErr := DecodeAndVerifyTriggerId(triggerId, key)
assert.Nil(t, appErr)
assert.Equal(t, clientTriggerId, decodedClientTriggerId)
assert.Equal(t, userId, decodedUserId)
})
t.Run("should succeed decoding and validation through request structs", func(t *testing.T) {
actionReq := &PostActionIntegrationRequest{
UserId: NewId(),
}
clientTriggerId, triggerId, appErr := actionReq.GenerateTriggerId(key)
require.Nil(t, appErr)
dialogReq := &OpenDialogRequest{TriggerId: triggerId}
decodedClientTriggerId, decodedUserId, appErr := dialogReq.DecodeAndVerifyTriggerId(key)
assert.Nil(t, appErr)
assert.Equal(t, clientTriggerId, decodedClientTriggerId)
assert.Equal(t, actionReq.UserId, decodedUserId)
})
t.Run("should fail on base64 decode", func(t *testing.T) {
_, _, appErr := DecodeAndVerifyTriggerId("junk!", key)
require.NotNil(t, appErr)
assert.Equal(t, "interactive_message.decode_trigger_id.base64_decode_failed", appErr.Id)
})
t.Run("should fail on trigger parsing", func(t *testing.T) {
_, _, appErr := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("junk!")), key)
require.NotNil(t, appErr)
assert.Equal(t, "interactive_message.decode_trigger_id.missing_data", appErr.Id)
})
t.Run("should fail on expired timestamp", func(t *testing.T) {
_, _, appErr := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:1234567890:junksignature")), key)
require.NotNil(t, appErr)
assert.Equal(t, "interactive_message.decode_trigger_id.expired", appErr.Id)
})
t.Run("should fail on base64 decoding signature", func(t *testing.T) {
_, _, appErr := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:12345678900000:junk!")), key)
require.NotNil(t, appErr)
assert.Equal(t, "interactive_message.decode_trigger_id.base64_decode_failed_signature", appErr.Id)
})
t.Run("should fail on bad signature", func(t *testing.T) {
_, _, appErr := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:12345678900000:junk")), key)
require.NotNil(t, appErr)
assert.Equal(t, "interactive_message.decode_trigger_id.signature_decode_failed", appErr.Id)
})
t.Run("should fail on bad key", func(t *testing.T) {
_, triggerId, appErr := GenerateTriggerId(NewId(), key)
require.Nil(t, appErr)
newKey, keyErr := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, keyErr)
_, _, appErr = DecodeAndVerifyTriggerId(triggerId, newKey)
require.NotNil(t, appErr)
assert.Equal(t, "interactive_message.decode_trigger_id.verify_signature_failed", appErr.Id)
})
}
func TestPostActionIntegrationEquals(t *testing.T) {
t.Run("equal uncomparable types", func(t *testing.T) {
pa1 := &PostAction{
Integration: &PostActionIntegration{
Context: map[string]any{
"a": map[string]any{
"a": 0,
},
},
},
}
pa2 := &PostAction{
Integration: &PostActionIntegration{
Context: map[string]any{
"a": map[string]any{
"a": 0,
},
},
},
}
require.True(t, pa1.Equals(pa2))
})
t.Run("equal comparable types", func(t *testing.T) {
pa1 := &PostAction{
Integration: &PostActionIntegration{
Context: map[string]any{
"a": "test",
},
},
}
pa2 := &PostAction{
Integration: &PostActionIntegration{
Context: map[string]any{
"a": "test",
},
},
}
require.True(t, pa1.Equals(pa2))
})
t.Run("non-equal types", func(t *testing.T) {
pa1 := &PostAction{
Integration: &PostActionIntegration{
Context: map[string]any{
"a": map[string]any{
"a": 0,
},
},
},
}
pa2 := &PostAction{
Integration: &PostActionIntegration{
Context: map[string]any{
"a": "test",
},
},
}
require.False(t, pa1.Equals(pa2))
})
t.Run("nil check", func(t *testing.T) {
pa1 := &PostAction{
Integration: &PostActionIntegration{},
}
pa2 := &PostAction{
Integration: nil,
}
require.False(t, pa1.Equals(pa2))
})
}

58
server/model/integrity.go Обычный файл
Просмотреть файл

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"errors"
)
type OrphanedRecord struct {
ParentId *string `json:"parent_id"`
ChildId *string `json:"child_id"`
}
type RelationalIntegrityCheckData struct {
ParentName string `json:"parent_name"`
ChildName string `json:"child_name"`
ParentIdAttr string `json:"parent_id_attr"`
ChildIdAttr string `json:"child_id_attr"`
Records []OrphanedRecord `json:"records"`
}
type IntegrityCheckResult struct {
Data any `json:"data"`
Err error `json:"err"`
}
func (r *IntegrityCheckResult) UnmarshalJSON(b []byte) error {
var data map[string]any
if err := json.Unmarshal(b, &data); err != nil {
return err
}
if d, ok := data["data"]; ok && d != nil {
var rdata RelationalIntegrityCheckData
m := d.(map[string]any)
rdata.ParentName = m["parent_name"].(string)
rdata.ChildName = m["child_name"].(string)
rdata.ParentIdAttr = m["parent_id_attr"].(string)
rdata.ChildIdAttr = m["child_id_attr"].(string)
for _, recData := range m["records"].([]any) {
var record OrphanedRecord
m := recData.(map[string]any)
if val := m["parent_id"]; val != nil {
record.ParentId = NewString(val.(string))
}
if val := m["child_id"]; val != nil {
record.ChildId = NewString(val.(string))
}
rdata.Records = append(rdata.Records, record)
}
r.Data = rdata
}
if err, ok := data["err"]; ok && err != nil {
r.Err = errors.New(data["err"].(string))
}
return nil
}

129
server/model/job.go Обычный файл
Просмотреть файл

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"time"
)
const (
JobTypeDataRetention = "data_retention"
JobTypeMessageExport = "message_export"
JobTypeElasticsearchPostIndexing = "elasticsearch_post_indexing"
JobTypeElasticsearchPostAggregation = "elasticsearch_post_aggregation"
JobTypeBlevePostIndexing = "bleve_post_indexing"
JobTypeLdapSync = "ldap_sync"
JobTypeMigrations = "migrations"
JobTypePlugins = "plugins"
JobTypeExpiryNotify = "expiry_notify"
JobTypeProductNotices = "product_notices"
JobTypeActiveUsers = "active_users"
JobTypeImportProcess = "import_process"
JobTypeImportDelete = "import_delete"
JobTypeExportProcess = "export_process"
JobTypeExportDelete = "export_delete"
JobTypeCloud = "cloud"
JobTypeResendInvitationEmail = "resend_invitation_email"
JobTypeExtractContent = "extract_content"
JobTypeLastAccessiblePost = "last_accessible_post"
JobTypeLastAccessibleFile = "last_accessible_file"
JobTypeUpgradeNotifyAdmin = "upgrade_notify_admin"
JobTypeTrialNotifyAdmin = "trial_notify_admin"
JobTypeInstallPluginNotifyAdmin = "install_plugin_notify_admin"
JobTypeHostedPurchaseScreening = "hosted_purchase_screening"
JobStatusPending = "pending"
JobStatusInProgress = "in_progress"
JobStatusSuccess = "success"
JobStatusError = "error"
JobStatusCancelRequested = "cancel_requested"
JobStatusCanceled = "canceled"
JobStatusWarning = "warning"
)
var AllJobTypes = [...]string{
JobTypeDataRetention,
JobTypeMessageExport,
JobTypeElasticsearchPostIndexing,
JobTypeElasticsearchPostAggregation,
JobTypeBlevePostIndexing,
JobTypeLdapSync,
JobTypeMigrations,
JobTypePlugins,
JobTypeExpiryNotify,
JobTypeProductNotices,
JobTypeActiveUsers,
JobTypeImportProcess,
JobTypeImportDelete,
JobTypeExportProcess,
JobTypeExportDelete,
JobTypeCloud,
JobTypeExtractContent,
JobTypeLastAccessiblePost,
JobTypeLastAccessibleFile,
}
type Job struct {
Id string `json:"id"`
Type string `json:"type"`
Priority int64 `json:"priority"`
CreateAt int64 `json:"create_at"`
StartAt int64 `json:"start_at"`
LastActivityAt int64 `json:"last_activity_at"`
Status string `json:"status"`
Progress int64 `json:"progress"`
Data StringMap `json:"data"`
}
func (j *Job) Auditable() map[string]interface{} {
return map[string]interface{}{
"id": j.Id,
"type": j.Type,
"priority": j.Priority,
"create_at": j.CreateAt,
"start_at": j.StartAt,
"last_activity_at": j.LastActivityAt,
"status": j.Status,
"progress": j.Progress,
"data": j.Data, // TODO do we want this here
}
}
func (j *Job) IsValid() *AppError {
if !IsValidId(j.Id) {
return NewAppError("Job.IsValid", "model.job.is_valid.id.app_error", nil, "id="+j.Id, http.StatusBadRequest)
}
if j.CreateAt == 0 {
return NewAppError("Job.IsValid", "model.job.is_valid.create_at.app_error", nil, "id="+j.Id, http.StatusBadRequest)
}
switch j.Status {
case JobStatusPending,
JobStatusInProgress,
JobStatusSuccess,
JobStatusError,
JobStatusWarning,
JobStatusCancelRequested,
JobStatusCanceled:
default:
return NewAppError("Job.IsValid", "model.job.is_valid.status.app_error", nil, "id="+j.Id, http.StatusBadRequest)
}
return nil
}
type Worker interface {
Run()
Stop()
JobChannel() chan<- Job
IsEnabled(cfg *Config) bool
}
type Scheduler interface {
Enabled(cfg *Config) bool
NextScheduleTime(cfg *Config, now time.Time, pendingJobs bool, lastSuccessfulJob *Job) *time.Time
ScheduleJob(cfg *Config, pendingJobs bool, lastSuccessfulJob *Job) (*Job, *AppError)
}

123
server/model/job_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,123 @@
// 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 TestJobAuditable(t *testing.T) {
job := &Job{
Id: "arandomstring0123456789012",
Type: JobTypeExportProcess,
Priority: 42,
CreateAt: 1336,
StartAt: 1337,
LastActivityAt: 1666609360813,
Status: JobStatusInProgress,
Progress: 32,
Data: StringMap{"Hello": "World"},
}
audit := job.Auditable()
require.Equal(t, job.Id, audit["id"])
require.Equal(t, job.Type, audit["type"])
require.Equal(t, job.Priority, audit["priority"])
require.Equal(t, job.CreateAt, audit["create_at"])
require.Equal(t, job.StartAt, audit["start_at"])
require.Equal(t, job.LastActivityAt, audit["last_activity_at"])
require.Equal(t, job.Status, audit["status"])
require.Equal(t, job.Progress, audit["progress"])
require.Equal(t, job.Data, audit["data"])
}
func TestJobIsValid(t *testing.T) {
t.Run("valid", func(t *testing.T) {
job := &Job{
Id: "arandomstring0123456789012",
Type: JobTypeExportProcess,
Priority: 42,
CreateAt: 1336,
StartAt: 1337,
LastActivityAt: 1666609360813,
Status: JobStatusInProgress,
Progress: 32,
Data: StringMap{"Hello": "World"},
}
require.Nil(t, job.IsValid())
})
t.Run("invalid ID", func(t *testing.T) {
job := &Job{
Id: "invalid!",
Type: JobTypeExportProcess,
Priority: 42,
CreateAt: 1336,
StartAt: 1337,
LastActivityAt: 1666609360813,
Status: JobStatusInProgress,
Progress: 32,
Data: StringMap{"Hello": "World"},
}
require.NotNil(t, job.IsValid())
})
t.Run("invalid creation time", func(t *testing.T) {
job := &Job{
Id: "arandomstring0123456789012",
Type: JobTypeExportProcess,
Priority: 42,
CreateAt: 0,
StartAt: 1337,
LastActivityAt: 1666609360813,
Status: JobStatusInProgress,
Progress: 32,
Data: StringMap{"Hello": "World"},
}
require.NotNil(t, job.IsValid())
})
t.Run("invalid status", func(t *testing.T) {
job := &Job{
Id: "arandomstring0123456789012",
Type: JobTypeExportProcess,
Priority: 42,
CreateAt: 1336,
StartAt: 1337,
LastActivityAt: 1666609360813,
Status: "doing the best it can",
Progress: 32,
Data: StringMap{"Hello": "World"},
}
require.NotNil(t, job.IsValid())
})
t.Run("valid status", func(t *testing.T) {
validStatuses := []string{JobStatusCancelRequested, JobStatusCanceled, JobStatusError, JobStatusInProgress, JobStatusPending, JobStatusSuccess, JobStatusWarning}
for _, status := range validStatuses {
t.Run(status, func(t *testing.T) {
job := &Job{
Id: "arandomstring0123456789012",
Type: JobTypeExportProcess,
Priority: 42,
CreateAt: 1336,
StartAt: 1337,
LastActivityAt: 1666609360813,
Status: status,
Progress: 32,
Data: StringMap{"Hello": "World"},
}
require.Nil(t, job.IsValid())
})
}
})
}

10
server/model/ldap.go Обычный файл
Просмотреть файл

@@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const (
UserAuthServiceLdap = "ldap"
LdapPublicCertificateName = "ldap-public.crt"
LdapPrivateKeyName = "ldap-private.key"
)

453
server/model/license.go Обычный файл
Просмотреть файл

@@ -0,0 +1,453 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
const (
DayInSeconds = 24 * 60 * 60
DayInMilliseconds = DayInSeconds * 1000
ExpiredLicenseError = "api.license.add_license.expired.app_error"
InvalidLicenseError = "api.license.add_license.invalid.app_error"
LicenseGracePeriod = DayInMilliseconds * 10 //10 days
LicenseRenewalLink = "https://mattermost.com/renew/"
LicenseShortSkuE10 = "E10"
LicenseShortSkuE20 = "E20"
LicenseShortSkuProfessional = "professional"
LicenseShortSkuEnterprise = "enterprise"
)
const (
LicenseUpForRenewalEmailSent = "LicenseUpForRenewalEmailSent"
)
var (
trialDuration = 30*(time.Hour*24) + (time.Hour * 8) // 720 hours (30 days) + 8 hours is trial license duration
adminTrialDuration = 30*(time.Hour*24) + (time.Hour * 23) + (time.Minute * 59) + (time.Second * 59) // 720 hours (30 days) + 23 hours, 59 mins and 59 seconds
// a sanctioned trial's duration is either more than the upper bound,
// or less than the lower bound
sanctionedTrialDurationLowerBound = 31*(time.Hour*24) + (time.Hour * 23) + (time.Minute * 59) + (time.Second * 59) // 744 hours (31 days) + 23 hours, 59 mins and 59 seconds
sanctionedTrialDurationUpperBound = 29*(time.Hour*24) + (time.Hour * 23) + (time.Minute * 59) + (time.Second * 59) // 696 hours (29 days) + 23 hours, 59 mins and 59 seconds
)
const (
TrueUpReviewTelemetryName = "true_up_review_sent"
TrueUpReviewAuthFeaturesMfa = "multi_factor_authentication"
TrueUpReviewAuthFeaturesADLdap = "ad_ldap_sign_in"
TrueUpReviewAuthFeaturesSaml = "saml_sign_in"
TrueUpReviewAuthFeatureOpenId = "openid_connect"
TrueUpReviewAuthFeatureGuestAccess = "guest_access"
)
type LicenseRecord struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
Bytes string `json:"-"`
}
type License struct {
Id string `json:"id"`
IssuedAt int64 `json:"issued_at"`
StartsAt int64 `json:"starts_at"`
ExpiresAt int64 `json:"expires_at"`
Customer *Customer `json:"customer"`
Features *Features `json:"features"`
SkuName string `json:"sku_name"`
SkuShortName string `json:"sku_short_name"`
IsTrial bool `json:"is_trial"`
IsGovSku bool `json:"is_gov_sku"`
SignupJWT *string `json:"signup_jwt"`
}
type Customer struct {
Id string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Company string `json:"company"`
}
type TrialLicenseRequest struct {
ServerID string `json:"server_id"`
Email string `json:"email"`
Name string `json:"name"`
SiteURL string `json:"site_url"`
SiteName string `json:"site_name"`
Users int `json:"users"`
TermsAccepted bool `json:"terms_accepted"`
ReceiveEmailsAccepted bool `json:"receive_emails_accepted"`
ContactName string `json:"contact_name"`
ContactEmail string `json:"contact_email"`
CompanyName string `json:"company_name"`
CompanyCountry string `json:"company_country"`
CompanySize string `json:"company_size"`
}
// If any of the below fields are set, this is not a legacy request, and all fields should be validated
func (tlr *TrialLicenseRequest) IsLegacy() bool {
return tlr.CompanyCountry == "" && tlr.CompanyName == "" && tlr.CompanySize == "" && tlr.ContactName == ""
}
func (tlr *TrialLicenseRequest) IsValid() bool {
if !tlr.TermsAccepted {
return false
}
if tlr.Email == "" {
return false
}
if tlr.Users <= 0 {
return false
}
if tlr.CompanyCountry == "" {
return false
}
if tlr.CompanyName == "" {
return false
}
if tlr.CompanySize == "" {
return false
}
if tlr.ContactName == "" {
return false
}
return true
}
type Features struct {
Users *int `json:"users"`
LDAP *bool `json:"ldap"`
LDAPGroups *bool `json:"ldap_groups"`
MFA *bool `json:"mfa"`
GoogleOAuth *bool `json:"google_oauth"`
Office365OAuth *bool `json:"office365_oauth"`
OpenId *bool `json:"openid"`
Compliance *bool `json:"compliance"`
Cluster *bool `json:"cluster"`
Metrics *bool `json:"metrics"`
MHPNS *bool `json:"mhpns"`
SAML *bool `json:"saml"`
Elasticsearch *bool `json:"elastic_search"`
Announcement *bool `json:"announcement"`
ThemeManagement *bool `json:"theme_management"`
EmailNotificationContents *bool `json:"email_notification_contents"`
DataRetention *bool `json:"data_retention"`
MessageExport *bool `json:"message_export"`
CustomPermissionsSchemes *bool `json:"custom_permissions_schemes"`
CustomTermsOfService *bool `json:"custom_terms_of_service"`
GuestAccounts *bool `json:"guest_accounts"`
GuestAccountsPermissions *bool `json:"guest_accounts_permissions"`
IDLoadedPushNotifications *bool `json:"id_loaded"`
LockTeammateNameDisplay *bool `json:"lock_teammate_name_display"`
EnterprisePlugins *bool `json:"enterprise_plugins"`
AdvancedLogging *bool `json:"advanced_logging"`
Cloud *bool `json:"cloud"`
SharedChannels *bool `json:"shared_channels"`
RemoteClusterService *bool `json:"remote_cluster_service"`
// after we enabled more features we'll need to control them with this
FutureFeatures *bool `json:"future_features"`
}
func (f *Features) ToMap() map[string]any {
return map[string]any{
"ldap": *f.LDAP,
"ldap_groups": *f.LDAPGroups,
"mfa": *f.MFA,
"google": *f.GoogleOAuth,
"office365": *f.Office365OAuth,
"openid": *f.OpenId,
"compliance": *f.Compliance,
"cluster": *f.Cluster,
"metrics": *f.Metrics,
"mhpns": *f.MHPNS,
"saml": *f.SAML,
"elastic_search": *f.Elasticsearch,
"email_notification_contents": *f.EmailNotificationContents,
"data_retention": *f.DataRetention,
"message_export": *f.MessageExport,
"custom_permissions_schemes": *f.CustomPermissionsSchemes,
"guest_accounts": *f.GuestAccounts,
"guest_accounts_permissions": *f.GuestAccountsPermissions,
"id_loaded": *f.IDLoadedPushNotifications,
"lock_teammate_name_display": *f.LockTeammateNameDisplay,
"enterprise_plugins": *f.EnterprisePlugins,
"advanced_logging": *f.AdvancedLogging,
"cloud": *f.Cloud,
"shared_channels": *f.SharedChannels,
"remote_cluster_service": *f.RemoteClusterService,
"future": *f.FutureFeatures,
}
}
func (f *Features) SetDefaults() {
if f.FutureFeatures == nil {
f.FutureFeatures = NewBool(true)
}
if f.Users == nil {
f.Users = NewInt(0)
}
if f.LDAP == nil {
f.LDAP = NewBool(*f.FutureFeatures)
}
if f.LDAPGroups == nil {
f.LDAPGroups = NewBool(*f.FutureFeatures)
}
if f.MFA == nil {
f.MFA = NewBool(*f.FutureFeatures)
}
if f.GoogleOAuth == nil {
f.GoogleOAuth = NewBool(*f.FutureFeatures)
}
if f.Office365OAuth == nil {
f.Office365OAuth = NewBool(*f.FutureFeatures)
}
if f.OpenId == nil {
f.OpenId = NewBool(*f.FutureFeatures)
}
if f.Compliance == nil {
f.Compliance = NewBool(*f.FutureFeatures)
}
if f.Cluster == nil {
f.Cluster = NewBool(*f.FutureFeatures)
}
if f.Metrics == nil {
f.Metrics = NewBool(*f.FutureFeatures)
}
if f.MHPNS == nil {
f.MHPNS = NewBool(*f.FutureFeatures)
}
if f.SAML == nil {
f.SAML = NewBool(*f.FutureFeatures)
}
if f.Elasticsearch == nil {
f.Elasticsearch = NewBool(*f.FutureFeatures)
}
if f.Announcement == nil {
f.Announcement = NewBool(true)
}
if f.ThemeManagement == nil {
f.ThemeManagement = NewBool(true)
}
if f.EmailNotificationContents == nil {
f.EmailNotificationContents = NewBool(*f.FutureFeatures)
}
if f.DataRetention == nil {
f.DataRetention = NewBool(*f.FutureFeatures)
}
if f.MessageExport == nil {
f.MessageExport = NewBool(*f.FutureFeatures)
}
if f.CustomPermissionsSchemes == nil {
f.CustomPermissionsSchemes = NewBool(*f.FutureFeatures)
}
if f.GuestAccounts == nil {
f.GuestAccounts = NewBool(*f.FutureFeatures)
}
if f.GuestAccountsPermissions == nil {
f.GuestAccountsPermissions = NewBool(*f.FutureFeatures)
}
if f.CustomTermsOfService == nil {
f.CustomTermsOfService = NewBool(*f.FutureFeatures)
}
if f.IDLoadedPushNotifications == nil {
f.IDLoadedPushNotifications = NewBool(*f.FutureFeatures)
}
if f.LockTeammateNameDisplay == nil {
f.LockTeammateNameDisplay = NewBool(*f.FutureFeatures)
}
if f.EnterprisePlugins == nil {
f.EnterprisePlugins = NewBool(*f.FutureFeatures)
}
if f.AdvancedLogging == nil {
f.AdvancedLogging = NewBool(*f.FutureFeatures)
}
if f.Cloud == nil {
f.Cloud = NewBool(false)
}
if f.SharedChannels == nil {
f.SharedChannels = NewBool(*f.FutureFeatures)
}
if f.RemoteClusterService == nil {
f.RemoteClusterService = NewBool(*f.FutureFeatures)
}
}
func (l *License) IsExpired() bool {
return l.ExpiresAt < GetMillis()
}
func (l *License) IsPastGracePeriod() bool {
timeDiff := GetMillis() - l.ExpiresAt
return timeDiff > LicenseGracePeriod
}
func (l *License) IsWithinExpirationPeriod() bool {
days := l.DaysToExpiration()
return days <= 60 && days >= 58
}
func (l *License) DaysToExpiration() int {
dif := l.ExpiresAt - GetMillis()
d, _ := time.ParseDuration(fmt.Sprint(dif) + "ms")
days := d.Hours() / 24
return int(days)
}
func (l *License) IsStarted() bool {
return l.StartsAt < GetMillis()
}
func (l *License) IsCloud() bool {
return l != nil && l.Features != nil && l.Features.Cloud != nil && *l.Features.Cloud
}
func (l *License) IsTrialLicense() bool {
return l.IsTrial || (l.ExpiresAt-l.StartsAt) == trialDuration.Milliseconds() || (l.ExpiresAt-l.StartsAt) == adminTrialDuration.Milliseconds()
}
func (l *License) IsSanctionedTrial() bool {
duration := l.ExpiresAt - l.StartsAt
return l.IsTrialLicense() &&
(duration >= sanctionedTrialDurationLowerBound.Milliseconds() || duration <= sanctionedTrialDurationUpperBound.Milliseconds())
}
func (l *License) HasEnterpriseMarketplacePlugins() bool {
return *l.Features.EnterprisePlugins ||
l.SkuShortName == LicenseShortSkuE20 ||
l.SkuShortName == LicenseShortSkuProfessional ||
l.SkuShortName == LicenseShortSkuEnterprise
}
func (l *License) HasRemoteClusterService() bool {
if l == nil {
return false
}
// If SharedChannels is enabled then RemoteClusterService must be enabled.
if l.HasSharedChannels() {
return true
}
return (l.Features != nil && l.Features.RemoteClusterService != nil && *l.Features.RemoteClusterService) ||
l.SkuShortName == LicenseShortSkuProfessional ||
l.SkuShortName == LicenseShortSkuEnterprise
}
func (l *License) HasSharedChannels() bool {
if l == nil {
return false
}
return (l.Features != nil && l.Features.SharedChannels != nil && *l.Features.SharedChannels) ||
l.SkuShortName == LicenseShortSkuProfessional ||
l.SkuShortName == LicenseShortSkuEnterprise
}
// NewTestLicense returns a license that expires in the future and has the given features.
func NewTestLicense(features ...string) *License {
ret := &License{
ExpiresAt: GetMillis() + 90*DayInMilliseconds,
Customer: &Customer{},
Features: &Features{},
}
ret.Features.SetDefaults()
featureMap := map[string]bool{}
for _, feature := range features {
featureMap[feature] = true
}
featureJson, _ := json.Marshal(featureMap)
json.Unmarshal(featureJson, &ret.Features)
return ret
}
// NewTestLicense returns a license that expires in the future and set as false the given features.
func NewTestLicenseWithFalseDefaults(features ...string) *License {
ret := &License{
ExpiresAt: GetMillis() + 90*DayInMilliseconds,
Customer: &Customer{},
Features: &Features{},
}
ret.Features.SetDefaults()
featureMap := map[string]bool{}
for _, feature := range features {
featureMap[feature] = false
}
featureJson, _ := json.Marshal(featureMap)
json.Unmarshal(featureJson, &ret.Features)
return ret
}
func NewTestLicenseSKU(skuShortName string, features ...string) *License {
lic := NewTestLicense(features...)
lic.SkuShortName = skuShortName
return lic
}
func (lr *LicenseRecord) IsValid() *AppError {
if !IsValidId(lr.Id) {
return NewAppError("LicenseRecord.IsValid", "model.license_record.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if lr.CreateAt == 0 {
return NewAppError("LicenseRecord.IsValid", "model.license_record.is_valid.create_at.app_error", nil, "", http.StatusBadRequest)
}
if lr.Bytes == "" || len(lr.Bytes) > 10000 {
return NewAppError("LicenseRecord.IsValid", "model.license_record.is_valid.bytes.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
func (lr *LicenseRecord) PreSave() {
lr.CreateAt = GetMillis()
}

7
server/model/license_key.go Обычный файл
Просмотреть файл

@@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !testlicensekey
package model
const isProdLicensePublicKey = true

7
server/model/license_key_test_env.go Обычный файл
Просмотреть файл

@@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build testlicensekey
package model
const isProdLicensePublicKey = false

489
server/model/license_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,489 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestLicenseFeaturesToMap(t *testing.T) {
f := Features{}
f.SetDefaults()
m := f.ToMap()
CheckTrue(t, m["ldap"].(bool))
CheckTrue(t, m["ldap_groups"].(bool))
CheckTrue(t, m["mfa"].(bool))
CheckTrue(t, m["google"].(bool))
CheckTrue(t, m["office365"].(bool))
CheckTrue(t, m["compliance"].(bool))
CheckTrue(t, m["cluster"].(bool))
CheckTrue(t, m["metrics"].(bool))
CheckTrue(t, m["mhpns"].(bool))
CheckTrue(t, m["saml"].(bool))
CheckTrue(t, m["elastic_search"].(bool))
CheckTrue(t, m["email_notification_contents"].(bool))
CheckTrue(t, m["data_retention"].(bool))
CheckTrue(t, m["message_export"].(bool))
CheckTrue(t, m["custom_permissions_schemes"].(bool))
CheckTrue(t, m["id_loaded"].(bool))
CheckTrue(t, m["future"].(bool))
CheckTrue(t, m["shared_channels"].(bool))
CheckTrue(t, m["remote_cluster_service"].(bool))
}
func TestLicenseFeaturesSetDefaults(t *testing.T) {
f := Features{}
f.SetDefaults()
CheckInt(t, *f.Users, 0)
CheckTrue(t, *f.LDAP)
CheckTrue(t, *f.LDAPGroups)
CheckTrue(t, *f.MFA)
CheckTrue(t, *f.GoogleOAuth)
CheckTrue(t, *f.Office365OAuth)
CheckTrue(t, *f.Compliance)
CheckTrue(t, *f.Cluster)
CheckTrue(t, *f.Metrics)
CheckTrue(t, *f.MHPNS)
CheckTrue(t, *f.SAML)
CheckTrue(t, *f.Elasticsearch)
CheckTrue(t, *f.EmailNotificationContents)
CheckTrue(t, *f.DataRetention)
CheckTrue(t, *f.MessageExport)
CheckTrue(t, *f.CustomPermissionsSchemes)
CheckTrue(t, *f.GuestAccountsPermissions)
CheckTrue(t, *f.IDLoadedPushNotifications)
CheckTrue(t, *f.SharedChannels)
CheckTrue(t, *f.RemoteClusterService)
CheckTrue(t, *f.FutureFeatures)
f = Features{}
f.SetDefaults()
*f.Users = 300
*f.FutureFeatures = false
*f.LDAP = true
*f.LDAPGroups = true
*f.MFA = true
*f.GoogleOAuth = true
*f.Office365OAuth = true
*f.Compliance = true
*f.Cluster = true
*f.Metrics = true
*f.MHPNS = true
*f.SAML = true
*f.Elasticsearch = true
*f.DataRetention = true
*f.MessageExport = true
*f.CustomPermissionsSchemes = true
*f.GuestAccounts = true
*f.GuestAccountsPermissions = true
*f.EmailNotificationContents = true
*f.IDLoadedPushNotifications = true
*f.SharedChannels = true
f.SetDefaults()
CheckInt(t, *f.Users, 300)
CheckTrue(t, *f.LDAP)
CheckTrue(t, *f.LDAPGroups)
CheckTrue(t, *f.MFA)
CheckTrue(t, *f.GoogleOAuth)
CheckTrue(t, *f.Office365OAuth)
CheckTrue(t, *f.Compliance)
CheckTrue(t, *f.Cluster)
CheckTrue(t, *f.Metrics)
CheckTrue(t, *f.MHPNS)
CheckTrue(t, *f.SAML)
CheckTrue(t, *f.Elasticsearch)
CheckTrue(t, *f.EmailNotificationContents)
CheckTrue(t, *f.DataRetention)
CheckTrue(t, *f.MessageExport)
CheckTrue(t, *f.CustomPermissionsSchemes)
CheckTrue(t, *f.GuestAccounts)
CheckTrue(t, *f.GuestAccountsPermissions)
CheckTrue(t, *f.IDLoadedPushNotifications)
CheckTrue(t, *f.SharedChannels)
CheckTrue(t, *f.RemoteClusterService)
CheckFalse(t, *f.FutureFeatures)
}
func TestLicenseIsExpired(t *testing.T) {
l1 := License{}
l1.ExpiresAt = GetMillis() - 1000
assert.True(t, l1.IsExpired())
l1.ExpiresAt = GetMillis() + 10000
assert.False(t, l1.IsExpired())
}
func TestLicenseIsPastGracePeriod(t *testing.T) {
l1 := License{}
l1.ExpiresAt = GetMillis() - LicenseGracePeriod - 1000
assert.True(t, l1.IsPastGracePeriod())
l1.ExpiresAt = GetMillis() + 1000
assert.False(t, l1.IsPastGracePeriod())
}
func TestLicenseIsStarted(t *testing.T) {
l1 := License{}
l1.StartsAt = GetMillis() - 1000
assert.True(t, l1.IsStarted())
l1.StartsAt = GetMillis() + 10000
assert.False(t, l1.IsStarted())
}
func TestIsCloud(t *testing.T) {
l1 := License{}
l1.Features = &Features{}
l1.Features.SetDefaults()
assert.False(t, l1.IsCloud())
boolTrue := true
l1.Features.Cloud = &boolTrue
assert.True(t, l1.IsCloud())
var license *License
assert.False(t, license.IsCloud())
l1.Features = nil
assert.False(t, l1.IsCloud())
t.Run("false if license is nil", func(t *testing.T) {
var license *License
assert.False(t, license.IsCloud())
})
}
func TestLicenseRecordIsValid(t *testing.T) {
lr := LicenseRecord{
CreateAt: GetMillis(),
Bytes: "asdfghjkl;",
}
appErr := lr.IsValid()
assert.NotNil(t, appErr)
lr.Id = NewId()
lr.CreateAt = 0
appErr = lr.IsValid()
assert.NotNil(t, appErr)
lr.CreateAt = GetMillis()
lr.Bytes = ""
appErr = lr.IsValid()
assert.NotNil(t, appErr)
lr.Bytes = strings.Repeat("0123456789", 1001)
appErr = lr.IsValid()
assert.NotNil(t, appErr)
lr.Bytes = "ASDFGHJKL;"
appErr = lr.IsValid()
assert.Nil(t, appErr)
}
func TestLicenseRecordPreSave(t *testing.T) {
lr := LicenseRecord{}
lr.PreSave()
assert.NotZero(t, lr.CreateAt)
}
func TestIsLegacyTrialRequest(t *testing.T) {
legacyTr := &TrialLicenseRequest{
Email: "test@mattermost.com",
TermsAccepted: true,
SiteURL: "https://mattermost.com",
SiteName: "Mattermost",
Users: 100,
}
t.Run("legacy trial request", func(t *testing.T) {
assert.True(t, legacyTr.IsLegacy())
})
t.Run("legacy trial request with any non-legacy field set is not a legacy request", func(t *testing.T) {
legacyTr.CompanyCountry = "US"
assert.False(t, legacyTr.IsLegacy())
legacyTr.CompanyCountry = ""
legacyTr.CompanyName = "test company"
assert.False(t, legacyTr.IsLegacy())
legacyTr.CompanyName = ""
legacyTr.CompanySize = "50-100"
assert.False(t, legacyTr.IsLegacy())
legacyTr.CompanySize = ""
legacyTr.ContactName = "test user"
assert.False(t, legacyTr.IsLegacy())
legacyTr.ContactName = ""
assert.True(t, legacyTr.IsLegacy())
})
}
func TestTrialLicenseRequestIsValid(t *testing.T) {
validTlr := &TrialLicenseRequest{
Email: "test@test.com",
Users: 100,
CompanyCountry: "US",
CompanyName: "Test Company",
CompanySize: "50-100",
ContactName: "Test User",
TermsAccepted: true,
}
resetBaseRequest := func() {
validTlr = &TrialLicenseRequest{
Email: "test@test.com",
Users: 100,
CompanyCountry: "US",
CompanyName: "Test Company",
CompanySize: "50-100",
ContactName: "Test User",
TermsAccepted: true,
}
}
t.Run("valid request", func(t *testing.T) {
resetBaseRequest()
assert.Equal(t, true, validTlr.IsValid())
})
t.Run("no terms", func(t *testing.T) {
resetBaseRequest()
validTlr.TermsAccepted = false
assert.Equal(t, false, validTlr.IsValid())
})
t.Run("no email", func(t *testing.T) {
resetBaseRequest()
validTlr.Email = ""
assert.Equal(t, false, validTlr.IsValid())
})
t.Run("no CompanyCountry", func(t *testing.T) {
resetBaseRequest()
validTlr.CompanyCountry = ""
assert.Equal(t, false, validTlr.IsValid())
})
t.Run("no CompanyName", func(t *testing.T) {
resetBaseRequest()
validTlr.CompanyName = ""
assert.Equal(t, false, validTlr.IsValid())
})
t.Run("no CompanySize", func(t *testing.T) {
resetBaseRequest()
validTlr.CompanySize = ""
assert.Equal(t, false, validTlr.IsValid())
})
t.Run("Bad User Count", func(t *testing.T) {
resetBaseRequest()
validTlr.Users = 0
assert.Equal(t, false, validTlr.IsValid())
})
}
func TestLicense_IsTrialLicense(t *testing.T) {
t.Run("detect trial license directly from the flag", func(t *testing.T) {
license := &License{
IsTrial: true,
}
assert.True(t, license.IsTrial)
license.IsTrial = false
assert.False(t, license.IsTrialLicense())
})
t.Run("detect trial license form duration", func(t *testing.T) {
startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC")
assert.NoError(t, err)
endDate, err := time.Parse(time.RFC822, "31 Jan 21 08:00 UTC")
assert.NoError(t, err)
license := &License{
StartsAt: startDate.UnixNano() / int64(time.Millisecond),
ExpiresAt: endDate.UnixNano() / int64(time.Millisecond),
}
assert.True(t, license.IsTrialLicense())
endDate, err = time.Parse(time.RFC822, "01 Feb 21 08:00 UTC")
assert.NoError(t, err)
license.ExpiresAt = endDate.UnixNano() / int64(time.Millisecond)
assert.False(t, license.IsTrialLicense())
// 30 days + 23 hours 59 mins 59 seconds
endDate, err = time.Parse("02 Jan 06 15:04:05 MST", "31 Jan 21 23:59:59 UTC")
assert.NoError(t, err)
license.ExpiresAt = endDate.UnixNano() / int64(time.Millisecond)
assert.True(t, license.IsTrialLicense())
})
t.Run("detect trial with both flag and duration", func(t *testing.T) {
startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC")
assert.NoError(t, err)
endDate, err := time.Parse(time.RFC822, "31 Jan 21 08:00 UTC")
assert.NoError(t, err)
license := &License{
IsTrial: true,
StartsAt: startDate.UnixNano() / int64(time.Millisecond),
ExpiresAt: endDate.UnixNano() / int64(time.Millisecond),
}
assert.True(t, license.IsTrialLicense())
license.IsTrial = false
// detecting trial from duration
assert.True(t, license.IsTrialLicense())
endDate, _ = time.Parse(time.RFC822, "1 Feb 2021 08:00 UTC")
license.ExpiresAt = endDate.UnixNano() / int64(time.Millisecond)
assert.False(t, license.IsTrialLicense())
license.IsTrial = true
assert.True(t, license.IsTrialLicense())
})
}
func TestLicense_IsSanctionedTrial(t *testing.T) {
t.Run("short duration sanctioned trial", func(t *testing.T) {
startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC")
assert.NoError(t, err)
endDate, err := time.Parse(time.RFC822, "08 Jan 21 08:00 UTC")
assert.NoError(t, err)
license := &License{
IsTrial: true,
StartsAt: startDate.UnixNano() / int64(time.Millisecond),
ExpiresAt: endDate.UnixNano() / int64(time.Millisecond),
}
assert.True(t, license.IsSanctionedTrial())
license.IsTrial = false
assert.False(t, license.IsSanctionedTrial())
})
t.Run("long duration sanctioned trial", func(t *testing.T) {
startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC")
assert.NoError(t, err)
endDate, err := time.Parse(time.RFC822, "02 Feb 21 08:00 UTC")
assert.NoError(t, err)
license := &License{
IsTrial: true,
StartsAt: startDate.UnixNano() / int64(time.Millisecond),
ExpiresAt: endDate.UnixNano() / int64(time.Millisecond),
}
assert.True(t, license.IsSanctionedTrial())
license.IsTrial = false
assert.False(t, license.IsSanctionedTrial())
})
t.Run("invalid duration for sanctioned trial", func(t *testing.T) {
startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC")
assert.NoError(t, err)
endDate, err := time.Parse(time.RFC822, "31 Jan 21 08:00 UTC")
assert.NoError(t, err)
license := &License{
IsTrial: true,
StartsAt: startDate.UnixNano() / int64(time.Millisecond),
ExpiresAt: endDate.UnixNano() / int64(time.Millisecond),
}
assert.False(t, license.IsSanctionedTrial())
})
t.Run("boundary conditions for sanctioned trial", func(t *testing.T) {
startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC")
assert.NoError(t, err)
// 29 days + 23 hours 59 mins 59 seconds
endDate, err := time.Parse("02 Jan 06 15:04:05 MST", "30 Jan 21 23:59:59 UTC")
assert.NoError(t, err)
license := &License{
IsTrial: true,
StartsAt: startDate.UnixNano() / int64(time.Millisecond),
ExpiresAt: endDate.UnixNano() / int64(time.Millisecond),
}
assert.True(t, license.IsSanctionedTrial())
// 31 days + 23 hours 59 mins 59 seconds
endDate, err = time.Parse("02 Jan 06 15:04:05 MST", "01 Feb 21 23:59:59 UTC")
assert.NoError(t, err)
license.ExpiresAt = endDate.UnixNano() / int64(time.Millisecond)
assert.True(t, license.IsSanctionedTrial())
})
}
func TestLicenseHasSharedChannels(t *testing.T) {
testCases := []struct {
description string
license License
expectedValue bool
}{
{
"licensed for shared channels",
License{
Features: &Features{
SharedChannels: NewBool(true),
},
SkuShortName: "other",
},
true,
},
{
"not licensed for shared channels",
License{
Features: &Features{},
SkuShortName: "other",
},
false,
},
{
"professional license for shared channels",
License{
Features: &Features{},
SkuShortName: LicenseShortSkuProfessional,
},
true,
},
{
"enterprise license for shared channels",
License{
Features: &Features{},
SkuShortName: LicenseShortSkuEnterprise,
},
true,
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(t, testCase.expectedValue, testCase.license.HasSharedChannels())
})
}
}

194
server/model/link_metadata.go Обычный файл
Просмотреть файл

@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/binary"
"encoding/json"
"fmt"
"hash/fnv"
"net/http"
"time"
"unicode/utf8"
"github.com/dyatlov/go-opengraph/opengraph"
"github.com/dyatlov/go-opengraph/opengraph/types/image"
)
const (
LinkMetadataTypeImage LinkMetadataType = "image"
LinkMetadataTypeNone LinkMetadataType = "none"
LinkMetadataTypeOpengraph LinkMetadataType = "opengraph"
LinkMetadataMaxImages int = 5
)
type LinkMetadataType string
// LinkMetadata stores arbitrary data about a link posted in a message. This includes dimensions of linked images
// and OpenGraph metadata.
type LinkMetadata struct {
// Hash is a value computed from the URL and Timestamp for use as a primary key in the database.
Hash int64
URL string
Timestamp int64
Type LinkMetadataType
// Data is the actual metadata for the link. It should contain data of one of the following types:
// - *model.PostImage if the linked content is an image
// - *opengraph.OpenGraph if the linked content is an HTML document
// - nil if the linked content has no metadata
Data any
}
// truncateText ensure string is 300 chars, truncate and add ellipsis
// if it was bigger.
func truncateText(original string) string {
if utf8.RuneCountInString(original) > 300 {
return fmt.Sprintf("%.300s[...]", original)
}
return original
}
func firstNImages(images []*image.Image, maxImages int) []*image.Image {
if maxImages < 0 { // don't break stuff, if it's weird, go for sane defaults
maxImages = LinkMetadataMaxImages
}
numImages := len(images)
if numImages > maxImages {
return images[0:maxImages]
}
return images
}
// TruncateOpenGraph ensure OpenGraph metadata doesn't grow too big by
// shortening strings, trimming fields and reducing the number of
// images.
func TruncateOpenGraph(ogdata *opengraph.OpenGraph) *opengraph.OpenGraph {
if ogdata != nil {
empty := &opengraph.OpenGraph{}
ogdata.Title = truncateText(ogdata.Title)
ogdata.Description = truncateText(ogdata.Description)
ogdata.SiteName = truncateText(ogdata.SiteName)
ogdata.Article = empty.Article
ogdata.Book = empty.Book
ogdata.Profile = empty.Profile
ogdata.Determiner = empty.Determiner
ogdata.Locale = empty.Locale
ogdata.LocalesAlternate = empty.LocalesAlternate
ogdata.Images = firstNImages(ogdata.Images, LinkMetadataMaxImages)
ogdata.Audios = empty.Audios
ogdata.Videos = empty.Videos
}
return ogdata
}
func (o *LinkMetadata) PreSave() {
o.Hash = GenerateLinkMetadataHash(o.URL, o.Timestamp)
}
func (o *LinkMetadata) IsValid() *AppError {
if o.URL == "" {
return NewAppError("LinkMetadata.IsValid", "model.link_metadata.is_valid.url.app_error", nil, "", http.StatusBadRequest)
}
if o.Timestamp == 0 || !isRoundedToNearestHour(o.Timestamp) {
return NewAppError("LinkMetadata.IsValid", "model.link_metadata.is_valid.timestamp.app_error", nil, "", http.StatusBadRequest)
}
switch o.Type {
case LinkMetadataTypeImage:
if o.Data == nil {
return NewAppError("LinkMetadata.IsValid", "model.link_metadata.is_valid.data.app_error", nil, "", http.StatusBadRequest)
}
if _, ok := o.Data.(*PostImage); !ok {
return NewAppError("LinkMetadata.IsValid", "model.link_metadata.is_valid.data_type.app_error", nil, "", http.StatusBadRequest)
}
case LinkMetadataTypeNone:
if o.Data != nil {
return NewAppError("LinkMetadata.IsValid", "model.link_metadata.is_valid.data_type.app_error", nil, "", http.StatusBadRequest)
}
case LinkMetadataTypeOpengraph:
if o.Data == nil {
return NewAppError("LinkMetadata.IsValid", "model.link_metadata.is_valid.data.app_error", nil, "", http.StatusBadRequest)
}
if _, ok := o.Data.(*opengraph.OpenGraph); !ok {
return NewAppError("LinkMetadata.IsValid", "model.link_metadata.is_valid.data_type.app_error", nil, "", http.StatusBadRequest)
}
default:
return NewAppError("LinkMetadata.IsValid", "model.link_metadata.is_valid.type.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
// DeserializeDataToConcreteType converts o.Data from JSON into properly structured data. This is intended to be used
// after getting a LinkMetadata object that has been stored in the database.
func (o *LinkMetadata) DeserializeDataToConcreteType() error {
var b []byte
switch t := o.Data.(type) {
case []byte:
// MySQL uses a byte slice for JSON
b = t
case string:
// Postgres uses a string for JSON
b = []byte(t)
}
if b == nil {
// Data doesn't need to be fixed
return nil
}
var data any
var err error
switch o.Type {
case LinkMetadataTypeImage:
image := &PostImage{}
err = json.Unmarshal(b, &image)
data = image
case LinkMetadataTypeOpengraph:
og := &opengraph.OpenGraph{}
json.Unmarshal(b, &og)
data = og
}
if err != nil {
return err
}
o.Data = data
return nil
}
// FloorToNearestHour takes a timestamp (in milliseconds) and returns it rounded to the previous hour in UTC.
func FloorToNearestHour(ms int64) int64 {
t := time.Unix(0, ms*int64(1000*1000)).UTC()
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, time.UTC).UnixNano() / int64(time.Millisecond)
}
// isRoundedToNearestHour returns true if the given timestamp (in milliseconds) has been rounded to the nearest hour in UTC.
func isRoundedToNearestHour(ms int64) bool {
return FloorToNearestHour(ms) == ms
}
// GenerateLinkMetadataHash generates a unique hash for a given URL and timestamp for use as a database key.
func GenerateLinkMetadataHash(url string, timestamp int64) int64 {
hash := fnv.New32()
// Note that we ignore write errors here because the Hash interface says that its Write will never return an error
binary.Write(hash, binary.LittleEndian, timestamp)
hash.Write([]byte(url))
return int64(hash.Sum32())
}

334
server/model/link_metadata_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,334 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"fmt"
"strings"
"testing"
"unicode/utf8"
"github.com/dyatlov/go-opengraph/opengraph"
"github.com/dyatlov/go-opengraph/opengraph/types/article"
"github.com/dyatlov/go-opengraph/opengraph/types/audio"
"github.com/dyatlov/go-opengraph/opengraph/types/book"
"github.com/dyatlov/go-opengraph/opengraph/types/image"
"github.com/dyatlov/go-opengraph/opengraph/types/profile"
"github.com/dyatlov/go-opengraph/opengraph/types/video"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const BigText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus maximus faucibus ex, vitae placerat neque feugiat ac. Nam tempus libero quis pellentesque feugiat. Cras tristique diam vel condimentum viverra. Proin molestie posuere leo. Nam pulvinar, ex quis tristique cursus, turpis ante commodo elit, a dapibus est ipsum id eros. Mauris tortor dolor, posuere ac velit vitae, faucibus viverra fusce."
func sampleImage(imageName string) *image.Image {
return &image.Image{
URL: fmt.Sprintf("http://example.com/%s", imageName),
SecureURL: fmt.Sprintf("https://example.com/%s", imageName),
Type: "png",
Width: 32,
Height: 32,
}
}
func TestLinkMetadataIsValid(t *testing.T) {
for _, test := range []struct {
Name string
Metadata *LinkMetadata
Expected bool
}{
{
Name: "should be valid image metadata",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800000,
Type: LinkMetadataTypeImage,
Data: &PostImage{},
},
Expected: true,
},
{
Name: "should be valid opengraph metadata",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800000,
Type: LinkMetadataTypeOpengraph,
Data: &opengraph.OpenGraph{},
},
Expected: true,
},
{
Name: "should be valid with no metadata",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800000,
Type: LinkMetadataTypeNone,
Data: nil,
},
Expected: true,
},
{
Name: "should be invalid because of empty URL",
Metadata: &LinkMetadata{
Timestamp: 1546300800000,
Type: LinkMetadataTypeImage,
Data: &PostImage{},
},
Expected: false,
},
{
Name: "should be invalid because of empty timestamp",
Metadata: &LinkMetadata{
URL: "http://example.com",
Type: LinkMetadataTypeImage,
Data: &PostImage{},
},
Expected: false,
},
{
Name: "should be invalid because of unrounded timestamp",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800001,
Type: LinkMetadataTypeImage,
Data: &PostImage{},
},
Expected: false,
},
{
Name: "should be invalid because of invalid type",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800000,
Type: "garbage",
Data: &PostImage{},
},
Expected: false,
},
{
Name: "should be invalid because of empty data",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800000,
Type: LinkMetadataTypeImage,
},
Expected: false,
},
{
Name: "should be invalid because of mismatched data and type, image type and opengraph data",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800000,
Type: LinkMetadataTypeImage,
Data: &opengraph.OpenGraph{},
},
Expected: false,
},
{
Name: "should be invalid because of mismatched data and type, opengraph type and image data",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800000,
Type: LinkMetadataTypeOpengraph,
Data: &PostImage{},
},
Expected: false,
},
{
Name: "should be invalid because of mismatched data and type, image type and random data",
Metadata: &LinkMetadata{
URL: "http://example.com",
Timestamp: 1546300800000,
Type: LinkMetadataTypeOpengraph,
Data: &Channel{},
},
Expected: false,
},
} {
t.Run(test.Name, func(t *testing.T) {
appErr := test.Metadata.IsValid()
if test.Expected {
assert.Nil(t, appErr)
} else {
assert.NotNil(t, appErr)
}
})
}
}
func TestLinkMetadataDeserializeDataToConcreteType(t *testing.T) {
t.Run("should convert []byte to PostImage", func(t *testing.T) {
image := &PostImage{
Height: 400,
Width: 500,
}
js, err := json.Marshal(image)
assert.NoError(t, err)
metadata := &LinkMetadata{
Type: LinkMetadataTypeImage,
Data: js,
}
require.IsType(t, []byte{}, metadata.Data)
err = metadata.DeserializeDataToConcreteType()
assert.NoError(t, err)
assert.IsType(t, &PostImage{}, metadata.Data)
assert.Equal(t, *image, *metadata.Data.(*PostImage))
})
t.Run("should convert string to OpenGraph", func(t *testing.T) {
og := &opengraph.OpenGraph{
URL: "http://example.com",
Description: "Hello, world!",
Images: []*image.Image{
{
URL: "http://example.com/image.png",
},
},
}
b, err := json.Marshal(og)
require.NoError(t, err)
metadata := &LinkMetadata{
Type: LinkMetadataTypeOpengraph,
Data: b,
}
require.IsType(t, []byte{}, metadata.Data)
err = metadata.DeserializeDataToConcreteType()
assert.NoError(t, err)
assert.IsType(t, &opengraph.OpenGraph{}, metadata.Data)
assert.Equal(t, *og, *metadata.Data.(*opengraph.OpenGraph))
})
t.Run("should ignore data of the correct type", func(t *testing.T) {
metadata := &LinkMetadata{
Type: LinkMetadataTypeOpengraph,
Data: 1234,
}
err := metadata.DeserializeDataToConcreteType()
assert.NoError(t, err)
})
t.Run("should ignore an invalid type", func(t *testing.T) {
metadata := &LinkMetadata{
Type: "garbage",
Data: "garbage",
}
err := metadata.DeserializeDataToConcreteType()
assert.NoError(t, err)
})
t.Run("should return error for invalid data", func(t *testing.T) {
metadata := &LinkMetadata{
Type: LinkMetadataTypeImage,
Data: "garbage",
}
err := metadata.DeserializeDataToConcreteType()
assert.Error(t, err)
})
}
func TestFloorToNearestHour(t *testing.T) {
assert.True(t, isRoundedToNearestHour(FloorToNearestHour(1546346096000)))
}
func TestTruncateText(t *testing.T) {
t.Run("Shouldn't affect strings smaller than 300 characters", func(t *testing.T) {
assert.Equal(t, utf8.RuneCountInString(truncateText("abc")), 3, "should be 3")
})
t.Run("Shouldn't affect empty strings", func(t *testing.T) {
assert.Equal(t, utf8.RuneCountInString(truncateText("")), 0, "should be empty")
})
t.Run("Truncates string to 300 + 5", func(t *testing.T) {
assert.Equal(t, utf8.RuneCountInString(truncateText(BigText)), 305, "should be 300 chars + 5")
})
t.Run("Truncated text ends in ellipsis", func(t *testing.T) {
assert.True(t, strings.HasSuffix(truncateText(BigText), "[...]"))
})
}
func TestFirstNImages(t *testing.T) {
t.Run("when empty, return an empty one", func(t *testing.T) {
empty := make([]*image.Image, 0)
assert.Exactly(t, firstNImages(empty, 1), empty, "Should be the same element")
})
t.Run("when it contains one element, return the same array", func(t *testing.T) {
one := []*image.Image{sampleImage("image.png")}
assert.Exactly(t, firstNImages(one, 1), one, "Should be the same element")
})
t.Run("when it contains more than one element and asking for only one, return the first one", func(t *testing.T) {
two := []*image.Image{sampleImage("image.png"), sampleImage("notme.png")}
assert.True(t, strings.HasSuffix(firstNImages(two, 1)[0].URL, "image.png"), "Should be the image element")
})
t.Run("when it contains less than asked, return the original", func(t *testing.T) {
two := []*image.Image{sampleImage("image.png"), sampleImage("notme.png")}
assert.Equal(t, two, firstNImages(two, 10), "should be the same pointer")
})
t.Run("asking for negative images", func(t *testing.T) {
six := []*image.Image{
sampleImage("image.png"),
sampleImage("another.png"),
sampleImage("yetanother.jpg"),
sampleImage("metoo.gif"),
sampleImage("fifth.ico"),
sampleImage("notme.tiff"),
}
assert.Len(t, firstNImages(six, -10), LinkMetadataMaxImages, "On negative, go for defaults")
})
}
func TestTruncateOpenGraph(t *testing.T) {
og := opengraph.OpenGraph{
Type: "something",
URL: "http://myawesomesite.com",
Title: BigText,
Description: BigText,
Determiner: BigText,
SiteName: BigText,
Locale: "[EN-en]",
LocalesAlternate: []string{"[EN-ca]", "[ES-es]"},
Images: []*image.Image{
sampleImage("image.png"),
sampleImage("another.png"),
sampleImage("yetanother.jpg"),
sampleImage("metoo.gif"),
sampleImage("fifth.ico"),
sampleImage("notme.tiff")},
Audios: []*audio.Audio{{}},
Videos: []*video.Video{{}},
Article: &article.Article{},
Book: &book.Book{},
Profile: &profile.Profile{},
}
result := TruncateOpenGraph(&og)
assert.Nil(t, result.Article, "No article stored")
assert.Nil(t, result.Book, "No book stored")
assert.Nil(t, result.Profile, "No profile stored")
assert.Len(t, result.Images, 5, "Only the first 5 images")
assert.Empty(t, result.Audios, "No audios stored")
assert.Empty(t, result.Videos, "No videos stored")
assert.Empty(t, result.LocalesAlternate, "No alternate locales stored")
assert.Equal(t, result.Determiner, "", "No determiner stored")
assert.Equal(t, utf8.RuneCountInString(result.Title), 305, "Title text is truncated")
assert.Equal(t, utf8.RuneCountInString(result.Description), 305, "Description text is truncated")
assert.Equal(t, utf8.RuneCountInString(result.SiteName), 305, "SiteName text is truncated")
}

461
server/model/manifest.go Обычный файл
Просмотреть файл

@@ -0,0 +1,461 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/blang/semver"
"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)
type PluginOption struct {
// The display name for the option.
DisplayName string `json:"display_name" yaml:"display_name"`
// The string value for the option.
Value string `json:"value" yaml:"value"`
}
type PluginSettingType int
const (
Bool PluginSettingType = iota
Dropdown
Generated
Radio
Text
LongText
Number
Username
Custom
)
type PluginSetting struct {
// The key that the setting will be assigned to in the configuration file.
Key string `json:"key" yaml:"key"`
// The display name for the setting.
DisplayName string `json:"display_name" yaml:"display_name"`
// The type of the setting.
//
// "bool" will result in a boolean true or false setting.
//
// "dropdown" will result in a string setting that allows the user to select from a list of
// pre-defined options.
//
// "generated" will result in a string setting that is set to a random, cryptographically secure
// string.
//
// "radio" will result in a string setting that allows the user to select from a short selection
// of pre-defined options.
//
// "text" will result in a string setting that can be typed in manually.
//
// "longtext" will result in a multi line string that can be typed in manually.
//
// "number" will result in in integer setting that can be typed in manually.
//
// "username" will result in a text setting that will autocomplete to a username.
//
// "custom" will result in a custom defined setting and will load the custom component registered for the Web App System Console.
Type string `json:"type" yaml:"type"`
// The help text to display to the user. Supports Markdown formatting.
HelpText string `json:"help_text" yaml:"help_text"`
// The help text to display alongside the "Regenerate" button for settings of the "generated" type.
RegenerateHelpText string `json:"regenerate_help_text,omitempty" yaml:"regenerate_help_text,omitempty"`
// The placeholder to display for "generated", "text", "longtext", "number" and "username" types when blank.
Placeholder string `json:"placeholder" yaml:"placeholder"`
// The default value of the setting.
Default any `json:"default" yaml:"default"`
// For "radio" or "dropdown" settings, this is the list of pre-defined options that the user can choose
// from.
Options []*PluginOption `json:"options,omitempty" yaml:"options,omitempty"`
// The intended hosting environment for this plugin setting. Can be "cloud" or "on-prem". When this field is set,
// and the opposite environment is running the plugin, the setting will be hidden in the admin console UI.
// Note that this functionality is entirely client-side, so the plugin needs to handle the case of invalid submissions.
Hosting string `json:"hosting"`
}
type PluginSettingsSchema struct {
// Optional text to display above the settings. Supports Markdown formatting.
Header string `json:"header" yaml:"header"`
// Optional text to display below the settings. Supports Markdown formatting.
Footer string `json:"footer" yaml:"footer"`
// A list of setting definitions.
Settings []*PluginSetting `json:"settings" yaml:"settings"`
}
// The plugin manifest defines the metadata required to load and present your plugin. The manifest
// file should be named plugin.json or plugin.yaml and placed in the top of your
// plugin bundle.
//
// Example plugin.json:
//
// {
// "id": "com.mycompany.myplugin",
// "name": "My Plugin",
// "description": "This is my plugin",
// "homepage_url": "https://example.com",
// "support_url": "https://example.com/support",
// "release_notes_url": "https://example.com/releases/v0.0.1",
// "icon_path": "assets/logo.svg",
// "version": "0.1.0",
// "min_server_version": "5.6.0",
// "server": {
// "executables": {
// "linux-amd64": "server/dist/plugin-linux-amd64",
// "darwin-amd64": "server/dist/plugin-darwin-amd64",
// "windows-amd64": "server/dist/plugin-windows-amd64.exe"
// }
// },
// "webapp": {
// "bundle_path": "webapp/dist/main.js"
// },
// "settings_schema": {
// "header": "Some header text",
// "footer": "Some footer text",
// "settings": [{
// "key": "someKey",
// "display_name": "Enable Extra Feature",
// "type": "bool",
// "help_text": "When true, an extra feature will be enabled!",
// "default": "false"
// }]
// },
// "props": {
// "someKey": "someData"
// }
// }
type Manifest struct {
// The id is a globally unique identifier that represents your plugin. Ids must be at least
// 3 characters, at most 190 characters and must match ^[a-zA-Z0-9-_\.]+$.
// Reverse-DNS notation using a name you control is a good option, e.g. "com.mycompany.myplugin".
Id string `json:"id" yaml:"id"`
// The name to be displayed for the plugin.
Name string `json:"name" yaml:"name"`
// A description of what your plugin is and does.
Description string `json:"description,omitempty" yaml:"description,omitempty"`
// HomepageURL is an optional link to learn more about the plugin.
HomepageURL string `json:"homepage_url,omitempty" yaml:"homepage_url,omitempty"`
// SupportURL is an optional URL where plugin issues can be reported.
SupportURL string `json:"support_url,omitempty" yaml:"support_url,omitempty"`
// ReleaseNotesURL is an optional URL where a changelog for the release can be found.
ReleaseNotesURL string `json:"release_notes_url,omitempty" yaml:"release_notes_url,omitempty"`
// A relative file path in the bundle that points to the plugins svg icon for use with the Plugin Marketplace.
// This should be relative to the root of your bundle and the location of the manifest file. Bitmap image formats are not supported.
IconPath string `json:"icon_path,omitempty" yaml:"icon_path,omitempty"`
// A version number for your plugin. Semantic versioning is recommended: http://semver.org
Version string `json:"version" yaml:"version"`
// The minimum Mattermost server version required for your plugin.
//
// Minimum server version: 5.6
MinServerVersion string `json:"min_server_version,omitempty" yaml:"min_server_version,omitempty"`
// Server defines the server-side portion of your plugin.
Server *ManifestServer `json:"server,omitempty" yaml:"server,omitempty"`
// If your plugin extends the web app, you'll need to define webapp.
Webapp *ManifestWebapp `json:"webapp,omitempty" yaml:"webapp,omitempty"`
// To allow administrators to configure your plugin via the Mattermost system console, you can
// provide your settings schema.
SettingsSchema *PluginSettingsSchema `json:"settings_schema,omitempty" yaml:"settings_schema,omitempty"`
// Plugins can store any kind of data in Props to allow other plugins to use it.
Props map[string]any `json:"props,omitempty" yaml:"props,omitempty"`
// RequiredConfig defines any required server configuration fields for the plugin to function properly.
//
// Use the pluginapi.Configuration.CheckRequiredServerConfiguration method to enforce this.
RequiredConfig *Config `json:"required_configuration,omitempty" yaml:"required_configuration,omitempty"`
}
type ManifestServer struct {
// Executables are the paths to your executable binaries, specifying multiple entry
// points for different platforms when bundled together in a single plugin.
Executables map[string]string `json:"executables,omitempty" yaml:"executables,omitempty"`
// Executable is the path to your executable binary. This should be relative to the root
// of your bundle and the location of the manifest file.
//
// On Windows, this file must have a ".exe" extension.
//
// If your plugin is compiled for multiple platforms, consider bundling them together
// and using the Executables field instead.
Executable string `json:"executable" yaml:"executable"`
}
// Deprecated: ManifestExecutables is a legacy structure capturing a subset of the known platform executables.
// It will be remove in v7.0: https://mattermost.atlassian.net/browse/MM-40531
type ManifestExecutables struct {
// LinuxAmd64 is the path to your executable binary for the corresponding platform
LinuxAmd64 string `json:"linux-amd64,omitempty" yaml:"linux-amd64,omitempty"`
// DarwinAmd64 is the path to your executable binary for the corresponding platform
DarwinAmd64 string `json:"darwin-amd64,omitempty" yaml:"darwin-amd64,omitempty"`
// WindowsAmd64 is the path to your executable binary for the corresponding platform
// This file must have a ".exe" extension
WindowsAmd64 string `json:"windows-amd64,omitempty" yaml:"windows-amd64,omitempty"`
}
type ManifestWebapp struct {
// The path to your webapp bundle. This should be relative to the root of your bundle and the
// location of the manifest file.
BundlePath string `json:"bundle_path" yaml:"bundle_path"`
// BundleHash is the 64-bit FNV-1a hash of the webapp bundle, computed when the plugin is loaded
BundleHash []byte `json:"-"`
}
func (m *Manifest) HasClient() bool {
return m.Webapp != nil
}
func (m *Manifest) ClientManifest() *Manifest {
cm := new(Manifest)
*cm = *m
cm.Name = ""
cm.Description = ""
cm.Server = nil
if cm.Webapp != nil {
cm.Webapp = new(ManifestWebapp)
*cm.Webapp = *m.Webapp
cm.Webapp.BundlePath = "/static/" + m.Id + "/" + fmt.Sprintf("%s_%x_bundle.js", m.Id, m.Webapp.BundleHash)
}
return cm
}
// GetExecutableForRuntime returns the path to the executable for the given runtime architecture.
//
// If the manifest defines multiple executables, but none match, or if only a single executable
// is defined, the Executable field will be returned. This method does not guarantee that the
// resulting binary can actually execute on the given platform.
func (m *Manifest) GetExecutableForRuntime(goOs, goArch string) string {
server := m.Server
if server == nil {
return ""
}
var executable string
if len(server.Executables) > 0 {
osArch := fmt.Sprintf("%s-%s", goOs, goArch)
executable = server.Executables[osArch]
}
if executable == "" {
executable = server.Executable
}
return executable
}
func (m *Manifest) HasServer() bool {
return m.Server != nil
}
func (m *Manifest) HasWebapp() bool {
return m.Webapp != nil
}
func (m *Manifest) MeetMinServerVersion(serverVersion string) (bool, error) {
minServerVersion, err := semver.Parse(m.MinServerVersion)
if err != nil {
return false, errors.New("failed to parse MinServerVersion")
}
sv := semver.MustParse(serverVersion)
if sv.LT(minServerVersion) {
return false, nil
}
return true, nil
}
func (m *Manifest) IsValid() error {
if !IsValidPluginId(m.Id) {
return errors.New("invalid plugin ID")
}
if strings.TrimSpace(m.Name) == "" {
return errors.New("a plugin name is needed")
}
if m.HomepageURL != "" && !IsValidHTTPURL(m.HomepageURL) {
return errors.New("invalid HomepageURL")
}
if m.SupportURL != "" && !IsValidHTTPURL(m.SupportURL) {
return errors.New("invalid SupportURL")
}
if m.ReleaseNotesURL != "" && !IsValidHTTPURL(m.ReleaseNotesURL) {
return errors.New("invalid ReleaseNotesURL")
}
if m.Version != "" {
_, err := semver.Parse(m.Version)
if err != nil {
return errors.Wrap(err, "failed to parse Version")
}
}
if m.MinServerVersion != "" {
_, err := semver.Parse(m.MinServerVersion)
if err != nil {
return errors.Wrap(err, "failed to parse MinServerVersion")
}
}
if m.SettingsSchema != nil {
err := m.SettingsSchema.isValid()
if err != nil {
return errors.Wrap(err, "invalid settings schema")
}
}
return nil
}
func (s *PluginSettingsSchema) isValid() error {
for _, setting := range s.Settings {
err := setting.isValid()
if err != nil {
return err
}
}
return nil
}
func (s *PluginSetting) isValid() error {
pluginSettingType, err := convertTypeToPluginSettingType(s.Type)
if err != nil {
return err
}
if s.RegenerateHelpText != "" && pluginSettingType != Generated {
return errors.New("should not set RegenerateHelpText for setting type that is not generated")
}
if s.Placeholder != "" && !(pluginSettingType == Generated ||
pluginSettingType == Text ||
pluginSettingType == LongText ||
pluginSettingType == Number ||
pluginSettingType == Username ||
pluginSettingType == Custom) {
return errors.New("should not set Placeholder for setting type not in text, generated, number, username, or custom")
}
if s.Options != nil {
if pluginSettingType != Radio && pluginSettingType != Dropdown {
return errors.New("should not set Options for setting type not in radio or dropdown")
}
for _, option := range s.Options {
if option.DisplayName == "" || option.Value == "" {
return errors.New("should not have empty Displayname or Value for any option")
}
}
}
return nil
}
func convertTypeToPluginSettingType(t string) (PluginSettingType, error) {
var settingType PluginSettingType
switch t {
case "bool":
return Bool, nil
case "dropdown":
return Dropdown, nil
case "generated":
return Generated, nil
case "radio":
return Radio, nil
case "text":
return Text, nil
case "number":
return Number, nil
case "longtext":
return LongText, nil
case "username":
return Username, nil
case "custom":
return Custom, nil
default:
return settingType, errors.New("invalid setting type: " + t)
}
}
// FindManifest will find and parse the manifest in a given directory.
//
// In all cases other than a does-not-exist error, path is set to the path of the manifest file that was
// found.
//
// Manifests are JSON or YAML files named plugin.json, plugin.yaml, or plugin.yml.
func FindManifest(dir string) (manifest *Manifest, path string, err error) {
for _, name := range []string{"plugin.yml", "plugin.yaml"} {
path = filepath.Join(dir, name)
f, ferr := os.Open(path)
if ferr != nil {
if !os.IsNotExist(ferr) {
return nil, "", ferr
}
continue
}
b, ioerr := io.ReadAll(f)
f.Close()
if ioerr != nil {
return nil, path, ioerr
}
var parsed Manifest
err = yaml.Unmarshal(b, &parsed)
if err != nil {
return nil, path, err
}
manifest = &parsed
manifest.Id = strings.ToLower(manifest.Id)
return manifest, path, nil
}
path = filepath.Join(dir, "plugin.json")
f, ferr := os.Open(path)
if ferr != nil {
if os.IsNotExist(ferr) {
path = ""
}
return nil, path, ferr
}
defer f.Close()
var parsed Manifest
err = json.NewDecoder(f).Decode(&parsed)
if err != nil {
return nil, path, err
}
manifest = &parsed
manifest.Id = strings.ToLower(manifest.Id)
return manifest, path, nil
}

811
server/model/manifest_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,811 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v2"
)
func TestIsValid(t *testing.T) {
testCases := []struct {
Title string
manifest *Manifest
ExpectError bool
}{
{"Invalid Id", &Manifest{Id: "some id", Name: "some name"}, true},
{"Invalid Name", &Manifest{Id: "com.company.test", Name: " "}, true},
{"Invalid homePageURL", &Manifest{Id: "com.company.test", Name: "some name", HomepageURL: "some url"}, true},
{"Invalid supportURL", &Manifest{Id: "com.company.test", Name: "some name", SupportURL: "some url"}, true},
{"Invalid ReleaseNotesURL", &Manifest{Id: "com.company.test", Name: "some name", ReleaseNotesURL: "some url"}, true},
{"Invalid version", &Manifest{Id: "com.company.test", Name: "some name", HomepageURL: "http://someurl.com", SupportURL: "http://someotherurl.com", Version: "version"}, true},
{"Invalid min version", &Manifest{Id: "com.company.test", Name: "some name", HomepageURL: "http://someurl.com", SupportURL: "http://someotherurl.com", Version: "5.10.0", MinServerVersion: "version"}, true},
{"SettingSchema error", &Manifest{Id: "com.company.test", Name: "some name", HomepageURL: "http://someurl.com", SupportURL: "http://someotherurl.com", Version: "5.10.0", MinServerVersion: "5.10.8", SettingsSchema: &PluginSettingsSchema{
Settings: []*PluginSetting{{Type: "Invalid"}},
}}, true},
{"Minimal valid manifest", &Manifest{Id: "com.company.test", Name: "some name"}, false},
{"Happy case", &Manifest{
Id: "com.company.test",
Name: "thename",
Description: "thedescription",
HomepageURL: "http://someurl.com",
SupportURL: "http://someotherurl.com",
ReleaseNotesURL: "http://someotherurl.com/releases/v0.0.1",
Version: "0.0.1",
MinServerVersion: "5.6.0",
Server: &ManifestServer{
Executable: "theexecutable",
},
Webapp: &ManifestWebapp{
BundlePath: "thebundlepath",
},
SettingsSchema: &PluginSettingsSchema{
Header: "theheadertext",
Footer: "thefootertext",
Settings: []*PluginSetting{
{
Key: "thesetting",
DisplayName: "thedisplayname",
Type: "dropdown",
HelpText: "thehelptext",
Options: []*PluginOption{
{
DisplayName: "theoptiondisplayname",
Value: "thevalue",
},
},
Default: "thedefault",
},
},
},
}, false},
}
for _, tc := range testCases {
t.Run(tc.Title, func(t *testing.T) {
err := tc.manifest.IsValid()
if tc.ExpectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestIsValidSettingsSchema(t *testing.T) {
testCases := []struct {
Title string
settingsSchema *PluginSettingsSchema
ExpectError bool
}{
{"Invalid Setting", &PluginSettingsSchema{Settings: []*PluginSetting{{Type: "invalid"}}}, true},
{"Happy case", &PluginSettingsSchema{Settings: []*PluginSetting{{Type: "text"}}}, false},
}
for _, tc := range testCases {
t.Run(tc.Title, func(t *testing.T) {
err := tc.settingsSchema.isValid()
if tc.ExpectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestSettingIsValid(t *testing.T) {
for name, test := range map[string]struct {
Setting PluginSetting
ExpectError bool
}{
"Invalid setting type": {
PluginSetting{Type: "invalid"},
true,
},
"RegenerateHelpText error": {
PluginSetting{Type: "text", RegenerateHelpText: "some text"},
true,
},
"Placeholder error": {
PluginSetting{Type: "bool", Placeholder: "some text"},
true,
},
"Nil Options": {
PluginSetting{Type: "bool"},
false,
},
"Options error": {
PluginSetting{Type: "generated", Options: []*PluginOption{}},
true,
},
"Options displayName error": {
PluginSetting{
Type: "radio",
Options: []*PluginOption{{
Value: "some value",
}},
},
true,
},
"Options value error": {
PluginSetting{
Type: "radio",
Options: []*PluginOption{{
DisplayName: "some name",
}},
},
true,
},
"Happy case": {
PluginSetting{
Type: "radio",
Options: []*PluginOption{{
DisplayName: "Name",
Value: "value",
}},
},
false,
},
"Valid number setting": {
PluginSetting{
Type: "number",
Default: 10,
},
false,
},
"Placeholder is disallowed for bool settings": {
PluginSetting{
Type: "bool",
Placeholder: "some Text",
},
true,
},
"Placeholder is allowed for text settings": {
PluginSetting{
Type: "text",
Placeholder: "some Text",
},
false,
},
"Placeholder is allowed for long text settings": {
PluginSetting{
Type: "longtext",
Placeholder: "some Text",
},
false,
},
"Placeholder is allowed for custom settings": {
PluginSetting{
Type: "custom",
Placeholder: "some Text",
},
false,
},
} {
t.Run(name, func(t *testing.T) {
err := test.Setting.isValid()
if test.ExpectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestConvertTypeToPluginSettingType(t *testing.T) {
testCases := []struct {
Title string
Type string
ExpectedSettingType PluginSettingType
ExpectError bool
}{
{"bool", "bool", Bool, false},
{"dropdown", "dropdown", Dropdown, false},
{"generated", "generated", Generated, false},
{"radio", "radio", Radio, false},
{"text", "text", Text, false},
{"longtext", "longtext", LongText, false},
{"username", "username", Username, false},
{"custom", "custom", Custom, false},
{"invalid", "invalid", Bool, true},
}
for _, tc := range testCases {
t.Run(tc.Title, func(t *testing.T) {
settingType, err := convertTypeToPluginSettingType(tc.Type)
if !tc.ExpectError {
assert.Equal(t, settingType, tc.ExpectedSettingType)
} else {
assert.Error(t, err)
}
})
}
}
func TestFindManifest(t *testing.T) {
for _, tc := range []struct {
Filename string
Contents string
ExpectError bool
ExpectNotExist bool
}{
{"foo", "bar", true, true},
{"plugin.json", "bar", true, false},
{"plugin.json", `{"id": "foo"}`, false, false},
{"plugin.json", `{"id": "FOO"}`, false, false},
{"plugin.yaml", `id: foo`, false, false},
{"plugin.yaml", "bar", true, false},
{"plugin.yml", `id: foo`, false, false},
{"plugin.yml", `id: FOO`, false, false},
{"plugin.yml", "bar", true, false},
} {
dir, err := os.MkdirTemp("", "mm-plugin-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
path := filepath.Join(dir, tc.Filename)
f, err := os.Create(path)
require.NoError(t, err)
_, err = f.WriteString(tc.Contents)
f.Close()
require.NoError(t, err)
m, mpath, err := FindManifest(dir)
assert.True(t, (err != nil) == tc.ExpectError, tc.Filename)
assert.True(t, (err != nil && os.IsNotExist(err)) == tc.ExpectNotExist, tc.Filename)
if !tc.ExpectNotExist {
assert.Equal(t, path, mpath, tc.Filename)
} else {
assert.Empty(t, mpath, tc.Filename)
}
if !tc.ExpectError {
require.NotNil(t, m, tc.Filename)
assert.NotEmpty(t, m.Id, tc.Filename)
assert.Equal(t, strings.ToLower(m.Id), m.Id)
}
}
}
func TestManifestUnmarshal(t *testing.T) {
expected := Manifest{
Id: "theid",
HomepageURL: "https://example.com",
SupportURL: "https://example.com/support",
IconPath: "assets/icon.svg",
MinServerVersion: "5.6.0",
Server: &ManifestServer{
Executable: "theexecutable",
Executables: map[string]string{
"linux-amd64": "theexecutable-linux-amd64",
"darwin-amd64": "theexecutable-darwin-amd64",
"windows-amd64": "theexecutable-windows-amd64",
"linux-arm64": "theexecutable-linux-arm64",
},
},
Webapp: &ManifestWebapp{
BundlePath: "thebundlepath",
},
SettingsSchema: &PluginSettingsSchema{
Header: "theheadertext",
Footer: "thefootertext",
Settings: []*PluginSetting{
{
Key: "thesetting",
DisplayName: "thedisplayname",
Type: "dropdown",
HelpText: "thehelptext",
RegenerateHelpText: "theregeneratehelptext",
Placeholder: "theplaceholder",
Options: []*PluginOption{
{
DisplayName: "theoptiondisplayname",
Value: "thevalue",
},
},
Default: "thedefault",
},
},
},
}
t.Run("yaml", func(t *testing.T) {
var yamlResult Manifest
require.NoError(t, yaml.Unmarshal([]byte(`
id: theid
homepage_url: https://example.com
support_url: https://example.com/support
icon_path: assets/icon.svg
min_server_version: 5.6.0
server:
executable: theexecutable
executables:
linux-amd64: theexecutable-linux-amd64
darwin-amd64: theexecutable-darwin-amd64
windows-amd64: theexecutable-windows-amd64
linux-arm64: theexecutable-linux-arm64
webapp:
bundle_path: thebundlepath
settings_schema:
header: theheadertext
footer: thefootertext
settings:
- key: thesetting
display_name: thedisplayname
type: dropdown
help_text: thehelptext
regenerate_help_text: theregeneratehelptext
placeholder: theplaceholder
options:
- display_name: theoptiondisplayname
value: thevalue
default: thedefault
`), &yamlResult))
assert.Equal(t, expected, yamlResult)
})
t.Run("json", func(t *testing.T) {
var jsonResult Manifest
require.NoError(t, json.Unmarshal([]byte(`{
"id": "theid",
"homepage_url": "https://example.com",
"support_url": "https://example.com/support",
"icon_path": "assets/icon.svg",
"min_server_version": "5.6.0",
"server": {
"executable": "theexecutable",
"executables": {
"linux-amd64": "theexecutable-linux-amd64",
"darwin-amd64": "theexecutable-darwin-amd64",
"windows-amd64": "theexecutable-windows-amd64",
"linux-arm64": "theexecutable-linux-arm64"
}
},
"webapp": {
"bundle_path": "thebundlepath"
},
"settings_schema": {
"header": "theheadertext",
"footer": "thefootertext",
"settings": [
{
"key": "thesetting",
"display_name": "thedisplayname",
"type": "dropdown",
"help_text": "thehelptext",
"regenerate_help_text": "theregeneratehelptext",
"placeholder": "theplaceholder",
"options": [
{
"display_name": "theoptiondisplayname",
"value": "thevalue"
}
],
"default": "thedefault"
}
]
}
}`), &jsonResult))
assert.Equal(t, expected, jsonResult)
})
}
func TestFindManifest_FileErrors(t *testing.T) {
for _, tc := range []string{"plugin.yaml", "plugin.json"} {
dir, err := os.MkdirTemp("", "mm-plugin-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
path := filepath.Join(dir, tc)
require.NoError(t, os.Mkdir(path, 0700))
m, mpath, err := FindManifest(dir)
assert.Nil(t, m)
assert.Equal(t, path, mpath)
assert.Error(t, err, tc)
assert.False(t, os.IsNotExist(err), tc)
}
}
func TestFindManifest_FolderPermission(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("skipping test while running as root: can't effectively remove permissions")
}
for _, tc := range []string{"plugin.yaml", "plugin.json"} {
dir, err := os.MkdirTemp("", "mm-plugin-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
path := filepath.Join(dir, tc)
require.NoError(t, os.Mkdir(path, 0700))
// User does not have permission in the plugin folder
err = os.Chmod(dir, 0066)
require.NoError(t, err)
m, mpath, err := FindManifest(dir)
assert.Nil(t, m)
assert.Equal(t, "", mpath)
assert.Error(t, err, tc)
assert.False(t, os.IsNotExist(err), tc)
}
}
func TestManifestHasClient(t *testing.T) {
manifest := &Manifest{
Id: "theid",
Server: &ManifestServer{
Executable: "theexecutable",
},
Webapp: &ManifestWebapp{
BundlePath: "thebundlepath",
},
}
assert.True(t, manifest.HasClient())
manifest.Webapp = nil
assert.False(t, manifest.HasClient())
}
func TestManifestClientManifest(t *testing.T) {
manifest := &Manifest{
Id: "theid",
Name: "thename",
Description: "thedescription",
Version: "0.0.1",
MinServerVersion: "5.6.0",
Server: &ManifestServer{
Executable: "theexecutable",
},
Webapp: &ManifestWebapp{
BundlePath: "thebundlepath",
BundleHash: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
},
SettingsSchema: &PluginSettingsSchema{
Header: "theheadertext",
Footer: "thefootertext",
Settings: []*PluginSetting{
{
Key: "thesetting",
DisplayName: "thedisplayname",
Type: "dropdown",
HelpText: "thehelptext",
RegenerateHelpText: "theregeneratehelptext",
Placeholder: "theplaceholder",
Options: []*PluginOption{
{
DisplayName: "theoptiondisplayname",
Value: "thevalue",
},
},
Default: "thedefault",
},
},
},
}
sanitized := manifest.ClientManifest()
assert.Equal(t, manifest.Id, sanitized.Id)
assert.Equal(t, manifest.Version, sanitized.Version)
assert.Equal(t, manifest.MinServerVersion, sanitized.MinServerVersion)
assert.Equal(t, "/static/theid/theid_000102030405060708090a0b0c0d0e0f_bundle.js", sanitized.Webapp.BundlePath)
assert.Equal(t, manifest.Webapp.BundleHash, sanitized.Webapp.BundleHash)
assert.Equal(t, manifest.SettingsSchema, sanitized.SettingsSchema)
assert.Empty(t, sanitized.Name)
assert.Empty(t, sanitized.Description)
assert.Empty(t, sanitized.Server)
assert.NotEmpty(t, manifest.Id)
assert.NotEmpty(t, manifest.Version)
assert.NotEmpty(t, manifest.MinServerVersion)
assert.NotEmpty(t, manifest.Webapp)
assert.NotEmpty(t, manifest.Name)
assert.NotEmpty(t, manifest.Description)
assert.NotEmpty(t, manifest.Server)
assert.NotEmpty(t, manifest.SettingsSchema)
}
func TestManifestGetExecutableForRuntime(t *testing.T) {
testCases := []struct {
Description string
Manifest *Manifest
GoOs string
GoArch string
ExpectedExecutable string
}{
{
"no server",
&Manifest{},
"linux",
"amd64",
"",
},
{
"no executable",
&Manifest{
Server: &ManifestServer{},
},
"linux",
"amd64",
"",
},
{
"single executable",
&Manifest{
Server: &ManifestServer{
Executable: "path/to/executable",
},
},
"linux",
"amd64",
"path/to/executable",
},
{
"single executable, different runtime",
&Manifest{
Server: &ManifestServer{
Executable: "path/to/executable",
},
},
"darwin",
"amd64",
"path/to/executable",
},
{
"multiple executables, no match",
&Manifest{
Server: &ManifestServer{
Executables: map[string]string{
"linux-amd64": "linux-amd64/path/to/executable",
"darwin-amd64": "darwin-amd64/path/to/executable",
"windows-amd64": "windows-amd64/path/to/executable",
"linux-arm64": "linux-arm64/path/to/executable",
},
},
},
"other",
"amd64",
"",
},
{
"multiple executables, linux-amd64 match",
&Manifest{
Server: &ManifestServer{
Executables: map[string]string{
"linux-amd64": "linux-amd64/path/to/executable",
"darwin-amd64": "darwin-amd64/path/to/executable",
"windows-amd64": "windows-amd64/path/to/executable",
"linux-arm64": "linux-arm64/path/to/executable",
},
},
},
"linux",
"amd64",
"linux-amd64/path/to/executable",
},
{
"multiple executables, linux-amd64 match, single executable ignored",
&Manifest{
Server: &ManifestServer{
Executables: map[string]string{
"linux-amd64": "linux-amd64/path/to/executable",
"darwin-amd64": "darwin-amd64/path/to/executable",
"windows-amd64": "windows-amd64/path/to/executable",
"linux-arm64": "linux-arm64/path/to/executable",
},
Executable: "path/to/executable",
},
},
"linux",
"amd64",
"linux-amd64/path/to/executable",
},
{
"multiple executables, darwin-amd64 match",
&Manifest{
Server: &ManifestServer{
Executables: map[string]string{
"linux-amd64": "linux-amd64/path/to/executable",
"darwin-amd64": "darwin-amd64/path/to/executable",
"windows-amd64": "windows-amd64/path/to/executable",
"linux-arm64": "linux-arm64/path/to/executable",
},
},
},
"darwin",
"amd64",
"darwin-amd64/path/to/executable",
},
{
"multiple executables, windows-amd64 match",
&Manifest{
Server: &ManifestServer{
Executables: map[string]string{
"linux-amd64": "linux-amd64/path/to/executable",
"darwin-amd64": "darwin-amd64/path/to/executable",
"windows-amd64": "windows-amd64/path/to/executable",
"linux-arm64": "linux-arm64/path/to/executable",
},
},
},
"windows",
"amd64",
"windows-amd64/path/to/executable",
},
{
"multiple executables, no match, single executable fallback",
&Manifest{
Server: &ManifestServer{
Executables: map[string]string{
"linux-amd64": "linux-amd64/path/to/executable",
"darwin-amd64": "darwin-amd64/path/to/executable",
"windows-amd64": "windows-amd64/path/to/executable",
"linux-arm64": "linux-arm64/path/to/executable",
},
Executable: "path/to/executable",
},
},
"other",
"amd64",
"path/to/executable",
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
assert.Equal(
t,
testCase.ExpectedExecutable,
testCase.Manifest.GetExecutableForRuntime(testCase.GoOs, testCase.GoArch),
)
})
}
}
func TestManifestHasServer(t *testing.T) {
testCases := []struct {
Description string
Manifest *Manifest
Expected bool
}{
{
"no server",
&Manifest{},
false,
},
{
"no executable, but server still considered present",
&Manifest{
Server: &ManifestServer{},
},
true,
},
{
"single executable",
&Manifest{
Server: &ManifestServer{
Executable: "path/to/executable",
},
},
true,
},
{
"multiple executables",
&Manifest{
Server: &ManifestServer{
Executables: map[string]string{
"linux-amd64": "linux-amd64/path/to/executable",
"darwin-amd64": "darwin-amd64/path/to/executable",
"windows-amd64": "windows-amd64/path/to/executable",
},
},
},
true,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
assert.Equal(t, testCase.Expected, testCase.Manifest.HasServer())
})
}
}
func TestManifestHasWebapp(t *testing.T) {
testCases := []struct {
Description string
Manifest *Manifest
Expected bool
}{
{
"no webapp",
&Manifest{},
false,
},
{
"no bundle path, but webapp still considered present",
&Manifest{
Webapp: &ManifestWebapp{},
},
true,
},
{
"bundle path defined",
&Manifest{
Webapp: &ManifestWebapp{
BundlePath: "path/to/bundle",
},
},
true,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
assert.Equal(t, testCase.Expected, testCase.Manifest.HasWebapp())
})
}
}
func TestManifestMeetMinServerVersion(t *testing.T) {
for name, test := range map[string]struct {
MinServerVersion string
ServerVersion string
ShouldError bool
ShouldFulfill bool
}{
"generously fulfilled": {
MinServerVersion: "5.5.0",
ServerVersion: "5.6.0",
ShouldError: false,
ShouldFulfill: true,
},
"exactly fulfilled": {
MinServerVersion: "5.6.0",
ServerVersion: "5.6.0",
ShouldError: false,
ShouldFulfill: true,
},
"not fulfilled": {
MinServerVersion: "5.6.0",
ServerVersion: "5.5.0",
ShouldError: false,
ShouldFulfill: false,
},
"fail to parse MinServerVersion": {
MinServerVersion: "abc",
ServerVersion: "5.5.0",
ShouldError: true,
},
} {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
manifest := Manifest{
MinServerVersion: test.MinServerVersion,
}
fulfilled, err := manifest.MeetMinServerVersion(test.ServerVersion)
if test.ShouldError {
assert.NotNil(err)
assert.False(fulfilled)
return
}
assert.Nil(err)
assert.Equal(test.ShouldFulfill, fulfilled)
})
}
}

129
server/model/marketplace_plugin.go Обычный файл
Просмотреть файл

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/url"
"strconv"
"github.com/pkg/errors"
)
// BaseMarketplacePlugin is a Mattermost plugin received from the Marketplace server.
type BaseMarketplacePlugin struct {
HomepageURL string `json:"homepage_url"`
IconData string `json:"icon_data"`
DownloadURL string `json:"download_url"`
ReleaseNotesURL string `json:"release_notes_url"`
Labels []MarketplaceLabel `json:"labels,omitempty"`
Hosting string `json:"hosting"` // Indicated if the plugin is limited to a certain hosting type
AuthorType string `json:"author_type"` // The maintainer of the plugin
ReleaseStage string `json:"release_stage"` // The stage in the software release cycle that the plugin is in
Enterprise bool `json:"enterprise"` // Indicated if the plugin is an enterprise plugin
Signature string `json:"signature"` // Signature represents a signature of a plugin saved in base64 encoding.
Manifest *Manifest `json:"manifest"`
}
// MarketplaceLabel represents a label shown in the Marketplace UI.
type MarketplaceLabel struct {
Name string `json:"name"`
Description string `json:"description"`
URL string `json:"url"`
Color string `json:"color"`
}
// MarketplacePlugin is a state aware Marketplace plugin.
type MarketplacePlugin struct {
*BaseMarketplacePlugin
InstalledVersion string `json:"installed_version"`
}
// BaseMarketplacePluginsFromReader decodes a json-encoded list of plugins from the given io.Reader.
func BaseMarketplacePluginsFromReader(reader io.Reader) ([]*BaseMarketplacePlugin, error) {
plugins := []*BaseMarketplacePlugin{}
decoder := json.NewDecoder(reader)
if err := decoder.Decode(&plugins); err != nil && err != io.EOF {
return nil, err
}
return plugins, nil
}
// MarketplacePluginsFromReader decodes a json-encoded list of plugins from the given io.Reader.
func MarketplacePluginsFromReader(reader io.Reader) ([]*MarketplacePlugin, error) {
plugins := []*MarketplacePlugin{}
decoder := json.NewDecoder(reader)
if err := decoder.Decode(&plugins); err != nil && err != io.EOF {
return nil, err
}
return plugins, nil
}
// DecodeSignature Decodes signature and returns ReadSeeker.
func (plugin *BaseMarketplacePlugin) DecodeSignature() (io.ReadSeeker, error) {
signatureBytes, err := base64.StdEncoding.DecodeString(plugin.Signature)
if err != nil {
return nil, errors.Wrap(err, "Unable to decode base64 signature.")
}
return bytes.NewReader(signatureBytes), nil
}
// MarketplacePluginFilter describes the parameters to request a list of plugins.
type MarketplacePluginFilter struct {
Page int
PerPage int
Filter string
ServerVersion string
BuildEnterpriseReady bool
EnterprisePlugins bool
Cloud bool
LocalOnly bool
Platform string
PluginId string
ReturnAllVersions bool
RemoteOnly bool
}
// ApplyToURL modifies the given url to include query string parameters for the request.
func (filter *MarketplacePluginFilter) ApplyToURL(u *url.URL) {
q := u.Query()
q.Add("page", strconv.Itoa(filter.Page))
if filter.PerPage > 0 {
q.Add("per_page", strconv.Itoa(filter.PerPage))
}
q.Add("filter", filter.Filter)
q.Add("server_version", filter.ServerVersion)
q.Add("build_enterprise_ready", strconv.FormatBool(filter.BuildEnterpriseReady))
q.Add("enterprise_plugins", strconv.FormatBool(filter.EnterprisePlugins))
q.Add("cloud", strconv.FormatBool(filter.Cloud))
q.Add("local_only", strconv.FormatBool(filter.LocalOnly))
q.Add("remote_only", strconv.FormatBool(filter.RemoteOnly))
q.Add("platform", filter.Platform)
q.Add("plugin_id", filter.PluginId)
q.Add("return_all_versions", strconv.FormatBool(filter.ReturnAllVersions))
u.RawQuery = q.Encode()
}
// InstallMarketplacePluginRequest struct describes parameters of the requested plugin.
type InstallMarketplacePluginRequest struct {
Id string `json:"id"`
Version string `json:"version"`
}
// PluginRequestFromReader decodes a json-encoded plugin request from the given io.Reader.
func PluginRequestFromReader(reader io.Reader) (*InstallMarketplacePluginRequest, error) {
var r *InstallMarketplacePluginRequest
err := json.NewDecoder(reader).Decode(&r)
if err != nil {
return nil, err
}
return r, nil
}

56
server/model/member_invite.go Обычный файл
Просмотреть файл

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"net/http"
)
type MemberInvite struct {
Emails []string `json:"emails"`
ChannelIds []string `json:"channelIds,omitempty"`
Message string `json:"message"`
}
func (i *MemberInvite) Auditable() map[string]interface{} {
return map[string]interface{}{
"emails": i.Emails,
"channel_ids": i.ChannelIds,
}
}
// IsValid validates that the invitation info is loaded correctly and with the correct structure
func (i *MemberInvite) IsValid() *AppError {
if len(i.Emails) == 0 {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.emails.app_error", nil, "", http.StatusBadRequest)
}
if len(i.ChannelIds) > 0 {
for _, channel := range i.ChannelIds {
if len(channel) != 26 {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.channel.app_error", nil, "channel="+channel, http.StatusBadRequest)
}
}
}
return nil
}
func (i *MemberInvite) UnmarshalJSON(b []byte) error {
var emails []string
if err := json.Unmarshal(b, &emails); err == nil {
*i = MemberInvite{}
i.Emails = emails
return nil
}
type TempMemberInvite MemberInvite
var o2 TempMemberInvite
if err := json.Unmarshal(b, &o2); err != nil {
return err
}
*i = MemberInvite(o2)
return nil
}

80
server/model/mention_map.go Обычный файл
Просмотреть файл

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"fmt"
"net/url"
)
type UserMentionMap map[string]string
type ChannelMentionMap map[string]string
const (
userMentionsKey = "user_mentions"
userMentionsIdsKey = "user_mentions_ids"
channelMentionsKey = "channel_mentions"
channelMentionsIdsKey = "channel_mentions_ids"
)
func UserMentionMapFromURLValues(values url.Values) (UserMentionMap, error) {
return mentionsFromURLValues(values, userMentionsKey, userMentionsIdsKey)
}
func (m UserMentionMap) ToURLValues() url.Values {
return mentionsToURLValues(m, userMentionsKey, userMentionsIdsKey)
}
func ChannelMentionMapFromURLValues(values url.Values) (ChannelMentionMap, error) {
return mentionsFromURLValues(values, channelMentionsKey, channelMentionsIdsKey)
}
func (m ChannelMentionMap) ToURLValues() url.Values {
return mentionsToURLValues(m, channelMentionsKey, channelMentionsIdsKey)
}
func mentionsFromURLValues(values url.Values, mentionKey, idKey string) (map[string]string, error) {
mentions, mentionsOk := values[mentionKey]
ids, idsOk := values[idKey]
if !mentionsOk && !idsOk {
return map[string]string{}, nil
}
if !mentionsOk {
return nil, fmt.Errorf("%s key not found", mentionKey)
}
if !idsOk {
return nil, fmt.Errorf("%s key not found", idKey)
}
if len(mentions) != len(ids) {
return nil, fmt.Errorf("keys %s and %s have different length", mentionKey, idKey)
}
mentionsMap := make(map[string]string)
for i, mention := range mentions {
id := ids[i]
if oldId, ok := mentionsMap[mention]; ok && oldId != id {
return nil, fmt.Errorf("key %s has two different values: %s and %s", mention, oldId, id)
}
mentionsMap[mention] = id
}
return mentionsMap, nil
}
func mentionsToURLValues(mentions map[string]string, mentionKey, idKey string) url.Values {
values := url.Values{}
for mention, id := range mentions {
values.Add(mentionKey, mention)
values.Add(idKey, id)
}
return values
}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше