Merge branch 'master' into MM-50966-in-product-expansion-backend
Этот коммит содержится в:
72
server/model/access.go
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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 ©
|
||||
}
|
||||
|
||||
// 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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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 ©
|
||||
}
|
||||
|
||||
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
}
|
||||
17
server/model/channel_member_history_result.go
Обычный файл
17
server/model/channel_member_history_result.go
Обычный файл
@@ -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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
8819
server/model/client4.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
106
server/model/client4_test.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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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 ©
|
||||
}
|
||||
|
||||
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
Обычный файл
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
Обычный файл
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
Обычный файл
4117
server/model/config.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
1479
server/model/config_test.go
Обычный файл
1479
server/model/config_test.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
148
server/model/custom_status.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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
4472
server/model/emoji_data.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
9
server/model/emoji_search.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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
}
|
||||
73
server/model/hosted_customer.go
Обычный файл
73
server/model/hosted_customer.go
Обычный файл
@@ -0,0 +1,73 @@
|
||||
// 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"`
|
||||
ExpandRequest *SelfHostedExpansionRequest `json:"expand_request"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type SelfHostedExpansionRequest struct {
|
||||
Seats int `json:"seats"`
|
||||
LicenseId string `json:"license_id"`
|
||||
}
|
||||
203
server/model/incoming_webhook.go
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
}
|
||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user