Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
87
server/boards/services/audit/audit.go
Обычный файл
87
server/boards/services/audit/audit.go
Обычный файл
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
DefMaxQueueSize = 1000
|
||||
|
||||
KeyAPIPath = "api_path"
|
||||
KeyEvent = "event"
|
||||
KeyStatus = "status"
|
||||
KeyUserID = "user_id"
|
||||
KeySessionID = "session_id"
|
||||
KeyClient = "client"
|
||||
KeyIPAddress = "ip_address"
|
||||
KeyClusterID = "cluster_id"
|
||||
KeyTeamID = "team_id"
|
||||
|
||||
Success = "success"
|
||||
Attempt = "attempt"
|
||||
Fail = "fail"
|
||||
)
|
||||
|
||||
var (
|
||||
LevelAuth = mlog.Level{ID: 1000, Name: "auth"}
|
||||
LevelModify = mlog.Level{ID: 1001, Name: "mod"}
|
||||
LevelRead = mlog.Level{ID: 1002, Name: "read"}
|
||||
)
|
||||
|
||||
// Audit provides auditing service.
|
||||
type Audit struct {
|
||||
auditLogger *mlog.Logger
|
||||
}
|
||||
|
||||
// NewAudit creates a new Audit instance which can be configured via `(*Audit).Configure`.
|
||||
func NewAudit(options ...mlog.Option) (*Audit, error) {
|
||||
logger, err := mlog.NewLogger(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Audit{
|
||||
auditLogger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Configure provides a new configuration for this audit service.
|
||||
// Zero or more sources of config can be provided:
|
||||
//
|
||||
// cfgFile - path to file containing JSON
|
||||
// cfgEscaped - JSON string probably from ENV var
|
||||
//
|
||||
// For each case JSON containing log targets is provided. Target name collisions are resolved
|
||||
// using the following precedence:
|
||||
//
|
||||
// cfgFile > cfgEscaped
|
||||
func (a *Audit) Configure(cfgFile string, cfgEscaped string) error {
|
||||
return a.auditLogger.Configure(cfgFile, cfgEscaped, nil)
|
||||
}
|
||||
|
||||
// Shutdown shuts down the audit service after making best efforts to flush any
|
||||
// remaining records.
|
||||
func (a *Audit) Shutdown() error {
|
||||
return a.auditLogger.Shutdown()
|
||||
}
|
||||
|
||||
// LogRecord emits an audit record with complete info.
|
||||
func (a *Audit) LogRecord(level mlog.Level, rec *Record) {
|
||||
fields := make([]mlog.Field, 0, 7+len(rec.Meta))
|
||||
|
||||
fields = append(fields, mlog.String(KeyAPIPath, rec.APIPath))
|
||||
fields = append(fields, mlog.String(KeyEvent, rec.Event))
|
||||
fields = append(fields, mlog.String(KeyStatus, rec.Status))
|
||||
fields = append(fields, mlog.String(KeyUserID, rec.UserID))
|
||||
fields = append(fields, mlog.String(KeySessionID, rec.SessionID))
|
||||
fields = append(fields, mlog.String(KeyClient, rec.Client))
|
||||
fields = append(fields, mlog.String(KeyIPAddress, rec.IPAddress))
|
||||
|
||||
for _, meta := range rec.Meta {
|
||||
fields = append(fields, mlog.Any(meta.K, meta.V))
|
||||
}
|
||||
|
||||
a.auditLogger.Log(level, "audit "+rec.Event, fields...)
|
||||
}
|
||||
69
server/boards/services/audit/record.go
Обычный файл
69
server/boards/services/audit/record.go
Обычный файл
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package audit
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
// Meta represents metadata that can be added to a audit record as name/value pairs.
|
||||
type Meta struct {
|
||||
K string
|
||||
V interface{}
|
||||
}
|
||||
|
||||
// FuncMetaTypeConv defines a function that can convert meta data types into something
|
||||
// that serializes well for audit records.
|
||||
type FuncMetaTypeConv func(val interface{}) (newVal interface{}, converted bool)
|
||||
|
||||
// Record provides a consistent set of fields used for all audit logging.
|
||||
type Record struct {
|
||||
APIPath string
|
||||
Event string
|
||||
Status string
|
||||
UserID string
|
||||
SessionID string
|
||||
Client string
|
||||
IPAddress string
|
||||
Meta []Meta
|
||||
metaConv []FuncMetaTypeConv
|
||||
}
|
||||
|
||||
// Success marks the audit record status as successful.
|
||||
func (rec *Record) Success() {
|
||||
rec.Status = Success
|
||||
}
|
||||
|
||||
// Success marks the audit record status as failed.
|
||||
func (rec *Record) Fail() {
|
||||
rec.Status = Fail
|
||||
}
|
||||
|
||||
// AddMeta adds a single name/value pair to this audit record's metadata.
|
||||
func (rec *Record) AddMeta(name string, val interface{}) {
|
||||
if rec.Meta == nil {
|
||||
rec.Meta = []Meta{}
|
||||
}
|
||||
|
||||
// possibly convert val to something better suited for serializing
|
||||
// via zero or more conversion functions.
|
||||
for _, conv := range rec.metaConv {
|
||||
converted, wasConverted := conv(val)
|
||||
if wasConverted {
|
||||
val = converted
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
lc, ok := val.(mlog.LogCloner)
|
||||
if ok {
|
||||
val = lc.LogClone()
|
||||
}
|
||||
|
||||
rec.Meta = append(rec.Meta, Meta{K: name, V: val})
|
||||
}
|
||||
|
||||
// AddMetaTypeConverter adds a function capable of converting meta field types
|
||||
// into something more suitable for serialization.
|
||||
func (rec *Record) AddMetaTypeConverter(f FuncMetaTypeConv) {
|
||||
rec.metaConv = append(rec.metaConv, f)
|
||||
}
|
||||
83
server/boards/services/audit/record_test.go
Обычный файл
83
server/boards/services/audit/record_test.go
Обычный файл
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type bloated struct {
|
||||
fld1 string
|
||||
fld2 string
|
||||
fld3 string
|
||||
fld4 string
|
||||
}
|
||||
|
||||
type wilted struct {
|
||||
wilt1 string
|
||||
}
|
||||
|
||||
func conv(val interface{}) (interface{}, bool) {
|
||||
if b, ok := val.(*bloated); ok {
|
||||
return &wilted{wilt1: b.fld1}, true
|
||||
}
|
||||
return val, false
|
||||
}
|
||||
|
||||
func TestRecord_AddMeta(t *testing.T) {
|
||||
type fields struct {
|
||||
metaConv []FuncMetaTypeConv
|
||||
}
|
||||
type args struct {
|
||||
name string
|
||||
val interface{}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantWilt bool
|
||||
wantVal string
|
||||
}{
|
||||
{name: "no converter", wantWilt: false, wantVal: "ok", fields: fields{}, args: args{name: "prop", val: "ok"}},
|
||||
{name: "don't convert", wantWilt: false, wantVal: "ok", fields: fields{metaConv: []FuncMetaTypeConv{conv}}, args: args{name: "prop", val: "ok"}},
|
||||
{name: "convert", wantWilt: true, wantVal: "1", fields: fields{metaConv: []FuncMetaTypeConv{conv}}, args: args{name: "prop", val: &bloated{
|
||||
fld1: "1", fld2: "2", fld3: "3", fld4: "4"}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := &Record{
|
||||
metaConv: tt.fields.metaConv,
|
||||
}
|
||||
rec.AddMeta(tt.args.name, tt.args.val)
|
||||
|
||||
// fetch the prop store in auditRecord meta data
|
||||
var ok bool
|
||||
var got interface{}
|
||||
for _, meta := range rec.Meta {
|
||||
if meta.K == "prop" {
|
||||
ok = true
|
||||
got = meta.V
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, ok)
|
||||
|
||||
// check if conversion was expected
|
||||
val, ok := got.(*wilted)
|
||||
require.Equal(t, tt.wantWilt, ok)
|
||||
|
||||
if ok {
|
||||
// if converted to wilt then make sure field was copied
|
||||
require.Equal(t, tt.wantVal, val.wilt1)
|
||||
} else {
|
||||
// if not converted, make sure val is unchanged
|
||||
require.Equal(t, tt.wantVal, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
16
server/boards/services/auth/email.go
Обычный файл
16
server/boards/services/auth/email.go
Обычный файл
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import "regexp"
|
||||
|
||||
var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
||||
|
||||
// IsEmailValid checks if the email provided passes the required structure and length.
|
||||
func IsEmailValid(e string) bool {
|
||||
if len(e) < 3 || len(e) > 254 {
|
||||
return false
|
||||
}
|
||||
return emailRegex.MatchString(e)
|
||||
}
|
||||
109
server/boards/services/auth/password.go
Обычный файл
109
server/boards/services/auth/password.go
Обычный файл
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
PasswordMaximumLength = 64
|
||||
PasswordSpecialChars = "!\"\\#$%&'()*+,-./:;<=>?@[]^_`|~" //nolint:gosec
|
||||
PasswordNumbers = "0123456789"
|
||||
PasswordUpperCaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
PasswordLowerCaseLetters = "abcdefghijklmnopqrstuvwxyz"
|
||||
PasswordAllChars = PasswordSpecialChars + PasswordNumbers + PasswordUpperCaseLetters + PasswordLowerCaseLetters
|
||||
|
||||
InvalidLowercasePassword = "lowercase"
|
||||
InvalidMinLengthPassword = "min-length"
|
||||
InvalidMaxLengthPassword = "max-length"
|
||||
InvalidNumberPassword = "number"
|
||||
InvalidUppercasePassword = "uppercase"
|
||||
InvalidSymbolPassword = "symbol"
|
||||
)
|
||||
|
||||
var PasswordHashStrength = 10
|
||||
|
||||
// HashPassword generates a hash using the bcrypt.GenerateFromPassword.
|
||||
func HashPassword(password string) string {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), PasswordHashStrength)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return string(hash)
|
||||
}
|
||||
|
||||
// ComparePassword compares the hash.
|
||||
func ComparePassword(hash, password string) bool {
|
||||
if password == "" || hash == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type InvalidPasswordError struct {
|
||||
FailingCriterias []string
|
||||
}
|
||||
|
||||
func (ipe *InvalidPasswordError) Error() string {
|
||||
return fmt.Sprintf("invalid password, failing criteria: %s", strings.Join(ipe.FailingCriterias, ", "))
|
||||
}
|
||||
|
||||
type PasswordSettings struct {
|
||||
MinimumLength int
|
||||
Lowercase bool
|
||||
Number bool
|
||||
Uppercase bool
|
||||
Symbol bool
|
||||
}
|
||||
|
||||
func IsPasswordValid(password string, settings PasswordSettings) error {
|
||||
err := &InvalidPasswordError{
|
||||
FailingCriterias: []string{},
|
||||
}
|
||||
|
||||
if len(password) < settings.MinimumLength {
|
||||
err.FailingCriterias = append(err.FailingCriterias, InvalidMinLengthPassword)
|
||||
}
|
||||
|
||||
if len(password) > PasswordMaximumLength {
|
||||
err.FailingCriterias = append(err.FailingCriterias, InvalidMaxLengthPassword)
|
||||
}
|
||||
|
||||
if settings.Lowercase {
|
||||
if !strings.ContainsAny(password, PasswordLowerCaseLetters) {
|
||||
err.FailingCriterias = append(err.FailingCriterias, InvalidLowercasePassword)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.Uppercase {
|
||||
if !strings.ContainsAny(password, PasswordUpperCaseLetters) {
|
||||
err.FailingCriterias = append(err.FailingCriterias, InvalidUppercasePassword)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.Number {
|
||||
if !strings.ContainsAny(password, PasswordNumbers) {
|
||||
err.FailingCriterias = append(err.FailingCriterias, InvalidNumberPassword)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.Symbol {
|
||||
if !strings.ContainsAny(password, PasswordSpecialChars) {
|
||||
err.FailingCriterias = append(err.FailingCriterias, InvalidSymbolPassword)
|
||||
}
|
||||
}
|
||||
|
||||
if len(err.FailingCriterias) > 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
148
server/boards/services/auth/password_test.go
Обычный файл
148
server/boards/services/auth/password_test.go
Обычный файл
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPasswordHash(t *testing.T) {
|
||||
hash := HashPassword("Test")
|
||||
|
||||
assert.True(t, ComparePassword(hash, "Test"), "Passwords don't match")
|
||||
assert.False(t, ComparePassword(hash, "Test2"), "Passwords should not have matched")
|
||||
}
|
||||
|
||||
func TestIsPasswordValidWithSettings(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
Password string
|
||||
Settings PasswordSettings
|
||||
ExpectedFailingCriterias []string
|
||||
}{
|
||||
"Short": {
|
||||
Password: strings.Repeat("x", 3),
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Lowercase: false,
|
||||
Uppercase: false,
|
||||
Number: false,
|
||||
Symbol: false,
|
||||
},
|
||||
},
|
||||
"Long": {
|
||||
Password: strings.Repeat("x", PasswordMaximumLength),
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Lowercase: false,
|
||||
Uppercase: false,
|
||||
Number: false,
|
||||
Symbol: false,
|
||||
},
|
||||
},
|
||||
"TooShort": {
|
||||
Password: strings.Repeat("x", 2),
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Lowercase: false,
|
||||
Uppercase: false,
|
||||
Number: false,
|
||||
Symbol: false,
|
||||
},
|
||||
ExpectedFailingCriterias: []string{"min-length"},
|
||||
},
|
||||
"TooLong": {
|
||||
Password: strings.Repeat("x", PasswordMaximumLength+1),
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Lowercase: false,
|
||||
Uppercase: false,
|
||||
Number: false,
|
||||
Symbol: false,
|
||||
},
|
||||
ExpectedFailingCriterias: []string{"max-length"},
|
||||
},
|
||||
"MissingLower": {
|
||||
Password: "AAAAAAAAAAASD123!@#",
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Lowercase: true,
|
||||
Uppercase: false,
|
||||
Number: false,
|
||||
Symbol: false,
|
||||
},
|
||||
ExpectedFailingCriterias: []string{"lowercase"},
|
||||
},
|
||||
"MissingUpper": {
|
||||
Password: "aaaaaaaaaaaaasd123!@#",
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Uppercase: true,
|
||||
Lowercase: false,
|
||||
Number: false,
|
||||
Symbol: false,
|
||||
},
|
||||
ExpectedFailingCriterias: []string{"uppercase"},
|
||||
},
|
||||
"MissingNumber": {
|
||||
Password: "asasdasdsadASD!@#",
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Number: true,
|
||||
Lowercase: false,
|
||||
Uppercase: false,
|
||||
Symbol: false,
|
||||
},
|
||||
ExpectedFailingCriterias: []string{"number"},
|
||||
},
|
||||
"MissingSymbol": {
|
||||
Password: "asdasdasdasdasdASD123",
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Symbol: true,
|
||||
Lowercase: false,
|
||||
Uppercase: false,
|
||||
Number: false,
|
||||
},
|
||||
ExpectedFailingCriterias: []string{"symbol"},
|
||||
},
|
||||
"MissingMultiple": {
|
||||
Password: "asdasdasdasdasdasd",
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Lowercase: true,
|
||||
Uppercase: true,
|
||||
Number: true,
|
||||
Symbol: true,
|
||||
},
|
||||
ExpectedFailingCriterias: []string{"uppercase", "number", "symbol"},
|
||||
},
|
||||
"Everything": {
|
||||
Password: "asdASD!@#123",
|
||||
Settings: PasswordSettings{
|
||||
MinimumLength: 3,
|
||||
Lowercase: true,
|
||||
Uppercase: true,
|
||||
Number: true,
|
||||
Symbol: true,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := IsPasswordValid(tc.Password, tc.Settings)
|
||||
if len(tc.ExpectedFailingCriterias) == 0 {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
var errFC *InvalidPasswordError
|
||||
if assert.ErrorAs(t, err, &errFC) {
|
||||
assert.Equal(t, tc.ExpectedFailingCriterias, errFC.FailingCriterias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
67
server/boards/services/auth/request_parser.go
Обычный файл
67
server/boards/services/auth/request_parser.go
Обычный файл
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderToken = "token"
|
||||
HeaderAuth = "Authorization"
|
||||
HeaderBearer = "BEARER"
|
||||
SessionCookieToken = "FOCALBOARDAUTHTOKEN"
|
||||
)
|
||||
|
||||
type TokenLocation int
|
||||
|
||||
const (
|
||||
TokenLocationNotFound TokenLocation = iota
|
||||
TokenLocationHeader
|
||||
TokenLocationCookie
|
||||
TokenLocationQueryString
|
||||
)
|
||||
|
||||
func (tl TokenLocation) String() string {
|
||||
switch tl {
|
||||
case TokenLocationNotFound:
|
||||
return "Not Found"
|
||||
case TokenLocationHeader:
|
||||
return "Header"
|
||||
case TokenLocationCookie:
|
||||
return "Cookie"
|
||||
case TokenLocationQueryString:
|
||||
return "QueryString"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func ParseAuthTokenFromRequest(r *http.Request) (string, TokenLocation) {
|
||||
authHeader := r.Header.Get(HeaderAuth)
|
||||
|
||||
// Attempt to parse the token from the cookie
|
||||
if cookie, err := r.Cookie(SessionCookieToken); err == nil {
|
||||
return cookie.Value, TokenLocationCookie
|
||||
}
|
||||
|
||||
// Parse the token from the header
|
||||
if len(authHeader) > 6 && strings.ToUpper(authHeader[0:6]) == HeaderBearer {
|
||||
// Default session token
|
||||
return authHeader[7:], TokenLocationHeader
|
||||
}
|
||||
|
||||
if len(authHeader) > 5 && strings.ToLower(authHeader[0:5]) == HeaderToken {
|
||||
// OAuth token
|
||||
return authHeader[6:], TokenLocationHeader
|
||||
}
|
||||
|
||||
// Attempt to parse token out of the query string
|
||||
if token := r.URL.Query().Get("access_token"); token != "" {
|
||||
return token, TokenLocationQueryString
|
||||
}
|
||||
|
||||
return "", TokenLocationNotFound
|
||||
}
|
||||
51
server/boards/services/auth/request_parser_test.go
Обычный файл
51
server/boards/services/auth/request_parser_test.go
Обычный файл
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseAuthTokenFromRequest(t *testing.T) {
|
||||
cases := []struct {
|
||||
header string
|
||||
cookie string
|
||||
query string
|
||||
expectedToken string
|
||||
expectedLocation TokenLocation
|
||||
}{
|
||||
{"", "", "", "", TokenLocationNotFound},
|
||||
{"token mytoken", "", "", "mytoken", TokenLocationHeader},
|
||||
{"BEARER mytoken", "", "", "mytoken", TokenLocationHeader},
|
||||
{"", "mytoken", "", "mytoken", TokenLocationCookie},
|
||||
{"", "", "mytoken", "mytoken", TokenLocationQueryString},
|
||||
}
|
||||
|
||||
for testnum, tc := range cases {
|
||||
pathname := "/test/here"
|
||||
if tc.query != "" {
|
||||
pathname += "?access_token=" + tc.query
|
||||
}
|
||||
req := httptest.NewRequest("GET", pathname, nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Add(HeaderAuth, tc.header)
|
||||
}
|
||||
if tc.cookie != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "FOCALBOARDAUTHTOKEN",
|
||||
Value: tc.cookie,
|
||||
})
|
||||
}
|
||||
|
||||
token, location := ParseAuthTokenFromRequest(req)
|
||||
|
||||
require.Equal(t, tc.expectedToken, token, "Wrong token on test "+strconv.Itoa(testnum))
|
||||
require.Equal(t, tc.expectedLocation, location, "Wrong location on test "+strconv.Itoa(testnum))
|
||||
}
|
||||
}
|
||||
135
server/boards/services/config/config.go
Обычный файл
135
server/boards/services/config/config.go
Обычный файл
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultServerRoot = "http://localhost:8000"
|
||||
DefaultPort = 8000
|
||||
)
|
||||
|
||||
type AmazonS3Config struct {
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
Bucket string
|
||||
PathPrefix string
|
||||
Region string
|
||||
Endpoint string
|
||||
SSL bool
|
||||
SignV2 bool
|
||||
SSE bool
|
||||
Trace bool
|
||||
Timeout int64
|
||||
}
|
||||
|
||||
// Configuration is the app configuration stored in a json file.
|
||||
type Configuration struct {
|
||||
ServerRoot string `json:"serverRoot" mapstructure:"serverRoot"`
|
||||
Port int `json:"port" mapstructure:"port"`
|
||||
DBType string `json:"dbtype" mapstructure:"dbtype"`
|
||||
DBConfigString string `json:"dbconfig" mapstructure:"dbconfig"`
|
||||
DBTablePrefix string `json:"dbtableprefix" mapstructure:"dbtableprefix"`
|
||||
UseSSL bool `json:"useSSL" mapstructure:"useSSL"`
|
||||
SecureCookie bool `json:"secureCookie" mapstructure:"secureCookie"`
|
||||
WebPath string `json:"webpath" mapstructure:"webpath"`
|
||||
FilesDriver string `json:"filesdriver" mapstructure:"filesdriver"`
|
||||
FilesS3Config AmazonS3Config `json:"filess3config" mapstructure:"filess3config"`
|
||||
FilesPath string `json:"filespath" mapstructure:"filespath"`
|
||||
MaxFileSize int64 `json:"maxfilesize" mapstructure:"maxfilesize"`
|
||||
Telemetry bool `json:"telemetry" mapstructure:"telemetry"`
|
||||
TelemetryID string `json:"telemetryid" mapstructure:"telemetryid"`
|
||||
PrometheusAddress string `json:"prometheusaddress" mapstructure:"prometheusaddress"`
|
||||
WebhookUpdate []string `json:"webhook_update" mapstructure:"webhook_update"`
|
||||
Secret string `json:"secret" mapstructure:"secret"`
|
||||
SessionExpireTime int64 `json:"session_expire_time" mapstructure:"session_expire_time"`
|
||||
SessionRefreshTime int64 `json:"session_refresh_time" mapstructure:"session_refresh_time"`
|
||||
LocalOnly bool `json:"localonly" mapstructure:"localonly"`
|
||||
EnableLocalMode bool `json:"enableLocalMode" mapstructure:"enableLocalMode"`
|
||||
LocalModeSocketLocation string `json:"localModeSocketLocation" mapstructure:"localModeSocketLocation"`
|
||||
EnablePublicSharedBoards bool `json:"enablePublicSharedBoards" mapstructure:"enablePublicSharedBoards"`
|
||||
FeatureFlags map[string]string `json:"featureFlags" mapstructure:"featureFlags"`
|
||||
EnableDataRetention bool `json:"enable_data_retention" mapstructure:"enable_data_retention"`
|
||||
DataRetentionDays int `json:"data_retention_days" mapstructure:"data_retention_days"`
|
||||
TeammateNameDisplay string `json:"teammate_name_display" mapstructure:"teammateNameDisplay"`
|
||||
ShowEmailAddress bool `json:"show_email_address" mapstructure:"showEmailAddress"`
|
||||
ShowFullName bool `json:"show_full_name" mapstructure:"showFullName"`
|
||||
|
||||
AuthMode string `json:"authMode" mapstructure:"authMode"`
|
||||
|
||||
LoggingCfgFile string `json:"logging_cfg_file" mapstructure:"logging_cfg_file"`
|
||||
LoggingCfgJSON string `json:"logging_cfg_json" mapstructure:"logging_cfg_json"`
|
||||
|
||||
AuditCfgFile string `json:"audit_cfg_file" mapstructure:"audit_cfg_file"`
|
||||
AuditCfgJSON string `json:"audit_cfg_json" mapstructure:"audit_cfg_json"`
|
||||
|
||||
NotifyFreqCardSeconds int `json:"notify_freq_card_seconds" mapstructure:"notify_freq_card_seconds"`
|
||||
NotifyFreqBoardSeconds int `json:"notify_freq_board_seconds" mapstructure:"notify_freq_board_seconds"`
|
||||
}
|
||||
|
||||
// ReadConfigFile read the configuration from the filesystem.
|
||||
func ReadConfigFile(configFilePath string) (*Configuration, error) {
|
||||
if configFilePath == "" {
|
||||
viper.SetConfigFile("./config.json")
|
||||
} else {
|
||||
viper.SetConfigFile(configFilePath)
|
||||
}
|
||||
|
||||
viper.SetEnvPrefix("focalboard")
|
||||
viper.AutomaticEnv() // read config values from env like FOCALBOARD_SERVERROOT=...
|
||||
viper.SetDefault("ServerRoot", DefaultServerRoot)
|
||||
viper.SetDefault("Port", DefaultPort)
|
||||
viper.SetDefault("DBType", "postgres")
|
||||
viper.SetDefault("DBConfigString", "postgres://mmuser:mostest@localhost/mattermost_test?sslmode=disable\u0026connect_timeout=10\u0026binary_parameters=yes")
|
||||
viper.SetDefault("DBTablePrefix", "")
|
||||
viper.SetDefault("SecureCookie", false)
|
||||
viper.SetDefault("WebPath", "./pack")
|
||||
viper.SetDefault("FilesPath", "./files")
|
||||
viper.SetDefault("FilesDriver", "local")
|
||||
viper.SetDefault("Telemetry", true)
|
||||
viper.SetDefault("TelemetryID", "")
|
||||
viper.SetDefault("WebhookUpdate", nil)
|
||||
viper.SetDefault("SessionExpireTime", 60*60*24*30) // 30 days session lifetime
|
||||
viper.SetDefault("SessionRefreshTime", 60*60*5) // 5 minutes session refresh
|
||||
viper.SetDefault("LocalOnly", false)
|
||||
viper.SetDefault("EnableLocalMode", false)
|
||||
viper.SetDefault("LocalModeSocketLocation", "/var/tmp/focalboard_local.socket")
|
||||
viper.SetDefault("EnablePublicSharedBoards", false)
|
||||
viper.SetDefault("FeatureFlags", map[string]string{})
|
||||
viper.SetDefault("AuthMode", "native")
|
||||
viper.SetDefault("NotifyFreqCardSeconds", 120) // 2 minutes after last card edit
|
||||
viper.SetDefault("NotifyFreqBoardSeconds", 86400) // 1 day after last card edit
|
||||
viper.SetDefault("EnableDataRetention", false)
|
||||
viper.SetDefault("DataRetentionDays", 365) // 1 year is default
|
||||
viper.SetDefault("PrometheusAddress", "")
|
||||
viper.SetDefault("TeammateNameDisplay", "username")
|
||||
viper.SetDefault("ShowEmailAddress", false)
|
||||
viper.SetDefault("ShowFullName", false)
|
||||
|
||||
err := viper.ReadInConfig() // Find and read the config file
|
||||
if err != nil { // Handle errors reading the config file
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configuration := Configuration{}
|
||||
|
||||
err = viper.Unmarshal(&configuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("readConfigFile")
|
||||
log.Printf("%+v", removeSecurityData(configuration))
|
||||
|
||||
return &configuration, nil
|
||||
}
|
||||
|
||||
func removeSecurityData(config Configuration) Configuration {
|
||||
clean := config
|
||||
return clean
|
||||
}
|
||||
236
server/boards/services/metrics/metrics.go
Обычный файл
236
server/boards/services/metrics/metrics.go
Обычный файл
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
)
|
||||
|
||||
const (
|
||||
MetricsNamespace = "focalboard"
|
||||
MetricsSubsystemBlocks = "blocks"
|
||||
MetricsSubsystemBoards = "boards"
|
||||
MetricsSubsystemTeams = "teams"
|
||||
MetricsSubsystemSystem = "system"
|
||||
|
||||
MetricsCloudInstallationLabel = "installationId"
|
||||
)
|
||||
|
||||
type InstanceInfo struct {
|
||||
Version string
|
||||
BuildNum string
|
||||
Edition string
|
||||
InstallationID string
|
||||
}
|
||||
|
||||
// Metrics used to instrumentate metrics in prometheus.
|
||||
type Metrics struct {
|
||||
registry *prometheus.Registry
|
||||
|
||||
instance *prometheus.GaugeVec
|
||||
startTime prometheus.Gauge
|
||||
|
||||
loginCount prometheus.Counter
|
||||
logoutCount prometheus.Counter
|
||||
loginFailCount prometheus.Counter
|
||||
|
||||
blocksInsertedCount prometheus.Counter
|
||||
blocksPatchedCount prometheus.Counter
|
||||
blocksDeletedCount prometheus.Counter
|
||||
|
||||
blockCount *prometheus.GaugeVec
|
||||
boardCount prometheus.Gauge
|
||||
teamCount prometheus.Gauge
|
||||
|
||||
blockLastActivity prometheus.Gauge
|
||||
}
|
||||
|
||||
// NewMetrics Factory method to create a new metrics collector.
|
||||
func NewMetrics(info InstanceInfo) *Metrics {
|
||||
m := &Metrics{}
|
||||
|
||||
m.registry = prometheus.NewRegistry()
|
||||
options := collectors.ProcessCollectorOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
}
|
||||
m.registry.MustRegister(collectors.NewProcessCollector(options))
|
||||
m.registry.MustRegister(collectors.NewGoCollector())
|
||||
|
||||
additionalLabels := map[string]string{}
|
||||
if info.InstallationID != "" {
|
||||
additionalLabels[MetricsCloudInstallationLabel] = os.Getenv("MM_CLOUD_INSTALLATION_ID")
|
||||
}
|
||||
|
||||
m.loginCount = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemSystem,
|
||||
Name: "login_total",
|
||||
Help: "Total number of logins.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.loginCount)
|
||||
|
||||
m.logoutCount = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemSystem,
|
||||
Name: "logout_total",
|
||||
Help: "Total number of logouts.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.logoutCount)
|
||||
|
||||
m.loginFailCount = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemSystem,
|
||||
Name: "login_fail_total",
|
||||
Help: "Total number of failed logins.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.loginFailCount)
|
||||
|
||||
m.instance = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemSystem,
|
||||
Name: "focalboard_instance_info",
|
||||
Help: "Instance information for Focalboard.",
|
||||
ConstLabels: additionalLabels,
|
||||
}, []string{"Version", "BuildNum", "Edition"})
|
||||
m.registry.MustRegister(m.instance)
|
||||
m.instance.WithLabelValues(info.Version, info.BuildNum, info.Edition).Set(1)
|
||||
|
||||
m.startTime = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemSystem,
|
||||
Name: "server_start_time",
|
||||
Help: "The time the server started.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.startTime.SetToCurrentTime()
|
||||
m.registry.MustRegister(m.startTime)
|
||||
|
||||
m.blocksInsertedCount = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemBlocks,
|
||||
Name: "blocks_inserted_total",
|
||||
Help: "Total number of blocks inserted.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.blocksInsertedCount)
|
||||
|
||||
m.blocksPatchedCount = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemBlocks,
|
||||
Name: "blocks_patched_total",
|
||||
Help: "Total number of blocks patched.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.blocksPatchedCount)
|
||||
|
||||
m.blocksDeletedCount = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemBlocks,
|
||||
Name: "blocks_deleted_total",
|
||||
Help: "Total number of blocks deleted.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.blocksDeletedCount)
|
||||
|
||||
m.blockCount = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemBlocks,
|
||||
Name: "blocks_total",
|
||||
Help: "Total number of blocks.",
|
||||
ConstLabels: additionalLabels,
|
||||
}, []string{"BlockType"})
|
||||
m.registry.MustRegister(m.blockCount)
|
||||
|
||||
m.boardCount = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemBoards,
|
||||
Name: "boards_total",
|
||||
Help: "Total number of boards.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.boardCount)
|
||||
|
||||
m.teamCount = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemTeams,
|
||||
Name: "teams_total",
|
||||
Help: "Total number of teams.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.teamCount)
|
||||
|
||||
m.blockLastActivity = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
Subsystem: MetricsSubsystemBlocks,
|
||||
Name: "blocks_last_activity",
|
||||
Help: "Time of last block insert, update, delete.",
|
||||
ConstLabels: additionalLabels,
|
||||
})
|
||||
m.registry.MustRegister(m.blockLastActivity)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Metrics) IncrementLoginCount(num int) {
|
||||
if m != nil {
|
||||
m.loginCount.Add(float64(num))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) IncrementLogoutCount(num int) {
|
||||
if m != nil {
|
||||
m.logoutCount.Add(float64(num))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) IncrementLoginFailCount(num int) {
|
||||
if m != nil {
|
||||
m.loginFailCount.Add(float64(num))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) IncrementBlocksInserted(num int) {
|
||||
if m != nil {
|
||||
m.blocksInsertedCount.Add(float64(num))
|
||||
m.blockLastActivity.SetToCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) IncrementBlocksPatched(num int) {
|
||||
if m != nil {
|
||||
m.blocksPatchedCount.Add(float64(num))
|
||||
m.blockLastActivity.SetToCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) IncrementBlocksDeleted(num int) {
|
||||
if m != nil {
|
||||
m.blocksDeletedCount.Add(float64(num))
|
||||
m.blockLastActivity.SetToCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) ObserveBlockCount(blockType string, count int64) {
|
||||
if m != nil {
|
||||
m.blockCount.WithLabelValues(blockType).Set(float64(count))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) ObserveBoardCount(count int64) {
|
||||
if m != nil {
|
||||
m.boardCount.Set(float64(count))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) ObserveTeamCount(count int64) {
|
||||
if m != nil {
|
||||
m.teamCount.Set(float64(count))
|
||||
}
|
||||
}
|
||||
40
server/boards/services/metrics/service.go
Обычный файл
40
server/boards/services/metrics/service.go
Обычный файл
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// Service prometheus to run the server.
|
||||
type Service struct {
|
||||
*http.Server
|
||||
}
|
||||
|
||||
// NewMetricsServer factory method to create a new prometheus server.
|
||||
func NewMetricsServer(address string, metricsService *Metrics, logger mlog.LoggerIFace) *Service {
|
||||
return &Service{
|
||||
&http.Server{ //nolint:gosec
|
||||
Addr: address,
|
||||
Handler: promhttp.HandlerFor(metricsService.registry, promhttp.HandlerOpts{
|
||||
ErrorLog: logger.StdLogger(mlog.LvlError),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run will start the prometheus server.
|
||||
func (h *Service) Run() error {
|
||||
return errors.Wrap(h.Server.ListenAndServe(), "prometheus ListenAndServe")
|
||||
}
|
||||
|
||||
// Shutdown will shutdown the prometheus server.
|
||||
func (h *Service) Shutdown() error {
|
||||
return errors.Wrap(h.Server.Close(), "prometheus Close")
|
||||
}
|
||||
59
server/boards/services/notify/notifylogger/logger_backend.go
Обычный файл
59
server/boards/services/notify/notifylogger/logger_backend.go
Обычный файл
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifylogger
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
backendName = "notifyLogger"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
logger mlog.LoggerIFace
|
||||
level mlog.Level
|
||||
}
|
||||
|
||||
func New(logger mlog.LoggerIFace, level mlog.Level) *Backend {
|
||||
return &Backend{
|
||||
logger: logger,
|
||||
level: level,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) ShutDown() error {
|
||||
_ = b.logger.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error {
|
||||
var board string
|
||||
var card string
|
||||
|
||||
if evt.Board != nil {
|
||||
board = evt.Board.Title
|
||||
}
|
||||
if evt.Card != nil {
|
||||
card = evt.Card.Title
|
||||
}
|
||||
|
||||
b.logger.Log(b.level, "Block change event",
|
||||
mlog.String("action", string(evt.Action)),
|
||||
mlog.String("board", board),
|
||||
mlog.String("card", card),
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) Name() string {
|
||||
return backendName
|
||||
}
|
||||
10
server/boards/services/notify/notifymentions/app_api.go
Обычный файл
10
server/boards/services/notify/notifymentions/app_api.go
Обычный файл
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
package notifymentions
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
type AppAPI interface {
|
||||
GetMemberForBoard(boardID, userID string) (*model.BoardMember, error)
|
||||
AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error)
|
||||
}
|
||||
18
server/boards/services/notify/notifymentions/delivery.go
Обычный файл
18
server/boards/services/notify/notifymentions/delivery.go
Обычный файл
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// MentionDelivery provides an interface for delivering @mention notifications to other systems, such as
|
||||
// channels server via plugin API.
|
||||
// On success the user id of the user mentioned is returned.
|
||||
type MentionDelivery interface {
|
||||
MentionDeliver(mentionedUser *mm_model.User, extract string, evt notify.BlockChangeEvent) (string, error)
|
||||
UserByUsername(mentionUsername string) (*mm_model.User, error)
|
||||
}
|
||||
98
server/boards/services/notify/notifymentions/extract.go
Обычный файл
98
server/boards/services/notify/notifymentions/extract.go
Обычный файл
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
defPrefixLines = 2
|
||||
defPrefixMaxChars = 100
|
||||
defSuffixLines = 2
|
||||
defSuffixMaxChars = 100
|
||||
)
|
||||
|
||||
type limits struct {
|
||||
prefixLines int
|
||||
prefixMaxChars int
|
||||
suffixLines int
|
||||
suffixMaxChars int
|
||||
}
|
||||
|
||||
func newLimits() limits {
|
||||
return limits{
|
||||
prefixLines: defPrefixLines,
|
||||
prefixMaxChars: defPrefixMaxChars,
|
||||
suffixLines: defSuffixLines,
|
||||
suffixMaxChars: defSuffixMaxChars,
|
||||
}
|
||||
}
|
||||
|
||||
// extractText returns all or a subset of the input string, such that
|
||||
// no more than `prefixLines` lines preceding the mention and `suffixLines`
|
||||
// lines after the mention are returned, and no more than approx
|
||||
// prefixMaxChars+suffixMaxChars are returned.
|
||||
func extractText(s string, mention string, limits limits) string {
|
||||
if !strings.HasPrefix(mention, "@") {
|
||||
mention = "@" + mention
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
|
||||
// find first line with mention
|
||||
found := -1
|
||||
for i, l := range lines {
|
||||
if strings.Contains(l, mention) {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
prefix := safeConcat(lines, found-limits.prefixLines, found)
|
||||
suffix := safeConcat(lines, found+1, found+limits.suffixLines+1)
|
||||
combined := strings.TrimSpace(strings.Join([]string{prefix, lines[found], suffix}, "\n"))
|
||||
|
||||
// find mention position within
|
||||
pos := strings.Index(combined, mention)
|
||||
pos = max(pos, 0)
|
||||
|
||||
return safeSubstr(combined, pos-limits.prefixMaxChars, pos+limits.suffixMaxChars)
|
||||
}
|
||||
|
||||
func safeConcat(lines []string, start int, end int) string {
|
||||
count := len(lines)
|
||||
start = min(max(start, 0), count)
|
||||
end = min(max(end, start), count)
|
||||
|
||||
var sb strings.Builder
|
||||
for i := start; i < end; i++ {
|
||||
if lines[i] != "" {
|
||||
sb.WriteString(lines[i])
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func safeSubstr(s string, start int, end int) string {
|
||||
count := len(s)
|
||||
start = min(max(start, 0), count)
|
||||
end = min(max(end, start), count)
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func min(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func max(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
115
server/boards/services/notify/notifymentions/extract_test.go
Обычный файл
115
server/boards/services/notify/notifymentions/extract_test.go
Обычный файл
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
s0 = "Zero is in the mind @billy."
|
||||
s1 = "This is line 1."
|
||||
s2 = "Line two is right here."
|
||||
s3 = "Three is the line I am."
|
||||
s4 = "'Four score and seven years...', said @lincoln."
|
||||
s5 = "Fast Five was arguably the best F&F film."
|
||||
s6 = "Big Hero 6 may have an inflated sense of self."
|
||||
s7 = "The seventh sign, @sarah, will be a failed unit test."
|
||||
)
|
||||
|
||||
var (
|
||||
all = []string{s0, s1, s2, s3, s4, s5, s6, s7}
|
||||
allConcat = strings.Join(all, "\n")
|
||||
|
||||
extractLimits = limits{
|
||||
prefixLines: 2,
|
||||
prefixMaxChars: 100,
|
||||
suffixLines: 2,
|
||||
suffixMaxChars: 100,
|
||||
}
|
||||
)
|
||||
|
||||
func join(s ...string) string {
|
||||
return strings.Join(s, "\n")
|
||||
}
|
||||
|
||||
func Test_extractText(t *testing.T) {
|
||||
type args struct {
|
||||
s string
|
||||
mention string
|
||||
limits limits
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{name: "good", want: join(s2, s3, s4, s5, s6), args: args{mention: "@lincoln", limits: extractLimits, s: allConcat}},
|
||||
{name: "not found", want: "", args: args{mention: "@bogus", limits: extractLimits, s: allConcat}},
|
||||
{name: "one line", want: join(s4), args: args{mention: "@lincoln", limits: extractLimits, s: s4}},
|
||||
{name: "two lines", want: join(s4, s5), args: args{mention: "@lincoln", limits: extractLimits, s: join(s4, s5)}},
|
||||
{name: "zero lines", want: "", args: args{mention: "@lincoln", limits: extractLimits, s: ""}},
|
||||
{name: "first line mention", want: join(s0, s1, s2), args: args{mention: "@billy", limits: extractLimits, s: allConcat}},
|
||||
{name: "last line mention", want: join(s5[7:], s6, s7), args: args{mention: "@sarah", limits: extractLimits, s: allConcat}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractText(tt.args.s, tt.args.mention, tt.args.limits); got != tt.want {
|
||||
t.Errorf("extractText()\ngot:\n%v\nwant:\n%v\n", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_safeConcat(t *testing.T) {
|
||||
type args struct {
|
||||
lines []string
|
||||
start int
|
||||
end int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{name: "out of range", want: join(s0, s1, s2, s3, s4, s5, s6, s7), args: args{start: -22, end: 99, lines: all}},
|
||||
{name: "2,3", want: join(s2, s3), args: args{start: 2, end: 4, lines: all}},
|
||||
{name: "mismatch", want: "", args: args{start: 4, end: 2, lines: all}},
|
||||
{name: "empty", want: "", args: args{start: 2, end: 4, lines: []string{}}},
|
||||
{name: "nil", want: "", args: args{start: 2, end: 4, lines: nil}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := safeConcat(tt.args.lines, tt.args.start, tt.args.end); got != tt.want {
|
||||
t.Errorf("safeConcat() = [%v], want [%v]", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_safeSubstr(t *testing.T) {
|
||||
type args struct {
|
||||
s string
|
||||
start int
|
||||
end int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{name: "good", want: "is line", args: args{start: 33, end: 40, s: join(s0, s1, s2)}},
|
||||
{name: "out of range", want: allConcat, args: args{start: -10, end: 1000, s: allConcat}},
|
||||
{name: "mismatch", want: "", args: args{start: 33, end: 26, s: allConcat}},
|
||||
{name: "empty", want: "", args: args{start: 2, end: 4, s: ""}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := safeSubstr(tt.args.s, tt.args.start, tt.args.end); got != tt.want {
|
||||
t.Errorf("safeSubstr()\ngot:\n[%v]\nwant:\n[%v]\n", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
34
server/boards/services/notify/notifymentions/mentions.go
Обычный файл
34
server/boards/services/notify/notifymentions/mentions.go
Обычный файл
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var atMentionRegexp = regexp.MustCompile(`\B@[[:alnum:]][[:alnum:]\.\-_:]*`)
|
||||
|
||||
// extractMentions extracts any mentions in the specified block and returns
|
||||
// a slice of usernames.
|
||||
func extractMentions(block *model.Block) map[string]struct{} {
|
||||
mentions := make(map[string]struct{})
|
||||
if block == nil || !strings.Contains(block.Title, "@") {
|
||||
return mentions
|
||||
}
|
||||
|
||||
str := block.Title
|
||||
|
||||
for _, match := range atMentionRegexp.FindAllString(str, -1) {
|
||||
name := mm_model.NormalizeUsername(match[1:])
|
||||
if mm_model.IsValidUsernameAllowRemote(name) {
|
||||
mentions[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return mentions
|
||||
}
|
||||
241
server/boards/services/notify/notifymentions/mentions_backend.go
Обычный файл
241
server/boards/services/notify/notifymentions/mentions_backend.go
Обычный файл
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
backendName = "notifyMentions"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMentionPermission = errors.New("mention not permitted")
|
||||
)
|
||||
|
||||
type MentionListener interface {
|
||||
OnMention(userID string, evt notify.BlockChangeEvent)
|
||||
}
|
||||
|
||||
type BackendParams struct {
|
||||
AppAPI AppAPI
|
||||
Permissions permissions.PermissionsService
|
||||
Delivery MentionDelivery
|
||||
Logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
// Backend provides the notification backend for @mentions.
|
||||
type Backend struct {
|
||||
appAPI AppAPI
|
||||
permissions permissions.PermissionsService
|
||||
delivery MentionDelivery
|
||||
logger mlog.LoggerIFace
|
||||
|
||||
mux sync.RWMutex
|
||||
listeners []MentionListener
|
||||
}
|
||||
|
||||
func New(params BackendParams) *Backend {
|
||||
return &Backend{
|
||||
appAPI: params.AppAPI,
|
||||
permissions: params.Permissions,
|
||||
delivery: params.Delivery,
|
||||
logger: params.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) ShutDown() error {
|
||||
_ = b.logger.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) Name() string {
|
||||
return backendName
|
||||
}
|
||||
|
||||
func (b *Backend) AddListener(l MentionListener) {
|
||||
b.mux.Lock()
|
||||
defer b.mux.Unlock()
|
||||
b.listeners = append(b.listeners, l)
|
||||
b.logger.Debug("Mention listener added.", mlog.Int("listener_count", len(b.listeners)))
|
||||
}
|
||||
|
||||
func (b *Backend) RemoveListener(l MentionListener) {
|
||||
b.mux.Lock()
|
||||
defer b.mux.Unlock()
|
||||
list := make([]MentionListener, 0, len(b.listeners))
|
||||
for _, listener := range b.listeners {
|
||||
if listener != l {
|
||||
list = append(list, listener)
|
||||
}
|
||||
}
|
||||
b.listeners = list
|
||||
b.logger.Debug("Mention listener removed.", mlog.Int("listener_count", len(b.listeners)))
|
||||
}
|
||||
|
||||
func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error {
|
||||
if evt.Board == nil || evt.Card == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if evt.Action == notify.Delete {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch evt.BlockChanged.Type {
|
||||
case model.TypeText, model.TypeComment, model.TypeImage:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
mentions := extractMentions(evt.BlockChanged)
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldMentions := extractMentions(evt.BlockOld)
|
||||
merr := merror.New()
|
||||
|
||||
b.mux.RLock()
|
||||
listeners := make([]MentionListener, len(b.listeners))
|
||||
copy(listeners, b.listeners)
|
||||
b.mux.RUnlock()
|
||||
|
||||
for username := range mentions {
|
||||
if _, exists := oldMentions[username]; exists {
|
||||
// the mention already existed; no need to notify again
|
||||
continue
|
||||
}
|
||||
|
||||
extract := extractText(evt.BlockChanged.Title, username, newLimits())
|
||||
|
||||
userID, err := b.deliverMentionNotification(username, extract, evt)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrMentionPermission) {
|
||||
b.logger.Debug("Cannot deliver notification", mlog.String("user", username), mlog.Err(err))
|
||||
} else {
|
||||
merr.Append(fmt.Errorf("cannot deliver notification for @%s: %w", username, err))
|
||||
}
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
// was a `@` followed by something other than a username.
|
||||
continue
|
||||
}
|
||||
|
||||
b.logger.Debug("Mention notification delivered",
|
||||
mlog.String("user", username),
|
||||
mlog.Int("listener_count", len(listeners)),
|
||||
)
|
||||
|
||||
for _, listener := range listeners {
|
||||
safeCallListener(listener, userID, evt, b.logger)
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
func safeCallListener(listener MentionListener, userID string, evt notify.BlockChangeEvent, logger mlog.LoggerIFace) {
|
||||
// don't let panicky listeners stop notifications
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Error("panic calling @mention notification listener", mlog.Any("err", r))
|
||||
}
|
||||
}()
|
||||
listener.OnMention(userID, evt)
|
||||
}
|
||||
|
||||
func (b *Backend) deliverMentionNotification(username string, extract string, evt notify.BlockChangeEvent) (string, error) {
|
||||
mentionedUser, err := b.delivery.UserByUsername(username)
|
||||
if err != nil {
|
||||
if model.IsErrNotFound(err) {
|
||||
// not really an error; could just be someone typed "@sometext"
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("cannot lookup mentioned user: %w", err)
|
||||
}
|
||||
|
||||
if evt.ModifiedBy == nil {
|
||||
return "", fmt.Errorf("invalid user cannot mention: %w", ErrMentionPermission)
|
||||
}
|
||||
|
||||
if evt.Board.Type == model.BoardTypeOpen {
|
||||
// public board rules:
|
||||
// - admin, editor, commenter: can mention anyone on team (mentioned users are automatically added to board)
|
||||
// - guest: can mention board members
|
||||
switch {
|
||||
case evt.ModifiedBy.SchemeAdmin, evt.ModifiedBy.SchemeEditor, evt.ModifiedBy.SchemeCommenter:
|
||||
if !b.permissions.HasPermissionToTeam(mentionedUser.Id, evt.TeamID, model.PermissionViewTeam) {
|
||||
return "", fmt.Errorf("%s cannot mention non-team member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
}
|
||||
// add mentioned user to board (if not already a member)
|
||||
member, err := b.appAPI.GetMemberForBoard(evt.Board.ID, mentionedUser.Id)
|
||||
if member == nil || model.IsErrNotFound(err) {
|
||||
// create memberships based on minimum board role
|
||||
newBoardMember := &model.BoardMember{
|
||||
UserID: mentionedUser.Id,
|
||||
BoardID: evt.Board.ID,
|
||||
SchemeViewer: evt.Board.MinimumRole == model.BoardRoleViewer ||
|
||||
evt.Board.MinimumRole == model.BoardRoleCommenter ||
|
||||
evt.Board.MinimumRole == model.BoardRoleEditor,
|
||||
SchemeCommenter: evt.Board.MinimumRole == model.BoardRoleCommenter ||
|
||||
evt.Board.MinimumRole == model.BoardRoleEditor,
|
||||
SchemeEditor: evt.Board.MinimumRole == model.BoardRoleEditor,
|
||||
}
|
||||
if _, err = b.appAPI.AddMemberToBoard(newBoardMember); err != nil {
|
||||
return "", fmt.Errorf("cannot add mentioned user %s to board %s: %w", mentionedUser.Id, evt.Board.ID, err)
|
||||
}
|
||||
b.logger.Debug("auto-added mentioned user to board",
|
||||
mlog.String("user_id", mentionedUser.Id),
|
||||
mlog.String("board_id", evt.Board.ID),
|
||||
mlog.String("board_type", string(evt.Board.Type)),
|
||||
)
|
||||
} else {
|
||||
b.logger.Debug("skipping auto-add mentioned user to board; already a member",
|
||||
mlog.String("user_id", mentionedUser.Id),
|
||||
mlog.String("board_id", evt.Board.ID),
|
||||
mlog.String("board_type", string(evt.Board.Type)),
|
||||
)
|
||||
}
|
||||
case evt.ModifiedBy.SchemeViewer:
|
||||
// viewer should not have gotten this far since they cannot add text to a card
|
||||
return "", fmt.Errorf("%s (viewer) cannot mention user %s: %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
default:
|
||||
// this is a guest
|
||||
if !b.permissions.HasPermissionToBoard(mentionedUser.Id, evt.Board.ID, model.PermissionViewBoard) {
|
||||
return "", fmt.Errorf("%s cannot mention non-board member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// private board rules:
|
||||
// - admin, editor, commenter, guest: can mention board members
|
||||
switch {
|
||||
case evt.ModifiedBy.SchemeViewer:
|
||||
// viewer should not have gotten this far since they cannot add text to a card
|
||||
return "", fmt.Errorf("%s (viewer) cannot mention user %s: %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
default:
|
||||
// everyone else can mention board members
|
||||
if !b.permissions.HasPermissionToBoard(mentionedUser.Id, evt.Board.ID, model.PermissionViewBoard) {
|
||||
return "", fmt.Errorf("%s cannot mention non-board member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.delivery.MentionDeliver(mentionedUser, extract, evt)
|
||||
}
|
||||
52
server/boards/services/notify/notifymentions/mentions_test.go
Обычный файл
52
server/boards/services/notify/notifymentions/mentions_test.go
Обычный файл
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func Test_extractMentions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
block *model.Block
|
||||
want map[string]struct{}
|
||||
}{
|
||||
{name: "empty", block: makeBlock(""), want: makeMap()},
|
||||
{name: "zero mentions", block: makeBlock("This is some text."), want: makeMap()},
|
||||
{name: "one mention", block: makeBlock("Hello @user1"), want: makeMap("user1")},
|
||||
{name: "multiple mentions", block: makeBlock("Hello @user1, @user2 and @user3"), want: makeMap("user1", "user2", "user3")},
|
||||
{name: "include period", block: makeBlock("Hello @user1."), want: makeMap("user1.")},
|
||||
{name: "include underscore", block: makeBlock("Hello @user1_"), want: makeMap("user1_")},
|
||||
{name: "don't include comma", block: makeBlock("Hello @user1,"), want: makeMap("user1")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractMentions(tt.block); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("extractMentions() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeBlock(text string) *model.Block {
|
||||
return &model.Block{
|
||||
ID: mm_model.NewId(),
|
||||
Type: model.TypeComment,
|
||||
Title: text,
|
||||
}
|
||||
}
|
||||
|
||||
func makeMap(mentions ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
for _, mention := range mentions {
|
||||
m[mention] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
25
server/boards/services/notify/notifysubscriptions/app_api.go
Обычный файл
25
server/boards/services/notify/notifysubscriptions/app_api.go
Обычный файл
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type AppAPI interface {
|
||||
GetBlockHistory(blockID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error)
|
||||
GetBlockHistoryNewestChildren(parentID string, opts model.QueryBlockHistoryChildOptions) ([]*model.Block, bool, error)
|
||||
GetBoardAndCardByID(blockID string) (board *model.Board, card *model.Block, err error)
|
||||
|
||||
GetUserByID(userID string) (*model.User, error)
|
||||
|
||||
CreateSubscription(sub *model.Subscription) (*model.Subscription, error)
|
||||
GetSubscribersForBlock(blockID string) ([]*model.Subscriber, error)
|
||||
UpdateSubscribersNotifiedAt(blockID string, notifyAt int64) error
|
||||
|
||||
UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error)
|
||||
GetNextNotificationHint(remove bool) (*model.NotificationHint, error)
|
||||
}
|
||||
17
server/boards/services/notify/notifysubscriptions/delivery.go
Обычный файл
17
server/boards/services/notify/notifysubscriptions/delivery.go
Обычный файл
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// SubscriptionDelivery provides an interface for delivering subscription notifications to other systems, such as
|
||||
// channels server via plugin API.
|
||||
type SubscriptionDelivery interface {
|
||||
SubscriptionDeliverSlackAttachments(teamID string, subscriberID string, subscriberType model.SubscriberType,
|
||||
attachments []*mm_model.SlackAttachment) error
|
||||
}
|
||||
364
server/boards/services/notify/notifysubscriptions/diff.go
Обычный файл
364
server/boards/services/notify/notifysubscriptions/diff.go
Обычный файл
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// Diff represents a difference between two versions of a block.
|
||||
type Diff struct {
|
||||
Board *model.Board
|
||||
Card *model.Block
|
||||
Authors StringMap
|
||||
|
||||
BlockType model.BlockType
|
||||
OldBlock *model.Block
|
||||
NewBlock *model.Block
|
||||
|
||||
UpdateAt int64 // the UpdateAt of the latest version of the block
|
||||
|
||||
schemaDiffs []SchemaDiff
|
||||
PropDiffs []PropDiff
|
||||
|
||||
Diffs []*Diff // Diffs for child blocks
|
||||
}
|
||||
|
||||
type PropDiff struct {
|
||||
ID string // property id
|
||||
Index int
|
||||
Name string
|
||||
OldValue string
|
||||
NewValue string
|
||||
}
|
||||
|
||||
type SchemaDiff struct {
|
||||
Board *model.Board
|
||||
|
||||
OldPropDef *model.PropDef
|
||||
NewPropDef *model.PropDef
|
||||
}
|
||||
|
||||
type diffGenerator struct {
|
||||
board *model.Board
|
||||
card *model.Block
|
||||
|
||||
store AppAPI
|
||||
hint *model.NotificationHint
|
||||
lastNotifyAt int64
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
func (dg *diffGenerator) generateDiffs() ([]*Diff, error) {
|
||||
// use block_history to fetch blocks in case they were deleted and no longer exist in blocks table.
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
Limit: 1,
|
||||
Descending: true,
|
||||
}
|
||||
blocks, err := dg.store.GetBlockHistory(dg.hint.BlockID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get block for notification: %w", err)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, fmt.Errorf("block not found for notification: %w", err)
|
||||
}
|
||||
block := blocks[0]
|
||||
|
||||
if dg.board == nil || dg.card == nil {
|
||||
return nil, fmt.Errorf("cannot generate diff for block %s; must have a valid board and card: %w", dg.hint.BlockID, err)
|
||||
}
|
||||
|
||||
// parse board's property schema here so it only happens once.
|
||||
schema, err := model.ParsePropertySchema(dg.board)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse property schema for board %s: %w", dg.board.ID, err)
|
||||
}
|
||||
|
||||
switch block.Type {
|
||||
case model.TypeBoard:
|
||||
dg.logger.Warn("generateDiffs for board skipped", mlog.String("block_id", block.ID))
|
||||
// TODO: Fix this
|
||||
// return dg.generateDiffsForBoard(block, schema)
|
||||
return nil, nil
|
||||
case model.TypeCard:
|
||||
diff, err := dg.generateDiffsForCard(block, schema)
|
||||
if err != nil || diff == nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*Diff{diff}, nil
|
||||
default:
|
||||
diff, err := dg.generateDiffForBlock(block, schema)
|
||||
if err != nil || diff == nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*Diff{diff}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: fix this
|
||||
/*
|
||||
func (dg *diffGenerator) generateDiffsForBoard(board *model.Board, schema model.PropSchema) ([]*Diff, error) {
|
||||
opts := model.QuerySubtreeOptions{
|
||||
AfterUpdateAt: dg.lastNotifyAt,
|
||||
}
|
||||
|
||||
find all child blocks of the board that updated since last notify.
|
||||
blocks, err := dg.store.GetSubTree2(board.ID, board.ID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get subtree for board %s: %w", board.ID, err)
|
||||
}
|
||||
|
||||
var diffs []*Diff
|
||||
|
||||
generate diff for board title change or description
|
||||
boardDiff, err := dg.generateDiffForBlock(board, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not generate diff for board %s: %w", board.ID, err)
|
||||
}
|
||||
|
||||
if boardDiff != nil {
|
||||
TODO: phase 2 feature (generate schema diffs and add to board diff) goes here.
|
||||
diffs = append(diffs, boardDiff)
|
||||
}
|
||||
|
||||
for _, b := range blocks {
|
||||
block := b
|
||||
if block.Type == model.TypeCard {
|
||||
cardDiffs, err := dg.generateDiffsForCard(&block, schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diffs = append(diffs, cardDiffs)
|
||||
}
|
||||
}
|
||||
return diffs, nil
|
||||
}
|
||||
*/
|
||||
|
||||
func (dg *diffGenerator) generateDiffsForCard(card *model.Block, schema model.PropSchema) (*Diff, error) {
|
||||
// generate diff for card title change and properties.
|
||||
cardDiff, err := dg.generateDiffForBlock(card, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not generate diff for card %s: %w", card.ID, err)
|
||||
}
|
||||
|
||||
// fetch all card content blocks that were updated after last notify
|
||||
opts := model.QueryBlockHistoryChildOptions{
|
||||
AfterUpdateAt: dg.lastNotifyAt,
|
||||
}
|
||||
blocks, _, err := dg.store.GetBlockHistoryNewestChildren(card.ID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get subtree for card %s: %w", card.ID, err)
|
||||
}
|
||||
|
||||
authors := make(StringMap)
|
||||
|
||||
// walk child blocks
|
||||
var childDiffs []*Diff
|
||||
for i := range blocks {
|
||||
if blocks[i].ID == card.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
blockDiff, err := dg.generateDiffForBlock(blocks[i], schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not generate diff for block %s: %w", blocks[i].ID, err)
|
||||
}
|
||||
if blockDiff != nil {
|
||||
childDiffs = append(childDiffs, blockDiff)
|
||||
authors.Append(blockDiff.Authors)
|
||||
}
|
||||
}
|
||||
|
||||
dg.logger.Debug("generateDiffsForCard",
|
||||
mlog.Bool("has_top_changes", cardDiff != nil),
|
||||
mlog.Int("subtree", len(blocks)),
|
||||
mlog.Array("author_names", authors.Values()),
|
||||
mlog.Int("child_diffs", len(childDiffs)),
|
||||
)
|
||||
|
||||
if len(childDiffs) != 0 {
|
||||
if cardDiff == nil { // will be nil if the card has no other changes besides child diffs
|
||||
cardDiff = &Diff{
|
||||
Board: dg.board,
|
||||
Card: card,
|
||||
Authors: make(StringMap),
|
||||
BlockType: card.Type,
|
||||
OldBlock: card,
|
||||
NewBlock: card,
|
||||
UpdateAt: card.UpdateAt,
|
||||
PropDiffs: nil,
|
||||
schemaDiffs: nil,
|
||||
}
|
||||
}
|
||||
cardDiff.Diffs = childDiffs
|
||||
}
|
||||
cardDiff.Authors.Append(authors)
|
||||
|
||||
return cardDiff, nil
|
||||
}
|
||||
|
||||
func (dg *diffGenerator) generateDiffForBlock(newBlock *model.Block, schema model.PropSchema) (*Diff, error) {
|
||||
dg.logger.Debug("generateDiffForBlock - new block",
|
||||
mlog.String("block_id", newBlock.ID),
|
||||
mlog.String("block_type", string(newBlock.Type)),
|
||||
mlog.String("modified_by", newBlock.ModifiedBy),
|
||||
mlog.Int64("update_at", newBlock.UpdateAt),
|
||||
)
|
||||
|
||||
// find the version of the block as it was at the time of last notify.
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
BeforeUpdateAt: dg.lastNotifyAt + 1,
|
||||
Limit: 1,
|
||||
Descending: true,
|
||||
}
|
||||
history, err := dg.store.GetBlockHistory(newBlock.ID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get block history for block %s: %w", newBlock.ID, err)
|
||||
}
|
||||
|
||||
var oldBlock *model.Block
|
||||
if len(history) != 0 {
|
||||
oldBlock = history[0]
|
||||
|
||||
dg.logger.Debug("generateDiffForBlock - old block",
|
||||
mlog.String("block_id", oldBlock.ID),
|
||||
mlog.String("block_type", string(oldBlock.Type)),
|
||||
mlog.Int64("before_update_at", dg.lastNotifyAt),
|
||||
mlog.String("modified_by", oldBlock.ModifiedBy),
|
||||
mlog.Int64("update_at", oldBlock.UpdateAt),
|
||||
)
|
||||
}
|
||||
|
||||
// find all the versions of the blocks that changed so we can gather all the author usernames.
|
||||
opts = model.QueryBlockHistoryOptions{
|
||||
AfterUpdateAt: dg.lastNotifyAt,
|
||||
Descending: true,
|
||||
}
|
||||
chgBlocks, err := dg.store.GetBlockHistory(newBlock.ID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting block history for block %s: %w", newBlock.ID, err)
|
||||
}
|
||||
authors := make(StringMap)
|
||||
|
||||
dg.logger.Debug("generateDiffForBlock - authors",
|
||||
mlog.Int64("after_update_at", dg.lastNotifyAt),
|
||||
mlog.Int("history_count", len(chgBlocks)),
|
||||
)
|
||||
|
||||
// have to loop through history slice because GetBlockHistory does not return pointers.
|
||||
for _, b := range chgBlocks {
|
||||
user, err := dg.store.GetUserByID(b.ModifiedBy)
|
||||
if err != nil || user == nil {
|
||||
dg.logger.Error("could not fetch username for block",
|
||||
mlog.String("modified_by", b.ModifiedBy),
|
||||
mlog.Err(err),
|
||||
)
|
||||
authors.Add(b.ModifiedBy, "unknown_user") // todo: localize this when server has i18n
|
||||
} else {
|
||||
authors.Add(user.ID, user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
propDiffs := dg.generatePropDiffs(oldBlock, newBlock, schema)
|
||||
|
||||
dg.logger.Debug("generateDiffForBlock - results",
|
||||
mlog.String("block_id", newBlock.ID),
|
||||
mlog.String("block_type", string(newBlock.Type)),
|
||||
mlog.Array("author_names", authors.Values()),
|
||||
mlog.Int("history_count", len(history)),
|
||||
mlog.Int("prop_diff_count", len(propDiffs)),
|
||||
)
|
||||
|
||||
diff := &Diff{
|
||||
Board: dg.board,
|
||||
Card: dg.card,
|
||||
Authors: authors,
|
||||
BlockType: newBlock.Type,
|
||||
OldBlock: oldBlock,
|
||||
NewBlock: newBlock,
|
||||
UpdateAt: newBlock.UpdateAt,
|
||||
PropDiffs: propDiffs,
|
||||
schemaDiffs: nil,
|
||||
}
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
func (dg *diffGenerator) generatePropDiffs(oldBlock, newBlock *model.Block, schema model.PropSchema) []PropDiff {
|
||||
var propDiffs []PropDiff
|
||||
|
||||
oldProps, err := model.ParseProperties(oldBlock, schema, dg.store)
|
||||
if err != nil {
|
||||
dg.logger.Error("Cannot parse properties for old block",
|
||||
mlog.String("block_id", oldBlock.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
|
||||
newProps, err := model.ParseProperties(newBlock, schema, dg.store)
|
||||
if err != nil {
|
||||
dg.logger.Error("Cannot parse properties for new block",
|
||||
mlog.String("block_id", oldBlock.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
|
||||
// look for new or changed properties.
|
||||
for k, prop := range newProps {
|
||||
oldP, ok := oldProps[k]
|
||||
if ok {
|
||||
// prop changed
|
||||
if prop.Value != oldP.Value {
|
||||
propDiffs = append(propDiffs, PropDiff{
|
||||
ID: prop.ID,
|
||||
Index: prop.Index,
|
||||
Name: prop.Name,
|
||||
NewValue: prop.Value,
|
||||
OldValue: oldP.Value,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// prop added
|
||||
propDiffs = append(propDiffs, PropDiff{
|
||||
ID: prop.ID,
|
||||
Index: prop.Index,
|
||||
Name: prop.Name,
|
||||
NewValue: prop.Value,
|
||||
OldValue: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// look for deleted properties
|
||||
for k, prop := range oldProps {
|
||||
_, ok := newProps[k]
|
||||
if !ok {
|
||||
// prop deleted
|
||||
propDiffs = append(propDiffs, PropDiff{
|
||||
ID: prop.ID,
|
||||
Index: prop.Index,
|
||||
Name: prop.Name,
|
||||
NewValue: "",
|
||||
OldValue: prop.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
return sortPropDiffs(propDiffs)
|
||||
}
|
||||
|
||||
func sortPropDiffs(propDiffs []PropDiff) []PropDiff {
|
||||
if len(propDiffs) == 0 {
|
||||
return propDiffs
|
||||
}
|
||||
|
||||
sort.Slice(propDiffs, func(i, j int) bool {
|
||||
return propDiffs[i].Index < propDiffs[j].Index
|
||||
})
|
||||
return propDiffs
|
||||
}
|
||||
184
server/boards/services/notify/notifysubscriptions/diff2markdown.go
Обычный файл
184
server/boards/services/notify/notifysubscriptions/diff2markdown.go
Обычный файл
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func generateMarkdownDiff(oldText string, newText string, logger mlog.LoggerIFace) string {
|
||||
oldTxtNorm := normalizeText(oldText)
|
||||
newTxtNorm := normalizeText(newText)
|
||||
|
||||
dmp := diffmatchpatch.New()
|
||||
|
||||
diffs := dmp.DiffMain(oldTxtNorm, newTxtNorm, false)
|
||||
|
||||
diffs = dmp.DiffCleanupSemantic(diffs)
|
||||
diffs = dmp.DiffCleanupEfficiency(diffs)
|
||||
|
||||
// check there is at least one insert or delete
|
||||
var editFound bool
|
||||
for _, d := range diffs {
|
||||
if (d.Type == diffmatchpatch.DiffInsert || d.Type == diffmatchpatch.DiffDelete) && strings.TrimSpace(d.Text) != "" {
|
||||
editFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !editFound {
|
||||
logger.Debug("skipping notification for superficial diff")
|
||||
return ""
|
||||
}
|
||||
|
||||
cfg := markDownCfg{
|
||||
insertOpen: "`",
|
||||
insertClose: "`",
|
||||
deleteOpen: "~~`",
|
||||
deleteClose: "`~~",
|
||||
}
|
||||
markdown := generateMarkdown(diffs, cfg)
|
||||
markdown = strings.ReplaceAll(markdown, "¶", "\n")
|
||||
|
||||
return markdown
|
||||
}
|
||||
|
||||
const (
|
||||
truncLenEquals = 60
|
||||
truncLenInserts = 120
|
||||
truncLenDeletes = 80
|
||||
)
|
||||
|
||||
type markDownCfg struct {
|
||||
insertOpen string
|
||||
insertClose string
|
||||
deleteOpen string
|
||||
deleteClose string
|
||||
}
|
||||
|
||||
func generateMarkdown(diffs []diffmatchpatch.Diff, cfg markDownCfg) string {
|
||||
sb := &strings.Builder{}
|
||||
|
||||
var first, last bool
|
||||
|
||||
for i, diff := range diffs {
|
||||
first = i == 0
|
||||
last = i == len(diffs)-1
|
||||
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffInsert:
|
||||
sb.WriteString(cfg.insertOpen)
|
||||
sb.WriteString(truncate(diff.Text, truncLenInserts, first, last))
|
||||
sb.WriteString(cfg.insertClose)
|
||||
|
||||
case diffmatchpatch.DiffDelete:
|
||||
sb.WriteString(cfg.deleteOpen)
|
||||
sb.WriteString(truncate(diff.Text, truncLenDeletes, first, last))
|
||||
sb.WriteString(cfg.deleteClose)
|
||||
|
||||
case diffmatchpatch.DiffEqual:
|
||||
sb.WriteString(truncate(diff.Text, truncLenEquals, first, last))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int, first bool, last bool) string {
|
||||
if len(s) < maxLen {
|
||||
return s
|
||||
}
|
||||
|
||||
var result string
|
||||
|
||||
switch {
|
||||
case first:
|
||||
// truncate left
|
||||
result = " ... " + rightWords(s, maxLen)
|
||||
case last:
|
||||
// truncate right
|
||||
result = leftWords(s, maxLen) + " ... "
|
||||
default:
|
||||
// truncate in the middle
|
||||
half := len(s) / 2
|
||||
|
||||
left := leftWords(s[:half], maxLen/2)
|
||||
right := rightWords(s[half:], maxLen/2)
|
||||
|
||||
result = left + " ... " + right
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(result, "¶", "↩")
|
||||
}
|
||||
|
||||
func normalizeText(s string) string {
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = strings.ReplaceAll(s, "\n\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\n", "¶")
|
||||
return s
|
||||
}
|
||||
|
||||
// leftWords returns approximately maxLen characters from the left part of the source string by truncating on the right,
|
||||
// with best effort to include whole words.
|
||||
func leftWords(s string, maxLen int) string {
|
||||
if len(s) < maxLen {
|
||||
return s
|
||||
}
|
||||
fields := strings.Fields(s)
|
||||
fields = words(fields, maxLen)
|
||||
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
// rightWords returns approximately maxLen from the right part of the source string by truncating from the left,
|
||||
// with best effort to include whole words.
|
||||
func rightWords(s string, maxLen int) string {
|
||||
if len(s) < maxLen {
|
||||
return s
|
||||
}
|
||||
fields := strings.Fields(s)
|
||||
|
||||
// reverse the fields so that the right-most words end up at the beginning.
|
||||
reverse(fields)
|
||||
|
||||
fields = words(fields, maxLen)
|
||||
|
||||
// reverse the fields again so that the original order is restored.
|
||||
reverse(fields)
|
||||
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
func reverse(ss []string) {
|
||||
ssLen := len(ss)
|
||||
for i := 0; i < ssLen/2; i++ {
|
||||
ss[i], ss[ssLen-i-1] = ss[ssLen-i-1], ss[i]
|
||||
}
|
||||
}
|
||||
|
||||
// words returns a subslice containing approximately maxChars of characters. The last item may be truncated.
|
||||
func words(words []string, maxChars int) []string {
|
||||
var count int
|
||||
result := make([]string, 0, len(words))
|
||||
|
||||
for i, w := range words {
|
||||
wordLen := len(w)
|
||||
if wordLen+count > maxChars {
|
||||
switch {
|
||||
case i == 0:
|
||||
result = append(result, w[:maxChars])
|
||||
case wordLen < 8:
|
||||
result = append(result, w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
count += wordLen
|
||||
result = append(result, w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_reverse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ss []string
|
||||
want []string
|
||||
}{
|
||||
{name: "even", ss: []string{"one", "two", "three", "four"}, want: []string{"four", "three", "two", "one"}},
|
||||
{name: "odd", ss: []string{"one", "two", "three"}, want: []string{"three", "two", "one"}},
|
||||
{name: "one", ss: []string{"one"}, want: []string{"one"}},
|
||||
{name: "empty", ss: []string{}, want: []string{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reverse(tt.ss)
|
||||
assert.Equal(t, tt.want, tt.ss)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// card change notifications.
|
||||
defAddCardNotify = "{{.Authors | printAuthors \"unknown_user\" }} has added the card {{. | makeLink}}\n"
|
||||
defModifyCardNotify = "###### {{.Authors | printAuthors \"unknown_user\" }} has modified the card {{. | makeLink}} on the board {{. | makeBoardLink}}\n"
|
||||
defDeleteCardNotify = "{{.Authors | printAuthors \"unknown_user\" }} has deleted the card {{. | makeLink}}\n"
|
||||
)
|
||||
|
||||
var (
|
||||
// templateCache is a map of text templateCache keyed by languange code.
|
||||
templateCache = make(map[string]*template.Template)
|
||||
templateCacheMux sync.Mutex
|
||||
)
|
||||
|
||||
// DiffConvOpts provides options when converting diffs to slack attachments.
|
||||
type DiffConvOpts struct {
|
||||
Language string
|
||||
MakeCardLink func(block *model.Block, board *model.Board, card *model.Block) string
|
||||
MakeBoardLink func(board *model.Board) string
|
||||
Logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
// getTemplate returns a new or cached named template based on the language specified.
|
||||
func getTemplate(name string, opts DiffConvOpts, def string) (*template.Template, error) {
|
||||
templateCacheMux.Lock()
|
||||
defer templateCacheMux.Unlock()
|
||||
|
||||
key := name + "&" + opts.Language
|
||||
t, ok := templateCache[key]
|
||||
if !ok {
|
||||
t = template.New(key)
|
||||
|
||||
if opts.MakeCardLink == nil {
|
||||
opts.MakeCardLink = func(block *model.Block, _ *model.Board, _ *model.Block) string {
|
||||
return fmt.Sprintf("`%s`", block.Title)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.MakeBoardLink == nil {
|
||||
opts.MakeBoardLink = func(board *model.Board) string {
|
||||
return fmt.Sprintf("`%s`", board.Title)
|
||||
}
|
||||
}
|
||||
myFuncs := template.FuncMap{
|
||||
"getBoardDescription": getBoardDescription,
|
||||
"makeLink": func(diff *Diff) string {
|
||||
return opts.MakeCardLink(diff.NewBlock, diff.Board, diff.Card)
|
||||
},
|
||||
"makeBoardLink": func(diff *Diff) string {
|
||||
return opts.MakeBoardLink(diff.Board)
|
||||
},
|
||||
"stripNewlines": func(s string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(s, "\n", "¶ "))
|
||||
},
|
||||
"printAuthors": func(empty string, authors StringMap) string {
|
||||
return makeAuthorsList(authors, empty)
|
||||
},
|
||||
}
|
||||
t.Funcs(myFuncs)
|
||||
|
||||
s := def // TODO: lookup i18n string when supported on server
|
||||
t2, err := t.Parse(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse markdown template '%s' for notifications: %w", key, err)
|
||||
}
|
||||
templateCache[key] = t2
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func makeAuthorsList(authors StringMap, empty string) string {
|
||||
if len(authors) == 0 {
|
||||
return empty
|
||||
}
|
||||
prefix := ""
|
||||
sb := &strings.Builder{}
|
||||
for _, name := range authors.Values() {
|
||||
sb.WriteString(prefix)
|
||||
sb.WriteString("@")
|
||||
sb.WriteString(strings.TrimSpace(name))
|
||||
prefix = ", "
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// execTemplate executes the named template corresponding to the template name and language specified.
|
||||
func execTemplate(w io.Writer, name string, opts DiffConvOpts, def string, data interface{}) error {
|
||||
t, err := getTemplate(name, opts, def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
// Diffs2SlackAttachments converts a slice of `Diff` to slack attachments to be used in a post.
|
||||
func Diffs2SlackAttachments(diffs []*Diff, opts DiffConvOpts) ([]*mm_model.SlackAttachment, error) {
|
||||
var attachments []*mm_model.SlackAttachment
|
||||
merr := merror.New()
|
||||
|
||||
for _, d := range diffs {
|
||||
// only handle cards for now.
|
||||
if d.BlockType == model.TypeCard {
|
||||
a, err := cardDiff2SlackAttachment(d, opts)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
continue
|
||||
}
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
attachments = append(attachments, a)
|
||||
}
|
||||
}
|
||||
return attachments, merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
func cardDiff2SlackAttachment(cardDiff *Diff, opts DiffConvOpts) (*mm_model.SlackAttachment, error) {
|
||||
// sanity check
|
||||
if cardDiff.NewBlock == nil && cardDiff.OldBlock == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
attachment := &mm_model.SlackAttachment{}
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
// card added
|
||||
if cardDiff.NewBlock != nil && cardDiff.OldBlock == nil {
|
||||
if err := execTemplate(buf, "AddCardNotify", opts, defAddCardNotify, cardDiff); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachment.Pretext = buf.String()
|
||||
attachment.Fallback = attachment.Pretext
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
// card deleted
|
||||
if (cardDiff.NewBlock == nil || cardDiff.NewBlock.DeleteAt != 0) && cardDiff.OldBlock != nil {
|
||||
buf.Reset()
|
||||
if err := execTemplate(buf, "DeleteCardNotify", opts, defDeleteCardNotify, cardDiff); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachment.Pretext = buf.String()
|
||||
attachment.Fallback = attachment.Pretext
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
// at this point new and old block are non-nil
|
||||
|
||||
opts.Logger.Debug("cardDiff2SlackAttachment",
|
||||
mlog.String("board_id", cardDiff.Board.ID),
|
||||
mlog.String("card_id", cardDiff.Card.ID),
|
||||
mlog.String("new_block_id", cardDiff.NewBlock.ID),
|
||||
mlog.String("old_block_id", cardDiff.OldBlock.ID),
|
||||
mlog.Int("childDiffs", len(cardDiff.Diffs)),
|
||||
)
|
||||
|
||||
buf.Reset()
|
||||
if err := execTemplate(buf, "ModifyCardNotify", opts, defModifyCardNotify, cardDiff); err != nil {
|
||||
return nil, fmt.Errorf("cannot write notification for card %s: %w", cardDiff.NewBlock.ID, err)
|
||||
}
|
||||
attachment.Pretext = buf.String()
|
||||
attachment.Fallback = attachment.Pretext
|
||||
|
||||
// title changes
|
||||
attachment.Fields = appendTitleChanges(attachment.Fields, cardDiff)
|
||||
|
||||
// property changes
|
||||
attachment.Fields = appendPropertyChanges(attachment.Fields, cardDiff)
|
||||
|
||||
// comment add/delete
|
||||
attachment.Fields = appendCommentChanges(attachment.Fields, cardDiff)
|
||||
|
||||
// File Attachment add/delete
|
||||
attachment.Fields = appendAttachmentChanges(attachment.Fields, cardDiff)
|
||||
|
||||
// content/description changes
|
||||
attachment.Fields = appendContentChanges(attachment.Fields, cardDiff, opts.Logger)
|
||||
|
||||
if len(attachment.Fields) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func appendTitleChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField {
|
||||
if cardDiff.NewBlock.Title != cardDiff.OldBlock.Title {
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: "Title",
|
||||
Value: fmt.Sprintf("%s ~~`%s`~~", stripNewlines(cardDiff.NewBlock.Title), stripNewlines(cardDiff.OldBlock.Title)),
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func appendPropertyChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField {
|
||||
if len(cardDiff.PropDiffs) == 0 {
|
||||
return fields
|
||||
}
|
||||
|
||||
for _, propDiff := range cardDiff.PropDiffs {
|
||||
if propDiff.NewValue == propDiff.OldValue {
|
||||
continue
|
||||
}
|
||||
|
||||
var val string
|
||||
if propDiff.OldValue != "" {
|
||||
val = fmt.Sprintf("%s ~~`%s`~~", stripNewlines(propDiff.NewValue), stripNewlines(propDiff.OldValue))
|
||||
} else {
|
||||
val = propDiff.NewValue
|
||||
}
|
||||
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: propDiff.Name,
|
||||
Value: val,
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func appendCommentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField {
|
||||
for _, child := range cardDiff.Diffs {
|
||||
if child.BlockType == model.TypeComment {
|
||||
var format string
|
||||
var msg string
|
||||
if child.NewBlock != nil && child.OldBlock == nil {
|
||||
// added comment
|
||||
format = "%s"
|
||||
msg = child.NewBlock.Title
|
||||
}
|
||||
|
||||
if (child.NewBlock == nil || child.NewBlock.DeleteAt != 0) && child.OldBlock != nil {
|
||||
// deleted comment
|
||||
format = "~~`%s`~~"
|
||||
msg = stripNewlines(child.OldBlock.Title)
|
||||
}
|
||||
|
||||
if format != "" {
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: "Comment by " + makeAuthorsList(child.Authors, "unknown_user"), // todo: localize this when server has i18n
|
||||
Value: fmt.Sprintf(format, msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func appendAttachmentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField {
|
||||
for _, child := range cardDiff.Diffs {
|
||||
if child.BlockType == model.TypeAttachment {
|
||||
var format string
|
||||
var msg string
|
||||
if child.NewBlock != nil && child.OldBlock == nil {
|
||||
format = "Added an attachment: **`%s`**"
|
||||
msg = child.NewBlock.Title
|
||||
} else {
|
||||
format = "Removed ~~`%s`~~ attachment"
|
||||
msg = stripNewlines(child.OldBlock.Title)
|
||||
}
|
||||
|
||||
if format != "" {
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: "Changed by " + makeAuthorsList(child.Authors, "unknown_user"), // TODO: localize this when server has i18n
|
||||
Value: fmt.Sprintf(format, msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func appendContentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff, logger mlog.LoggerIFace) []*mm_model.SlackAttachmentField {
|
||||
for _, child := range cardDiff.Diffs {
|
||||
var opAdd, opDelete bool
|
||||
var opString string
|
||||
|
||||
switch {
|
||||
case child.OldBlock == nil && child.NewBlock != nil:
|
||||
opAdd = true
|
||||
opString = "added" // TODO: localize when i18n added to server
|
||||
case child.NewBlock == nil || child.NewBlock.DeleteAt != 0:
|
||||
opDelete = true
|
||||
opString = "deleted"
|
||||
default:
|
||||
opString = "modified"
|
||||
}
|
||||
|
||||
var newTitle, oldTitle string
|
||||
if child.OldBlock != nil {
|
||||
oldTitle = child.OldBlock.Title
|
||||
}
|
||||
if child.NewBlock != nil {
|
||||
newTitle = child.NewBlock.Title
|
||||
}
|
||||
|
||||
switch child.BlockType {
|
||||
case model.TypeDivider, model.TypeComment:
|
||||
// do nothing
|
||||
continue
|
||||
case model.TypeImage:
|
||||
if newTitle == "" {
|
||||
newTitle = "An image was " + opString + "." // TODO: localize when i18n added to server
|
||||
}
|
||||
oldTitle = ""
|
||||
case model.TypeAttachment:
|
||||
if newTitle == "" {
|
||||
newTitle = "A file attachment was " + opString + "." // TODO: localize when i18n added to server
|
||||
}
|
||||
oldTitle = ""
|
||||
default:
|
||||
if !opAdd {
|
||||
if opDelete {
|
||||
newTitle = ""
|
||||
}
|
||||
// only strip newlines when modifying or deleting
|
||||
oldTitle = stripNewlines(oldTitle)
|
||||
newTitle = stripNewlines(newTitle)
|
||||
}
|
||||
if newTitle == oldTitle {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logger.Trace("appendContentChanges",
|
||||
mlog.String("type", string(child.BlockType)),
|
||||
mlog.String("opString", opString),
|
||||
mlog.String("oldTitle", oldTitle),
|
||||
mlog.String("newTitle", newTitle),
|
||||
)
|
||||
|
||||
markdown := generateMarkdownDiff(oldTitle, newTitle, logger)
|
||||
if markdown == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: "Description",
|
||||
Value: markdown,
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
282
server/boards/services/notify/notifysubscriptions/notifier.go
Обычный файл
282
server/boards/services/notify/notifysubscriptions/notifier.go
Обычный файл
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
defBlockNotificationFreq = time.Minute * 2
|
||||
enqueueNotifyHintTimeout = time.Second * 10
|
||||
hintQueueSize = 20
|
||||
)
|
||||
|
||||
var (
|
||||
errEnqueueNotifyHintTimeout = errors.New("enqueue notify hint timed out")
|
||||
)
|
||||
|
||||
// notifier provides block change notifications for subscribers. Block change events are batched
|
||||
// via notifications hints written to the database so that fewer notifications are sent for active
|
||||
// blocks.
|
||||
type notifier struct {
|
||||
serverRoot string
|
||||
store AppAPI
|
||||
permissions permissions.PermissionsService
|
||||
delivery SubscriptionDelivery
|
||||
logger mlog.LoggerIFace
|
||||
|
||||
hints chan *model.NotificationHint
|
||||
|
||||
mux sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newNotifier(params BackendParams) *notifier {
|
||||
return ¬ifier{
|
||||
serverRoot: params.ServerRoot,
|
||||
store: params.AppAPI,
|
||||
permissions: params.Permissions,
|
||||
delivery: params.Delivery,
|
||||
logger: params.Logger,
|
||||
done: nil,
|
||||
hints: make(chan *model.NotificationHint, hintQueueSize),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) start() {
|
||||
n.mux.Lock()
|
||||
defer n.mux.Unlock()
|
||||
|
||||
if n.done == nil {
|
||||
n.done = make(chan struct{})
|
||||
go n.loop()
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) stop() {
|
||||
n.mux.Lock()
|
||||
defer n.mux.Unlock()
|
||||
|
||||
if n.done != nil {
|
||||
close(n.done)
|
||||
n.done = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) loop() {
|
||||
done := n.done
|
||||
var nextNotify time.Time
|
||||
|
||||
for {
|
||||
hint, err := n.store.GetNextNotificationHint(false)
|
||||
switch {
|
||||
case model.IsErrNotFound(err):
|
||||
// no hints in table; wait up to an hour or when `onNotifyHint` is called again
|
||||
nextNotify = time.Now().Add(time.Hour * 1)
|
||||
n.logger.Debug("notify loop - no hints in queue", mlog.Time("next_check", nextNotify))
|
||||
case err != nil:
|
||||
// try again in a minute
|
||||
nextNotify = time.Now().Add(time.Minute * 1)
|
||||
n.logger.Error("notify loop - error fetching next notification", mlog.Err(err))
|
||||
case hint.NotifyAt > utils.GetMillis():
|
||||
// next hint is not ready yet; sleep until hint.NotifyAt
|
||||
nextNotify = utils.GetTimeForMillis(hint.NotifyAt)
|
||||
default:
|
||||
// it's time to notify
|
||||
n.notify()
|
||||
continue
|
||||
}
|
||||
|
||||
n.logger.Debug("subscription notifier loop",
|
||||
mlog.Time("next_notify", nextNotify),
|
||||
)
|
||||
|
||||
select {
|
||||
case <-n.hints:
|
||||
// A new hint was added. Wake up and check if next hint is ready to go.
|
||||
case <-time.After(time.Until(nextNotify)):
|
||||
// Next scheduled hint should be ready now.
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) onNotifyHint(hint *model.NotificationHint) error {
|
||||
n.logger.Debug("onNotifyHint - enqueing hint", mlog.Any("hint", hint))
|
||||
|
||||
select {
|
||||
case n.hints <- hint:
|
||||
case <-time.After(enqueueNotifyHintTimeout):
|
||||
return errEnqueueNotifyHintTimeout
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *notifier) notify() {
|
||||
var hint *model.NotificationHint
|
||||
var err error
|
||||
|
||||
hint, err = n.store.GetNextNotificationHint(true)
|
||||
if err != nil {
|
||||
if model.IsErrNotFound(err) {
|
||||
// Expected when multiple nodes in a cluster try to process the same hint at the same time.
|
||||
// This simply means the other node won. Returning here will simply try fetching another hint.
|
||||
return
|
||||
}
|
||||
n.logger.Error("notify - error fetching next notification", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err = n.notifySubscribers(hint); err != nil {
|
||||
n.logger.Error("Error notifying subscribers", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) notifySubscribers(hint *model.NotificationHint) error {
|
||||
// get the subscriber list
|
||||
subs, err := n.store.GetSubscribersForBlock(hint.BlockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
n.logger.Debug("notifySubscribers - no subscribers", mlog.Any("hint", hint))
|
||||
return nil
|
||||
}
|
||||
|
||||
// subs slice is sorted by `NotifiedAt`, therefore subs[0] contains the oldest NotifiedAt needed
|
||||
oldestNotifiedAt := subs[0].NotifiedAt
|
||||
|
||||
// need the block's board and card.
|
||||
board, card, err := n.store.GetBoardAndCardByID(hint.BlockID)
|
||||
if err != nil || board == nil || card == nil {
|
||||
return fmt.Errorf("could not get board & card for block %s: %w", hint.BlockID, err)
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - subscribers",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("board_id", board.ID),
|
||||
mlog.String("card_id", card.ID),
|
||||
mlog.Int("sub_count", len(subs)),
|
||||
)
|
||||
|
||||
dg := &diffGenerator{
|
||||
board: board,
|
||||
card: card,
|
||||
store: n.store,
|
||||
hint: hint,
|
||||
lastNotifyAt: oldestNotifiedAt,
|
||||
logger: n.logger,
|
||||
}
|
||||
diffs, err := dg.generateDiffs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - diffs",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.Int("diff_count", len(diffs)),
|
||||
)
|
||||
|
||||
if len(diffs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
diffAuthors := make(StringMap)
|
||||
for _, d := range diffs {
|
||||
diffAuthors.Append(d.Authors)
|
||||
}
|
||||
|
||||
opts := DiffConvOpts{
|
||||
Language: "en", // TODO: use correct language when i18n is available on server.
|
||||
MakeCardLink: func(block *model.Block, board *model.Board, card *model.Block) string {
|
||||
return fmt.Sprintf("[%s](%s)", block.Title, utils.MakeCardLink(n.serverRoot, board.TeamID, board.ID, card.ID))
|
||||
},
|
||||
MakeBoardLink: func(board *model.Board) string {
|
||||
return fmt.Sprintf("[%s](%s)", board.Title, utils.MakeBoardLink(n.serverRoot, board.TeamID, board.ID))
|
||||
},
|
||||
Logger: n.logger,
|
||||
}
|
||||
|
||||
attachments, err := Diffs2SlackAttachments(diffs, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
if len(attachments) > 0 {
|
||||
for _, sub := range subs {
|
||||
// don't notify the author of their own changes.
|
||||
authorName, isAuthor := diffAuthors[sub.SubscriberID]
|
||||
if isAuthor && len(diffAuthors) == 1 {
|
||||
n.logger.Debug("notifySubscribers - skipping author",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("author_id", sub.SubscriberID),
|
||||
mlog.String("author_username", authorName),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// make sure the subscriber still has permissions for the board.
|
||||
if !n.permissions.HasPermissionToBoard(sub.SubscriberID, board.ID, model.PermissionViewBoard) {
|
||||
n.logger.Debug("notifySubscribers - skipping non-board member",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("subscriber_id", sub.SubscriberID),
|
||||
mlog.String("board_id", board.ID),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - deliver",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("modified_by_id", hint.ModifiedByID),
|
||||
mlog.String("subscriber_id", sub.SubscriberID),
|
||||
mlog.String("subscriber_type", string(sub.SubscriberType)),
|
||||
)
|
||||
|
||||
if err = n.delivery.SubscriptionDeliverSlackAttachments(board.TeamID, sub.SubscriberID, sub.SubscriberType, attachments); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot deliver notification to subscriber %s [%s]: %w",
|
||||
sub.SubscriberID, sub.SubscriberType, err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
n.logger.Debug("notifySubscribers - skip delivery; no chg",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("modified_by_id", hint.ModifiedByID),
|
||||
)
|
||||
}
|
||||
|
||||
// find the new NotifiedAt based on the newest diff.
|
||||
var notifiedAt int64
|
||||
for _, d := range diffs {
|
||||
if d.UpdateAt > notifiedAt {
|
||||
notifiedAt = d.UpdateAt
|
||||
}
|
||||
for _, c := range d.Diffs {
|
||||
if c.UpdateAt > notifiedAt {
|
||||
notifiedAt = c.UpdateAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the last notified_at for all subscribers since we at least attempted to notify all of them.
|
||||
err = dg.store.UpdateSubscribersNotifiedAt(dg.hint.BlockID, notifiedAt)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("could not update subscribers notified_at for block %s: %w", dg.hint.BlockID, err))
|
||||
}
|
||||
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
backendName = "notifySubscriptions"
|
||||
)
|
||||
|
||||
type BackendParams struct {
|
||||
ServerRoot string
|
||||
AppAPI AppAPI
|
||||
Permissions permissions.PermissionsService
|
||||
Delivery SubscriptionDelivery
|
||||
Logger mlog.LoggerIFace
|
||||
NotifyFreqCardSeconds int
|
||||
NotifyFreqBoardSeconds int
|
||||
}
|
||||
|
||||
// Backend provides the notification backend for subscriptions.
|
||||
type Backend struct {
|
||||
appAPI AppAPI
|
||||
permissions permissions.PermissionsService
|
||||
delivery SubscriptionDelivery
|
||||
notifier *notifier
|
||||
logger mlog.LoggerIFace
|
||||
notifyFreqCardSeconds int
|
||||
notifyFreqBoardSeconds int
|
||||
}
|
||||
|
||||
func New(params BackendParams) *Backend {
|
||||
return &Backend{
|
||||
appAPI: params.AppAPI,
|
||||
delivery: params.Delivery,
|
||||
permissions: params.Permissions,
|
||||
notifier: newNotifier(params),
|
||||
logger: params.Logger,
|
||||
notifyFreqCardSeconds: params.NotifyFreqCardSeconds,
|
||||
notifyFreqBoardSeconds: params.NotifyFreqBoardSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) Start() error {
|
||||
b.logger.Debug("Starting subscriptions backend",
|
||||
mlog.Int("freq_card", b.notifyFreqCardSeconds),
|
||||
mlog.Int("freq_board", b.notifyFreqBoardSeconds),
|
||||
)
|
||||
b.notifier.start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) ShutDown() error {
|
||||
b.logger.Debug("Stopping subscriptions backend")
|
||||
b.notifier.stop()
|
||||
_ = b.logger.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) Name() string {
|
||||
return backendName
|
||||
}
|
||||
|
||||
func (b *Backend) getBlockUpdateFreq(blockType model.BlockType) time.Duration {
|
||||
// check for env variable override
|
||||
sFreq := os.Getenv("MM_BOARDS_NOTIFY_FREQ_SECONDS")
|
||||
if sFreq != "" && sFreq != "0" {
|
||||
if freq, err := strconv.ParseInt(sFreq, 10, 64); err != nil {
|
||||
b.logger.Error("Environment variable MM_BOARDS_NOTIFY_FREQ_SECONDS invalid (ignoring)", mlog.Err(err))
|
||||
} else {
|
||||
return time.Second * time.Duration(freq)
|
||||
}
|
||||
}
|
||||
|
||||
switch blockType {
|
||||
case model.TypeCard:
|
||||
return time.Second * time.Duration(b.notifyFreqCardSeconds)
|
||||
default:
|
||||
return defBlockNotificationFreq
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error {
|
||||
if evt.Board == nil {
|
||||
b.logger.Warn("No board found for block, skipping notify",
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
var err error
|
||||
|
||||
// if new card added, automatically subscribe the author.
|
||||
if evt.Action == notify.Add && evt.BlockChanged.Type == model.TypeCard {
|
||||
sub := &model.Subscription{
|
||||
BlockType: model.TypeCard,
|
||||
BlockID: evt.BlockChanged.ID,
|
||||
SubscriberType: model.SubTypeUser,
|
||||
SubscriberID: evt.ModifiedBy.UserID,
|
||||
}
|
||||
|
||||
if _, err = b.appAPI.CreateSubscription(sub); err != nil {
|
||||
b.logger.Warn("Cannot subscribe card author to card",
|
||||
mlog.String("card_id", evt.BlockChanged.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// notify board subscribers
|
||||
subs, err := b.appAPI.GetSubscribersForBlock(evt.Board.ID)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("cannot fetch subscribers for board %s: %w", evt.Board.ID, err))
|
||||
}
|
||||
if err = b.notifySubscribers(subs, evt.Board.ID, model.TypeBoard, evt.ModifiedBy.UserID); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot notify board subscribers for board %s: %w", evt.Board.ID, err))
|
||||
}
|
||||
|
||||
if evt.Card == nil {
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// notify card subscribers
|
||||
subs, err = b.appAPI.GetSubscribersForBlock(evt.Card.ID)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("cannot fetch subscribers for card %s: %w", evt.Card.ID, err))
|
||||
}
|
||||
if err = b.notifySubscribers(subs, evt.Card.ID, model.TypeCard, evt.ModifiedBy.UserID); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot notify card subscribers for card %s: %w", evt.Card.ID, err))
|
||||
}
|
||||
|
||||
// notify block subscribers (if/when other types can be subscribed to)
|
||||
if evt.Board.ID != evt.BlockChanged.ID && evt.Card.ID != evt.BlockChanged.ID {
|
||||
subs, err := b.appAPI.GetSubscribersForBlock(evt.BlockChanged.ID)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("cannot fetch subscribers for block %s: %w", evt.BlockChanged.ID, err))
|
||||
}
|
||||
if err := b.notifySubscribers(subs, evt.BlockChanged.ID, evt.BlockChanged.Type, evt.ModifiedBy.UserID); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot notify block subscribers for block %s: %w", evt.BlockChanged.ID, err))
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// notifySubscribers triggers a change notification for subscribers by writing a notification hint to the database.
|
||||
func (b *Backend) notifySubscribers(subs []*model.Subscriber, blockID string, idType model.BlockType, modifiedByID string) error {
|
||||
if len(subs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hint := &model.NotificationHint{
|
||||
BlockType: idType,
|
||||
BlockID: blockID,
|
||||
ModifiedByID: modifiedByID,
|
||||
}
|
||||
|
||||
hint, err := b.appAPI.UpsertNotificationHint(hint, b.getBlockUpdateFreq(idType))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot upsert notification hint: %w", err)
|
||||
}
|
||||
if err := b.notifier.onNotifyHint(hint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnMention satisfies the `MentionListener` interface and is called whenever a @mention notification
|
||||
// is sent. Here we create a subscription for the mentioned user to the card.
|
||||
func (b *Backend) OnMention(userID string, evt notify.BlockChangeEvent) {
|
||||
if evt.Card == nil {
|
||||
b.logger.Debug("Cannot subscribe mentioned user to nil card",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// user mentioned must be a board member to subscribe to card.
|
||||
if !b.permissions.HasPermissionToBoard(userID, evt.Board.ID, model.PermissionViewBoard) {
|
||||
b.logger.Debug("Not subscribing mentioned non-board member to card",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
sub := &model.Subscription{
|
||||
BlockType: model.TypeCard,
|
||||
BlockID: evt.Card.ID,
|
||||
SubscriberType: model.SubTypeUser,
|
||||
SubscriberID: userID,
|
||||
}
|
||||
|
||||
var err error
|
||||
if _, err = b.appAPI.CreateSubscription(sub); err != nil {
|
||||
b.logger.Warn("Cannot subscribe mentioned user to card",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("card_id", evt.Card.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("Subscribed mentioned user to card",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("card_id", evt.Card.ID),
|
||||
)
|
||||
}
|
||||
60
server/boards/services/notify/notifysubscriptions/util.go
Обычный файл
60
server/boards/services/notify/notifysubscriptions/util.go
Обычный файл
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func getBoardDescription(board *model.Block) string {
|
||||
if board == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
descr, ok := board.Fields["description"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
description, ok := descr.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
func stripNewlines(s string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(s, "\n", "¶ "))
|
||||
}
|
||||
|
||||
type StringMap map[string]string
|
||||
|
||||
func (sm StringMap) Add(k string, v string) {
|
||||
sm[k] = v
|
||||
}
|
||||
|
||||
func (sm StringMap) Append(m StringMap) {
|
||||
for k, v := range m {
|
||||
sm[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func (sm StringMap) Keys() []string {
|
||||
keys := make([]string, 0, len(sm))
|
||||
for k := range sm {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (sm StringMap) Values() []string {
|
||||
values := make([]string, 0, len(sm))
|
||||
for _, v := range sm {
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
40
server/boards/services/notify/plugindelivery/mention_deliver.go
Обычный файл
40
server/boards/services/notify/plugindelivery/mention_deliver.go
Обычный файл
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// MentionDeliver notifies a user they have been mentioned in a blockv ia the plugin API.
|
||||
func (pd *PluginDelivery) MentionDeliver(mentionedUser *mm_model.User, extract string, evt notify.BlockChangeEvent) (string, error) {
|
||||
author, err := pd.api.GetUserByID(evt.ModifiedBy.UserID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot find user: %w", err)
|
||||
}
|
||||
|
||||
channel, err := pd.getDirectChannel(evt.TeamID, mentionedUser.Id, pd.botID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot get direct channel: %w", err)
|
||||
}
|
||||
link := utils.MakeCardLink(pd.serverRoot, evt.Board.TeamID, evt.Board.ID, evt.Card.ID)
|
||||
boardLink := utils.MakeBoardLink(pd.serverRoot, evt.Board.TeamID, evt.Board.ID)
|
||||
|
||||
post := &mm_model.Post{
|
||||
UserId: pd.botID,
|
||||
ChannelId: channel.Id,
|
||||
Message: formatMessage(author.Username, extract, evt.Card.Title, link, evt.BlockChanged, boardLink, evt.Board.Title),
|
||||
}
|
||||
|
||||
if _, err := pd.api.CreatePost(post); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return mentionedUser.Id, nil
|
||||
}
|
||||
24
server/boards/services/notify/plugindelivery/message.go
Обычный файл
24
server/boards/services/notify/plugindelivery/message.go
Обычный файл
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO: localize these when i18n is available.
|
||||
defCommentTemplate = "@%s mentioned you in a comment on the card [%s](%s) in board [%s](%s)\n> %s"
|
||||
defDescriptionTemplate = "@%s mentioned you in the card [%s](%s) in board [%s](%s)\n> %s"
|
||||
)
|
||||
|
||||
func formatMessage(author string, extract string, card string, link string, block *model.Block, boardLink string, board string) string {
|
||||
template := defDescriptionTemplate
|
||||
if block.Type == model.TypeComment {
|
||||
template = defCommentTemplate
|
||||
}
|
||||
return fmt.Sprintf(template, author, card, link, board, boardLink, extract)
|
||||
}
|
||||
52
server/boards/services/notify/plugindelivery/plugin_delivery.go
Обычный файл
52
server/boards/services/notify/plugindelivery/plugin_delivery.go
Обычный файл
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
type servicesAPI interface {
|
||||
// GetDirectChannelOrCreate gets a direct message channel,
|
||||
// or creates one if it does not already exist
|
||||
GetDirectChannelOrCreate(userID1, userID2 string) (*mm_model.Channel, error)
|
||||
|
||||
// CreatePost creates a post.
|
||||
CreatePost(post *mm_model.Post) (*mm_model.Post, error)
|
||||
|
||||
// GetUserByID gets a user by their ID.
|
||||
GetUserByID(userID string) (*mm_model.User, error)
|
||||
|
||||
// GetUserByUsername gets a user by their username.
|
||||
GetUserByUsername(name string) (*mm_model.User, error)
|
||||
|
||||
// GetTeamMember gets a team member by their user id.
|
||||
GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error)
|
||||
|
||||
// GetChannelByID gets a Channel by its ID.
|
||||
GetChannelByID(channelID string) (*mm_model.Channel, error)
|
||||
|
||||
// GetChannelMember gets a channel member by userID.
|
||||
GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error)
|
||||
|
||||
// CreateMember adds a user to the specified team. Safe to call if the user is
|
||||
// already a member of the team.
|
||||
CreateMember(teamID string, userID string) (*mm_model.TeamMember, error)
|
||||
}
|
||||
|
||||
// PluginDelivery provides ability to send notifications to direct message channels via Mattermost plugin API.
|
||||
type PluginDelivery struct {
|
||||
botID string
|
||||
serverRoot string
|
||||
api servicesAPI
|
||||
}
|
||||
|
||||
// New creates a PluginDelivery instance.
|
||||
func New(botID string, serverRoot string, api servicesAPI) *PluginDelivery {
|
||||
return &PluginDelivery{
|
||||
botID: botID,
|
||||
serverRoot: serverRoot,
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedSubscriberType = errors.New("invalid subscriber type")
|
||||
)
|
||||
|
||||
// SubscriptionDeliverSlashAttachments notifies a user that changes were made to a block they are subscribed to.
|
||||
func (pd *PluginDelivery) SubscriptionDeliverSlackAttachments(teamID string, subscriberID string, subscriptionType model.SubscriberType,
|
||||
attachments []*mm_model.SlackAttachment) error {
|
||||
// check subscriber is member of channel
|
||||
_, err := pd.api.GetUserByID(subscriberID)
|
||||
if err != nil {
|
||||
if model.IsErrNotFound(err) {
|
||||
// subscriber is not a member of the channel; fail silently.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot fetch channel member for user %s: %w", subscriberID, err)
|
||||
}
|
||||
|
||||
channelID, err := pd.getDirectChannelID(teamID, subscriberID, subscriptionType, pd.botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
post := &mm_model.Post{
|
||||
UserId: pd.botID,
|
||||
ChannelId: channelID,
|
||||
}
|
||||
|
||||
mm_model.ParseSlackAttachment(post, attachments)
|
||||
|
||||
_, err = pd.api.CreatePost(post)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pd *PluginDelivery) getDirectChannelID(teamID string, subscriberID string, subscriberType model.SubscriberType, botID string) (string, error) {
|
||||
switch subscriberType {
|
||||
case model.SubTypeUser:
|
||||
user, err := pd.api.GetUserByID(subscriberID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot find user: %w", err)
|
||||
}
|
||||
channel, err := pd.getDirectChannel(teamID, user.Id, botID)
|
||||
if err != nil || channel == nil {
|
||||
return "", fmt.Errorf("cannot get direct channel: %w", err)
|
||||
}
|
||||
return channel.Id, nil
|
||||
case model.SubTypeChannel:
|
||||
return subscriberID, nil
|
||||
default:
|
||||
return "", ErrUnsupportedSubscriberType
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *PluginDelivery) getDirectChannel(teamID string, userID string, botID string) (*mm_model.Channel, error) {
|
||||
// first ensure the bot is a member of the team.
|
||||
_, err := pd.api.CreateMember(teamID, botID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot add bot to team %s: %w", teamID, err)
|
||||
}
|
||||
return pd.api.GetDirectChannelOrCreate(userID, botID)
|
||||
}
|
||||
55
server/boards/services/notify/plugindelivery/user.go
Обычный файл
55
server/boards/services/notify/plugindelivery/user.go
Обычный файл
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
const (
|
||||
usernameSpecialChars = ".-_ "
|
||||
)
|
||||
|
||||
func (pd *PluginDelivery) UserByUsername(username string) (*mm_model.User, error) {
|
||||
// check for usernames that might have trailing punctuation
|
||||
var user *mm_model.User
|
||||
var err error
|
||||
ok := true
|
||||
trimmed := username
|
||||
for ok {
|
||||
user, err = pd.api.GetUserByUsername(trimmed)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
trimmed, ok = trimUsernameSpecialChar(trimmed)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// trimUsernameSpecialChar tries to remove the last character from word if it
|
||||
// is a special character for usernames (dot, dash or underscore). If not, it
|
||||
// returns the same string.
|
||||
func trimUsernameSpecialChar(word string) (string, bool) {
|
||||
len := len(word)
|
||||
|
||||
if len > 0 && strings.LastIndexAny(word, usernameSpecialChars) == (len-1) {
|
||||
return word[:len-1], true
|
||||
}
|
||||
|
||||
return word, false
|
||||
}
|
||||
152
server/boards/services/notify/plugindelivery/user_test.go
Обычный файл
152
server/boards/services/notify/plugindelivery/user_test.go
Обычный файл
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var (
|
||||
defTeamID = mm_model.NewId()
|
||||
|
||||
user1 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "dlauder",
|
||||
}
|
||||
user2 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "steve.mqueen",
|
||||
}
|
||||
user3 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "bart_",
|
||||
}
|
||||
user4 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "missing_",
|
||||
}
|
||||
user5 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "wrong_team",
|
||||
}
|
||||
|
||||
mockUsers = map[string]*mm_model.User{
|
||||
"dlauder": user1,
|
||||
"steve.mqueen": user2,
|
||||
"bart_": user3,
|
||||
"wrong_team": user5,
|
||||
}
|
||||
)
|
||||
|
||||
func Test_userByUsername(t *testing.T) {
|
||||
servicesAPI := newServicesAPIMock(mockUsers)
|
||||
delivery := New("bot_id", "server_root", servicesAPI)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uname string
|
||||
teamID string
|
||||
want *mm_model.User
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "user1", uname: user1.Username, want: user1, wantErr: false},
|
||||
{name: "user1 with period", uname: user1.Username + ".", want: user1, wantErr: false},
|
||||
{name: "user1 with period plus more", uname: user1.Username + ". ", want: user1, wantErr: false},
|
||||
{name: "user2 with periods", uname: user2.Username + "...", want: user2, wantErr: false},
|
||||
{name: "user2 with underscore", uname: user2.Username + "_", want: user2, wantErr: false},
|
||||
{name: "user2 with hyphen plus more", uname: user2.Username + "- ", want: user2, wantErr: false},
|
||||
{name: "user2 with hyphen plus all", uname: user2.Username + ".-_ ", want: user2, wantErr: false},
|
||||
{name: "user3 with underscore", uname: user3.Username + "_", want: user3, wantErr: false},
|
||||
{name: "user4 missing", uname: user4.Username, want: nil, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := delivery.UserByUsername(tt.uname)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("userByUsername() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("userByUsername()\ngot:\n%v\nwant:\n%v\n", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type servicesAPIMock struct {
|
||||
users map[string]*mm_model.User
|
||||
}
|
||||
|
||||
func newServicesAPIMock(users map[string]*mm_model.User) servicesAPIMock {
|
||||
return servicesAPIMock{
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetUserByUsername(name string) (*mm_model.User, error) {
|
||||
user, ok := m.users[name]
|
||||
if !ok {
|
||||
return nil, model.NewErrNotFound(name)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetDirectChannelOrCreate(userID1, userID2 string) (*mm_model.Channel, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) CreatePost(post *mm_model.Post) (*mm_model.Post, error) {
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetUserByID(userID string) (*mm_model.User, error) {
|
||||
for _, user := range m.users {
|
||||
if user.Id == userID {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
return nil, model.NewErrNotFound(userID)
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) {
|
||||
user, err := m.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if teamID != defTeamID {
|
||||
return nil, model.NewErrNotFound(teamID)
|
||||
}
|
||||
|
||||
member := &mm_model.TeamMember{
|
||||
UserId: user.Id,
|
||||
TeamId: teamID,
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetChannelByID(channelID string) (*mm_model.Channel, error) {
|
||||
return nil, model.NewErrNotFound(channelID)
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) {
|
||||
return nil, model.NewErrNotFound(userID)
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) {
|
||||
member := &mm_model.TeamMember{
|
||||
UserId: userID,
|
||||
TeamId: teamID,
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
109
server/boards/services/notify/service.go
Обычный файл
109
server/boards/services/notify/service.go
Обычный файл
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
Add Action = "add"
|
||||
Update Action = "update"
|
||||
Delete Action = "delete"
|
||||
)
|
||||
|
||||
type BlockChangeEvent struct {
|
||||
Action Action
|
||||
TeamID string
|
||||
Board *model.Board
|
||||
Card *model.Block
|
||||
BlockChanged *model.Block
|
||||
BlockOld *model.Block
|
||||
ModifiedBy *model.BoardMember
|
||||
}
|
||||
|
||||
// Backend provides an interface for sending notifications.
|
||||
type Backend interface {
|
||||
Start() error
|
||||
ShutDown() error
|
||||
BlockChanged(evt BlockChangeEvent) error
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Service is a service that sends notifications based on block activity using one or more backends.
|
||||
type Service struct {
|
||||
mux sync.RWMutex
|
||||
backends []Backend
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
// New creates a notification service with one or more Backends capable of sending notifications.
|
||||
func New(logger mlog.LoggerIFace, backends ...Backend) (*Service, error) {
|
||||
notify := &Service{
|
||||
backends: make([]Backend, 0, len(backends)),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
for _, backend := range backends {
|
||||
if err := notify.AddBackend(backend); err != nil {
|
||||
merr.Append(err)
|
||||
} else {
|
||||
logger.Info("Initialized notification backend", mlog.String("name", backend.Name()))
|
||||
}
|
||||
}
|
||||
return notify, merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// AddBackend adds a backend to the list that will be informed of any block changes.
|
||||
func (s *Service) AddBackend(backend Backend) error {
|
||||
if err := backend.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
s.backends = append(s.backends, backend)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown calls shutdown for all backends.
|
||||
func (s *Service) Shutdown() error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
merr := merror.New()
|
||||
for _, backend := range s.backends {
|
||||
if err := backend.ShutDown(); err != nil {
|
||||
merr.Append(err)
|
||||
}
|
||||
}
|
||||
s.backends = nil
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// BlockChanged should be called whenever a block is added/updated/deleted.
|
||||
// All backends are informed of the event.
|
||||
func (s *Service) BlockChanged(evt BlockChangeEvent) {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
for _, backend := range s.backends {
|
||||
if err := backend.BlockChanged(evt); err != nil {
|
||||
s.logger.Error("Error delivering notification",
|
||||
mlog.String("backend", backend.Name()),
|
||||
mlog.String("action", string(evt.Action)),
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package localpermissions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
permissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mocks"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type TestHelper struct {
|
||||
t *testing.T
|
||||
ctrl *gomock.Controller
|
||||
store *permissionsMocks.MockStore
|
||||
permissions *Service
|
||||
}
|
||||
|
||||
func SetupTestHelper(t *testing.T) *TestHelper {
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := permissionsMocks.NewMockStore(ctrl)
|
||||
return &TestHelper{
|
||||
t: t,
|
||||
ctrl: ctrl,
|
||||
store: mockStore,
|
||||
permissions: New(mockStore, mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)),
|
||||
}
|
||||
}
|
||||
|
||||
func (th *TestHelper) checkBoardPermissions(roleName string, member *model.BoardMember, hasPermissionTo, hasNotPermissionTo []*mm_model.Permission) {
|
||||
for _, p := range hasPermissionTo {
|
||||
th.t.Run(roleName+" "+p.Id, func(t *testing.T) {
|
||||
th.store.EXPECT().
|
||||
GetMemberForBoard(member.BoardID, member.UserID).
|
||||
Return(member, nil).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p)
|
||||
assert.True(t, hasPermission)
|
||||
})
|
||||
}
|
||||
|
||||
for _, p := range hasNotPermissionTo {
|
||||
th.t.Run(roleName+" "+p.Id, func(t *testing.T) {
|
||||
th.store.EXPECT().
|
||||
GetMemberForBoard(member.BoardID, member.UserID).
|
||||
Return(member, nil).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p)
|
||||
assert.False(t, hasPermission)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package localpermissions
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store permissions.Store
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
func New(store permissions.Store, logger mlog.LoggerIFace) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionTo(userID string, permission *mm_model.Permission) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
|
||||
if userID == "" || teamID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
if permission.Id == model.PermissionManageTeam.Id {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToChannel(userID, channelID string, permission *mm_model.Permission) bool {
|
||||
if userID == "" || channelID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool {
|
||||
if userID == "" || boardID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
member, err := s.store.GetMemberForBoard(boardID, userID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("error getting member for board",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("userID", userID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
switch member.MinimumRole {
|
||||
case "admin":
|
||||
member.SchemeAdmin = true
|
||||
case "editor":
|
||||
member.SchemeEditor = true
|
||||
case "commenter":
|
||||
member.SchemeCommenter = true
|
||||
case "viewer":
|
||||
member.SchemeViewer = true
|
||||
}
|
||||
|
||||
switch permission {
|
||||
case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionDeleteOthersComments:
|
||||
return member.SchemeAdmin
|
||||
case model.PermissionManageBoardCards, model.PermissionManageBoardProperties:
|
||||
return member.SchemeAdmin || member.SchemeEditor
|
||||
case model.PermissionCommentBoardCards:
|
||||
return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter
|
||||
case model.PermissionViewBoard:
|
||||
return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter || member.SchemeViewer
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package localpermissions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHasPermissionToTeam(t *testing.T) {
|
||||
th := SetupTestHelper(t)
|
||||
|
||||
t.Run("empty input should always unauthorize", func(t *testing.T) {
|
||||
assert.False(t, th.permissions.HasPermissionToTeam("", "team-id", model.PermissionManageBoardCards))
|
||||
assert.False(t, th.permissions.HasPermissionToTeam("user-id", "", model.PermissionManageBoardCards))
|
||||
assert.False(t, th.permissions.HasPermissionToTeam("user-id", "team-id", nil))
|
||||
})
|
||||
|
||||
t.Run("all users have all permissions on teams", func(t *testing.T) {
|
||||
hasPermission := th.permissions.HasPermissionToTeam("user-id", "team-id", model.PermissionManageBoardCards)
|
||||
assert.True(t, hasPermission)
|
||||
})
|
||||
|
||||
t.Run("no users have PermissionManageTeam on teams", func(t *testing.T) {
|
||||
hasPermission := th.permissions.HasPermissionToTeam("user-id", "team-id", model.PermissionManageTeam)
|
||||
assert.False(t, hasPermission)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasPermissionToBoard(t *testing.T) {
|
||||
th := SetupTestHelper(t)
|
||||
|
||||
t.Run("empty input should always unauthorize", func(t *testing.T) {
|
||||
assert.False(t, th.permissions.HasPermissionToBoard("", "board-id", model.PermissionManageBoardCards))
|
||||
assert.False(t, th.permissions.HasPermissionToBoard("user-id", "", model.PermissionManageBoardCards))
|
||||
assert.False(t, th.permissions.HasPermissionToBoard("user-id", "board-id", nil))
|
||||
})
|
||||
|
||||
t.Run("nonexistent user", func(t *testing.T) {
|
||||
userID := "user-id"
|
||||
boardID := "board-id"
|
||||
|
||||
th.store.EXPECT().
|
||||
GetMemberForBoard(boardID, userID).
|
||||
Return(nil, sql.ErrNoRows).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards)
|
||||
assert.False(t, hasPermission)
|
||||
})
|
||||
|
||||
t.Run("board admin", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: "user-id",
|
||||
BoardID: "board-id",
|
||||
SchemeAdmin: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionViewBoard,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{}
|
||||
|
||||
th.checkBoardPermissions("admin", member, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
|
||||
t.Run("board editor", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: "user-id",
|
||||
BoardID: "board-id",
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionViewBoard,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
}
|
||||
|
||||
th.checkBoardPermissions("editor", member, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
|
||||
t.Run("board commenter", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: "user-id",
|
||||
BoardID: "board-id",
|
||||
SchemeCommenter: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionViewBoard,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
th.checkBoardPermissions("commenter", member, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
|
||||
t.Run("board viewer", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: "user-id",
|
||||
BoardID: "board-id",
|
||||
SchemeViewer: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionViewBoard,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
th.checkBoardPermissions("viewer", member, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
|
||||
t.Run("Manage Team Permission ", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: "user-id",
|
||||
BoardID: "board-id",
|
||||
SchemeViewer: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionViewBoard,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
th.checkBoardPermissions("viewer", member, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
}
|
||||
101
server/boards/services/permissions/mmpermissions/helpers_test.go
Обычный файл
101
server/boards/services/permissions/mmpermissions/helpers_test.go
Обычный файл
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package mmpermissions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
mmpermissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mmpermissions/mocks"
|
||||
permissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mocks"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type TestHelper struct {
|
||||
t *testing.T
|
||||
ctrl *gomock.Controller
|
||||
store *permissionsMocks.MockStore
|
||||
api *mmpermissionsMocks.MockAPI
|
||||
permissions *Service
|
||||
}
|
||||
|
||||
func SetupTestHelper(t *testing.T) *TestHelper {
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := permissionsMocks.NewMockStore(ctrl)
|
||||
mockAPI := mmpermissionsMocks.NewMockAPI(ctrl)
|
||||
|
||||
return &TestHelper{
|
||||
t: t,
|
||||
ctrl: ctrl,
|
||||
store: mockStore,
|
||||
api: mockAPI,
|
||||
permissions: New(mockStore, mockAPI, mlog.CreateConsoleTestLogger(true, mlog.LvlError)),
|
||||
}
|
||||
}
|
||||
|
||||
func (th *TestHelper) checkBoardPermissions(roleName string, member *model.BoardMember, teamID string,
|
||||
hasPermissionTo, hasNotPermissionTo []*mm_model.Permission) {
|
||||
for _, p := range hasPermissionTo {
|
||||
th.t.Run(roleName+" "+p.Id, func(t *testing.T) {
|
||||
th.store.EXPECT().
|
||||
GetBoard(member.BoardID).
|
||||
Return(&model.Board{ID: member.BoardID, TeamID: teamID}, nil).
|
||||
Times(1)
|
||||
|
||||
th.api.EXPECT().
|
||||
HasPermissionToTeam(member.UserID, teamID, model.PermissionViewTeam).
|
||||
Return(true).
|
||||
Times(1)
|
||||
|
||||
th.store.EXPECT().
|
||||
GetMemberForBoard(member.BoardID, member.UserID).
|
||||
Return(member, nil).
|
||||
Times(1)
|
||||
|
||||
if !member.SchemeAdmin {
|
||||
th.api.EXPECT().
|
||||
HasPermissionToTeam(member.UserID, teamID, model.PermissionManageTeam).
|
||||
Return(roleName == "elevated-admin").
|
||||
Times(1)
|
||||
}
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p)
|
||||
assert.True(t, hasPermission)
|
||||
})
|
||||
}
|
||||
|
||||
for _, p := range hasNotPermissionTo {
|
||||
th.t.Run(roleName+" "+p.Id, func(t *testing.T) {
|
||||
th.store.EXPECT().
|
||||
GetBoard(member.BoardID).
|
||||
Return(&model.Board{ID: member.BoardID, TeamID: teamID}, nil).
|
||||
Times(1)
|
||||
|
||||
th.api.EXPECT().
|
||||
HasPermissionToTeam(member.UserID, teamID, model.PermissionViewTeam).
|
||||
Return(true).
|
||||
Times(1)
|
||||
|
||||
th.store.EXPECT().
|
||||
GetMemberForBoard(member.BoardID, member.UserID).
|
||||
Return(member, nil).
|
||||
Times(1)
|
||||
|
||||
if !member.SchemeAdmin {
|
||||
th.api.EXPECT().
|
||||
HasPermissionToTeam(member.UserID, teamID, model.PermissionManageTeam).
|
||||
Return(roleName == "elevated-admin").
|
||||
Times(1)
|
||||
}
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, p)
|
||||
assert.False(t, hasPermission)
|
||||
})
|
||||
}
|
||||
}
|
||||
128
server/boards/services/permissions/mmpermissions/mmpermissions.go
Обычный файл
128
server/boards/services/permissions/mmpermissions/mmpermissions.go
Обычный файл
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package mmpermissions
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type APIInterface interface {
|
||||
HasPermissionTo(userID string, permission *mm_model.Permission) bool
|
||||
HasPermissionToTeam(userID string, teamID string, permission *mm_model.Permission) bool
|
||||
HasPermissionToChannel(userID string, channelID string, permission *mm_model.Permission) bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store permissions.Store
|
||||
api APIInterface
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
func New(store permissions.Store, api APIInterface, logger mlog.LoggerIFace) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
api: api,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionTo(userID string, permission *mm_model.Permission) bool {
|
||||
if userID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
return s.api.HasPermissionTo(userID, permission)
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
|
||||
if userID == "" || teamID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
return s.api.HasPermissionToTeam(userID, teamID, permission)
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToChannel(userID, channelID string, permission *mm_model.Permission) bool {
|
||||
if userID == "" || channelID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
return s.api.HasPermissionToChannel(userID, channelID, permission)
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool {
|
||||
if userID == "" || boardID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
board, err := s.store.GetBoard(boardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
var boards []*model.Board
|
||||
boards, err = s.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(boards) == 0 {
|
||||
return false
|
||||
}
|
||||
board = boards[0]
|
||||
} else if err != nil {
|
||||
s.logger.Error("error getting board",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("userID", userID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// we need to check that the user has permission to see the team
|
||||
// regardless of its local permissions to the board
|
||||
if !s.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
return false
|
||||
}
|
||||
member, err := s.store.GetMemberForBoard(boardID, userID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("error getting member for board",
|
||||
mlog.String("boardID", boardID),
|
||||
mlog.String("userID", userID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
switch member.MinimumRole {
|
||||
case "admin":
|
||||
member.SchemeAdmin = true
|
||||
case "editor":
|
||||
member.SchemeEditor = true
|
||||
case "commenter":
|
||||
member.SchemeCommenter = true
|
||||
case "viewer":
|
||||
member.SchemeViewer = true
|
||||
}
|
||||
|
||||
// Admins become member of boards, but get minimal role
|
||||
// if they are a System/Team Admin (model.PermissionManageTeam)
|
||||
// elevate their permissions
|
||||
if !member.SchemeAdmin && s.HasPermissionToTeam(userID, board.TeamID, model.PermissionManageTeam) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch permission {
|
||||
case model.PermissionManageBoardType, model.PermissionDeleteBoard, model.PermissionManageBoardRoles, model.PermissionShareBoard, model.PermissionDeleteOthersComments:
|
||||
return member.SchemeAdmin
|
||||
case model.PermissionManageBoardCards, model.PermissionManageBoardProperties:
|
||||
return member.SchemeAdmin || member.SchemeEditor
|
||||
case model.PermissionCommentBoardCards:
|
||||
return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter
|
||||
case model.PermissionViewBoard:
|
||||
return member.SchemeAdmin || member.SchemeEditor || member.SchemeCommenter || member.SchemeViewer
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
//go:generate mockgen -copyright_file=../../../../copyright.txt -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API
|
||||
package mmpermissions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
testTeamID = "team-id"
|
||||
testBoardID = "board-id"
|
||||
testUserID = "user-id"
|
||||
)
|
||||
|
||||
func TestHasPermissionsToTeam(t *testing.T) {
|
||||
th := SetupTestHelper(t)
|
||||
|
||||
t.Run("empty input should always unauthorize", func(t *testing.T) {
|
||||
assert.False(t, th.permissions.HasPermissionToTeam("", testTeamID, model.PermissionManageBoardCards))
|
||||
assert.False(t, th.permissions.HasPermissionToTeam(testUserID, "", model.PermissionManageBoardCards))
|
||||
assert.False(t, th.permissions.HasPermissionToTeam(testUserID, testTeamID, nil))
|
||||
})
|
||||
|
||||
t.Run("should authorize if the plugin API does", func(t *testing.T) {
|
||||
userID := testUserID
|
||||
teamID := testTeamID
|
||||
|
||||
th.api.EXPECT().
|
||||
HasPermissionToTeam(userID, teamID, model.PermissionViewTeam).
|
||||
Return(true).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam)
|
||||
assert.True(t, hasPermission)
|
||||
})
|
||||
|
||||
t.Run("should not authorize if the plugin API doesn't", func(t *testing.T) {
|
||||
userID := testUserID
|
||||
teamID := testTeamID
|
||||
|
||||
th.api.EXPECT().
|
||||
HasPermissionToTeam(userID, teamID, model.PermissionViewTeam).
|
||||
Return(false).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam)
|
||||
assert.False(t, hasPermission)
|
||||
})
|
||||
}
|
||||
|
||||
// test case for user removed.
|
||||
func TestHasPermissionToBoard(t *testing.T) {
|
||||
th := SetupTestHelper(t)
|
||||
|
||||
t.Run("empty input should always unauthorize", func(t *testing.T) {
|
||||
assert.False(t, th.permissions.HasPermissionToBoard("", testBoardID, model.PermissionManageBoardCards))
|
||||
assert.False(t, th.permissions.HasPermissionToBoard(testUserID, "", model.PermissionManageBoardCards))
|
||||
assert.False(t, th.permissions.HasPermissionToBoard(testUserID, testBoardID, nil))
|
||||
})
|
||||
|
||||
userID := testUserID
|
||||
boardID := testBoardID
|
||||
teamID := testTeamID
|
||||
|
||||
t.Run("nonexistent member", func(t *testing.T) {
|
||||
th.store.EXPECT().
|
||||
GetBoard(boardID).
|
||||
Return(&model.Board{ID: boardID, TeamID: teamID}, nil).
|
||||
Times(1)
|
||||
|
||||
th.api.EXPECT().
|
||||
HasPermissionToTeam(userID, teamID, model.PermissionViewTeam).
|
||||
Return(true).
|
||||
Times(1)
|
||||
|
||||
th.store.EXPECT().
|
||||
GetMemberForBoard(boardID, userID).
|
||||
Return(nil, sql.ErrNoRows).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards)
|
||||
assert.False(t, hasPermission)
|
||||
})
|
||||
|
||||
t.Run("nonexistent board", func(t *testing.T) {
|
||||
th.store.EXPECT().
|
||||
GetBoard(boardID).
|
||||
Return(nil, sql.ErrNoRows).
|
||||
Times(1)
|
||||
|
||||
th.store.EXPECT().
|
||||
GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true}).
|
||||
Return(nil, sql.ErrNoRows).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards)
|
||||
assert.False(t, hasPermission)
|
||||
})
|
||||
|
||||
t.Run("user that has been removed from the team", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeAdmin: true,
|
||||
}
|
||||
|
||||
th.store.EXPECT().
|
||||
GetBoard(boardID).
|
||||
Return(&model.Board{ID: boardID, TeamID: teamID}, nil).
|
||||
Times(1)
|
||||
|
||||
th.api.EXPECT().
|
||||
HasPermissionToTeam(userID, teamID, model.PermissionViewTeam).
|
||||
Return(true).
|
||||
Times(1)
|
||||
|
||||
th.store.EXPECT().
|
||||
GetMemberForBoard(member.BoardID, member.UserID).
|
||||
Return(member, nil).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(member.UserID, member.BoardID, model.PermissionViewBoard)
|
||||
assert.True(t, hasPermission)
|
||||
})
|
||||
|
||||
t.Run("board admin", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeAdmin: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionViewBoard,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{}
|
||||
|
||||
th.checkBoardPermissions("admin", member, teamID, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
|
||||
t.Run("board editor", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionViewBoard,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
}
|
||||
|
||||
th.checkBoardPermissions("editor", member, teamID, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
|
||||
t.Run("board commenter", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeCommenter: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionViewBoard,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
th.checkBoardPermissions("commenter", member, teamID, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
|
||||
t.Run("board viewer", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeViewer: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionViewBoard,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
th.checkBoardPermissions("viewer", member, teamID, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
|
||||
t.Run("elevate board viewer permissions", func(t *testing.T) {
|
||||
member := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeViewer: true,
|
||||
}
|
||||
|
||||
hasPermissionTo := []*mm_model.Permission{
|
||||
model.PermissionManageBoardType,
|
||||
model.PermissionDeleteBoard,
|
||||
model.PermissionManageBoardRoles,
|
||||
model.PermissionShareBoard,
|
||||
model.PermissionManageBoardCards,
|
||||
model.PermissionViewBoard,
|
||||
model.PermissionManageBoardProperties,
|
||||
}
|
||||
|
||||
hasNotPermissionTo := []*mm_model.Permission{}
|
||||
th.checkBoardPermissions("elevated-admin", member, teamID, hasPermissionTo, hasNotPermissionTo)
|
||||
})
|
||||
}
|
||||
2659
server/boards/services/permissions/mmpermissions/mocks/mockpluginapi.go
Обычный файл
2659
server/boards/services/permissions/mmpermissions/mocks/mockpluginapi.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
83
server/boards/services/permissions/mocks/mockstore.go
Обычный файл
83
server/boards/services/permissions/mocks/mockstore.go
Обычный файл
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/mattermost/mattermost-server/v6/server/boards/services/permissions (interfaces: Store)
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
model "github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
// MockStore is a mock of Store interface.
|
||||
type MockStore struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockStoreMockRecorder
|
||||
}
|
||||
|
||||
// MockStoreMockRecorder is the mock recorder for MockStore.
|
||||
type MockStoreMockRecorder struct {
|
||||
mock *MockStore
|
||||
}
|
||||
|
||||
// NewMockStore creates a new mock instance.
|
||||
func NewMockStore(ctrl *gomock.Controller) *MockStore {
|
||||
mock := &MockStore{ctrl: ctrl}
|
||||
mock.recorder = &MockStoreMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockStore) EXPECT() *MockStoreMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetBoard mocks base method.
|
||||
func (m *MockStore) GetBoard(arg0 string) (*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetBoard", arg0)
|
||||
ret0, _ := ret[0].(*model.Board)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetBoard indicates an expected call of GetBoard.
|
||||
func (mr *MockStoreMockRecorder) GetBoard(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoard", reflect.TypeOf((*MockStore)(nil).GetBoard), arg0)
|
||||
}
|
||||
|
||||
// GetBoardHistory mocks base method.
|
||||
func (m *MockStore) GetBoardHistory(arg0 string, arg1 model.QueryBoardHistoryOptions) ([]*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetBoardHistory", arg0, arg1)
|
||||
ret0, _ := ret[0].([]*model.Board)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetBoardHistory indicates an expected call of GetBoardHistory.
|
||||
func (mr *MockStoreMockRecorder) GetBoardHistory(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardHistory", reflect.TypeOf((*MockStore)(nil).GetBoardHistory), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetMemberForBoard mocks base method.
|
||||
func (m *MockStore) GetMemberForBoard(arg0, arg1 string) (*model.BoardMember, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetMemberForBoard", arg0, arg1)
|
||||
ret0, _ := ret[0].(*model.BoardMember)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetMemberForBoard indicates an expected call of GetMemberForBoard.
|
||||
func (mr *MockStoreMockRecorder) GetMemberForBoard(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMemberForBoard", reflect.TypeOf((*MockStore)(nil).GetMemberForBoard), arg0, arg1)
|
||||
}
|
||||
25
server/boards/services/permissions/permissions.go
Обычный файл
25
server/boards/services/permissions/permissions.go
Обычный файл
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
//go:generate mockgen -copyright_file=../../../copyright.txt -destination=mocks/mockstore.go -package mocks . Store
|
||||
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
type PermissionsService interface {
|
||||
HasPermissionTo(userID string, permission *mm_model.Permission) bool
|
||||
HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool
|
||||
HasPermissionToChannel(userID, channelID string, permission *mm_model.Permission) bool
|
||||
HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
GetBoard(boardID string) (*model.Board, error)
|
||||
GetMemberForBoard(boardID, userID string) (*model.BoardMember, error)
|
||||
GetBoardHistory(boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error)
|
||||
}
|
||||
75
server/boards/services/scheduler/scheduler.go
Обычный файл
75
server/boards/services/scheduler/scheduler.go
Обычный файл
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TaskFunc func()
|
||||
|
||||
type ScheduledTask struct {
|
||||
Name string `json:"name"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
Recurring bool `json:"recurring"`
|
||||
function func()
|
||||
cancel chan struct{}
|
||||
cancelled chan struct{}
|
||||
}
|
||||
|
||||
func CreateTask(name string, function TaskFunc, timeToExecution time.Duration) *ScheduledTask {
|
||||
return createTask(name, function, timeToExecution, false)
|
||||
}
|
||||
|
||||
func CreateRecurringTask(name string, function TaskFunc, interval time.Duration) *ScheduledTask {
|
||||
return createTask(name, function, interval, true)
|
||||
}
|
||||
|
||||
func createTask(name string, function TaskFunc, interval time.Duration, recurring bool) *ScheduledTask {
|
||||
task := &ScheduledTask{
|
||||
Name: name,
|
||||
Interval: interval,
|
||||
Recurring: recurring,
|
||||
function: function,
|
||||
cancel: make(chan struct{}),
|
||||
cancelled: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(task.cancelled)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
function()
|
||||
case <-task.cancel:
|
||||
return
|
||||
}
|
||||
|
||||
if !task.Recurring {
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
func (task *ScheduledTask) Cancel() {
|
||||
close(task.cancel)
|
||||
<-task.cancelled
|
||||
}
|
||||
|
||||
func (task *ScheduledTask) String() string {
|
||||
return fmt.Sprintf(
|
||||
"%s\nInterval: %s\nRecurring: %t\n",
|
||||
task.Name,
|
||||
task.Interval.String(),
|
||||
task.Recurring,
|
||||
)
|
||||
}
|
||||
81
server/boards/services/scheduler/scheduler_test.go
Обычный файл
81
server/boards/services/scheduler/scheduler_test.go
Обычный файл
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateTask(t *testing.T) {
|
||||
taskName := "Test Task"
|
||||
taskTime := time.Millisecond * 200
|
||||
taskWait := time.Millisecond * 100
|
||||
|
||||
executionCount := new(int32)
|
||||
testFunc := func() {
|
||||
atomic.AddInt32(executionCount, 1)
|
||||
}
|
||||
|
||||
task := CreateTask(taskName, testFunc, taskTime)
|
||||
|
||||
assert.EqualValues(t, 0, atomic.LoadInt32(executionCount))
|
||||
|
||||
time.Sleep(taskTime + taskWait)
|
||||
|
||||
assert.EqualValues(t, 1, atomic.LoadInt32(executionCount))
|
||||
assert.Equal(t, taskName, task.Name)
|
||||
assert.Equal(t, taskTime, task.Interval)
|
||||
assert.False(t, task.Recurring)
|
||||
}
|
||||
|
||||
func TestCreateRecurringTask(t *testing.T) {
|
||||
taskName := "Test Recurring Task"
|
||||
taskTime := time.Millisecond * 500
|
||||
taskWait := time.Millisecond * 200
|
||||
|
||||
executionCount := new(int32)
|
||||
testFunc := func() {
|
||||
atomic.AddInt32(executionCount, 1)
|
||||
}
|
||||
|
||||
task := CreateRecurringTask(taskName, testFunc, taskTime)
|
||||
|
||||
assert.EqualValues(t, 0, atomic.LoadInt32(executionCount))
|
||||
|
||||
time.Sleep(taskTime + taskWait)
|
||||
|
||||
assert.EqualValues(t, 1, atomic.LoadInt32(executionCount))
|
||||
|
||||
time.Sleep(taskTime)
|
||||
|
||||
assert.EqualValues(t, 2, atomic.LoadInt32(executionCount))
|
||||
assert.Equal(t, taskName, task.Name)
|
||||
assert.Equal(t, taskTime, task.Interval)
|
||||
assert.True(t, task.Recurring)
|
||||
|
||||
task.Cancel()
|
||||
}
|
||||
|
||||
func TestCancelTask(t *testing.T) {
|
||||
taskName := "Test Task"
|
||||
taskTime := time.Millisecond * 100
|
||||
taskWait := time.Millisecond * 100
|
||||
|
||||
executionCount := new(int32)
|
||||
testFunc := func() {
|
||||
atomic.AddInt32(executionCount, 1)
|
||||
}
|
||||
|
||||
task := CreateTask(taskName, testFunc, taskTime)
|
||||
|
||||
assert.EqualValues(t, 0, atomic.LoadInt32(executionCount))
|
||||
task.Cancel()
|
||||
|
||||
time.Sleep(taskTime + taskWait)
|
||||
assert.EqualValues(t, 0, atomic.LoadInt32(executionCount))
|
||||
}
|
||||
266
server/boards/services/store/generators/main.go
Обычный файл
266
server/boards/services/store/generators/main.go
Обычный файл
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const (
|
||||
WithTransactionComment = "@withTransaction"
|
||||
ErrorType = "error"
|
||||
StringType = "string"
|
||||
IntType = "int"
|
||||
Int32Type = "int32"
|
||||
Int64Type = "int64"
|
||||
BoolType = "bool"
|
||||
)
|
||||
|
||||
func isError(typeName string) bool {
|
||||
return strings.Contains(typeName, ErrorType)
|
||||
}
|
||||
|
||||
func isString(typeName string) bool {
|
||||
return typeName == StringType
|
||||
}
|
||||
|
||||
func isInt(typeName string) bool {
|
||||
return typeName == IntType || typeName == Int32Type || typeName == Int64Type
|
||||
}
|
||||
|
||||
func isBool(typeName string) bool {
|
||||
return typeName == BoolType
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := buildTransactionalStore(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTransactionalStore() error {
|
||||
code, err := generateLayer("TransactionalStore", "transactional_store.go.tmpl")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formatedCode, err := format.Source(code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path.Join("sqlstore/public_methods.go"), formatedCode, 0644) //nolint:gosec
|
||||
}
|
||||
|
||||
type methodParam struct {
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
|
||||
type methodData struct {
|
||||
Params []methodParam
|
||||
Results []string
|
||||
WithTransaction bool
|
||||
}
|
||||
|
||||
type storeMetadata struct {
|
||||
Name string
|
||||
Methods map[string]methodData
|
||||
}
|
||||
|
||||
var blacklistedStoreMethodNames = map[string]bool{
|
||||
"Shutdown": true,
|
||||
"DBType": true,
|
||||
"DBVersion": true,
|
||||
}
|
||||
|
||||
func extractMethodMetadata(method *ast.Field, src []byte) methodData {
|
||||
params := []methodParam{}
|
||||
results := []string{}
|
||||
withTransaction := false
|
||||
ast.Inspect(method.Type, func(expr ast.Node) bool {
|
||||
//nolint:gocritic
|
||||
switch e := expr.(type) {
|
||||
case *ast.FuncType:
|
||||
if method.Doc != nil {
|
||||
for _, comment := range method.Doc.List {
|
||||
if strings.Contains(comment.Text, WithTransactionComment) {
|
||||
withTransaction = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.Params != nil {
|
||||
for _, param := range e.Params.List {
|
||||
for _, paramName := range param.Names {
|
||||
params = append(params, methodParam{Name: paramName.Name, Type: string(src[param.Type.Pos()-1 : param.Type.End()-1])})
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.Results != nil {
|
||||
for _, result := range e.Results.List {
|
||||
results = append(results, string(src[result.Type.Pos()-1:result.Type.End()-1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return methodData{Params: params, Results: results, WithTransaction: withTransaction}
|
||||
}
|
||||
|
||||
func extractStoreMetadata() (*storeMetadata, error) {
|
||||
// Create the AST by parsing src.
|
||||
fset := token.NewFileSet() // positions are relative to fset
|
||||
|
||||
file, err := os.Open("store.go")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to open store/store.go file: %w", err)
|
||||
}
|
||||
src, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Close()
|
||||
f, err := parser.ParseFile(fset, "", src, parser.AllErrors|parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata := storeMetadata{Methods: map[string]methodData{}}
|
||||
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
//nolint:gocritic
|
||||
switch x := n.(type) {
|
||||
case *ast.TypeSpec:
|
||||
if x.Name.Name == "Store" {
|
||||
for _, method := range x.Type.(*ast.InterfaceType).Methods.List {
|
||||
methodName := method.Names[0].Name
|
||||
if _, ok := blacklistedStoreMethodNames[methodName]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
metadata.Methods[methodName] = extractMethodMetadata(method, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
func generateLayer(name, templateFile string) ([]byte, error) {
|
||||
out := bytes.NewBufferString("")
|
||||
metadata, err := extractStoreMetadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata.Name = name
|
||||
|
||||
myFuncs := template.FuncMap{
|
||||
"joinResultsForSignature": func(results []string) string {
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(results) == 1 {
|
||||
return strings.Join(results, ", ")
|
||||
}
|
||||
return fmt.Sprintf("(%s)", strings.Join(results, ", "))
|
||||
},
|
||||
"genResultsVars": func(results []string, withNilError bool) string {
|
||||
vars := []string{}
|
||||
for i, typeName := range results {
|
||||
switch {
|
||||
case isError(typeName):
|
||||
if withNilError {
|
||||
vars = append(vars, "nil")
|
||||
} else {
|
||||
vars = append(vars, "err")
|
||||
}
|
||||
case i == 0:
|
||||
vars = append(vars, "result")
|
||||
default:
|
||||
vars = append(vars, fmt.Sprintf("resultVar%d", i))
|
||||
}
|
||||
}
|
||||
return strings.Join(vars, ", ")
|
||||
},
|
||||
"errorPresent": func(results []string) bool {
|
||||
for _, typeName := range results {
|
||||
if isError(typeName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
"errorVar": func(results []string) string {
|
||||
for _, typeName := range results {
|
||||
if isError(typeName) {
|
||||
return "err"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"joinParams": func(params []methodParam) string {
|
||||
paramsNames := make([]string, 0, len(params))
|
||||
for _, param := range params {
|
||||
tParams := ""
|
||||
if strings.HasPrefix(param.Type, "...") {
|
||||
tParams = "..."
|
||||
}
|
||||
paramsNames = append(paramsNames, param.Name+tParams)
|
||||
}
|
||||
return strings.Join(paramsNames, ", ")
|
||||
},
|
||||
"joinParamsWithType": func(params []methodParam) string {
|
||||
paramsWithType := []string{}
|
||||
for _, param := range params {
|
||||
switch param.Type {
|
||||
case "Container":
|
||||
paramsWithType = append(paramsWithType, fmt.Sprintf("%s store.%s", param.Name, param.Type))
|
||||
default:
|
||||
paramsWithType = append(paramsWithType, fmt.Sprintf("%s %s", param.Name, param.Type))
|
||||
}
|
||||
}
|
||||
return strings.Join(paramsWithType, ", ")
|
||||
},
|
||||
"renameStoreMethod": func(methodName string) string {
|
||||
return strings.ToLower(methodName[0:1]) + methodName[1:]
|
||||
},
|
||||
"genErrorResultsVars": func(results []string, errName string) string {
|
||||
vars := []string{}
|
||||
for _, typeName := range results {
|
||||
switch {
|
||||
case isError(typeName):
|
||||
vars = append(vars, errName)
|
||||
case isString(typeName):
|
||||
vars = append(vars, "\"\"")
|
||||
case isInt(typeName):
|
||||
vars = append(vars, "0")
|
||||
case isBool(typeName):
|
||||
vars = append(vars, "false")
|
||||
default:
|
||||
vars = append(vars, "nil")
|
||||
}
|
||||
}
|
||||
return strings.Join(vars, ", ")
|
||||
},
|
||||
}
|
||||
|
||||
t := template.Must(template.New(templateFile).Funcs(myFuncs).ParseFiles("generators/" + templateFile))
|
||||
if err = t.Execute(out, metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Code generated by "make generate" from the Store interface
|
||||
// DO NOT EDIT
|
||||
|
||||
// To add a public method, create an entry in the Store interface,
|
||||
// prefix it with a @withTransaction comment if you need it to be
|
||||
// transactional and then add a private method in the store itself
|
||||
// with db sq.BaseRunner as the first parameter before running `make
|
||||
// generate`
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
{{range $index, $element := .Methods}}
|
||||
func (s *SQLStore) {{$index}}({{$element.Params | joinParamsWithType}}) {{$element.Results | joinResultsForSignature}} {
|
||||
{{- if $element.WithTransaction}}
|
||||
tx, txErr := s.db.BeginTx(context.Background(), nil)
|
||||
if txErr != nil {
|
||||
return {{ genErrorResultsVars $element.Results "txErr"}}
|
||||
}
|
||||
|
||||
{{- if $element.Results | len | eq 0}}
|
||||
s.{{$index | renameStoreMethod}}(tx, {{$element.Params | joinParams}})
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return {{ genErrorResultsVars $element.Results "err"}}
|
||||
}
|
||||
{{else}}
|
||||
{{genResultsVars $element.Results false }} := s.{{$index | renameStoreMethod}}(tx, {{$element.Params | joinParams}})
|
||||
{{- if $element.Results | errorPresent }}
|
||||
if {{$element.Results | errorVar}} != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "{{$index}}"))
|
||||
}
|
||||
return {{ genErrorResultsVars $element.Results "err"}}
|
||||
}
|
||||
{{end}}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return {{ genErrorResultsVars $element.Results "err"}}
|
||||
}
|
||||
|
||||
return {{ genResultsVars $element.Results true -}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
return s.{{$index | renameStoreMethod}}(s.db, {{$element.Params | joinParams}})
|
||||
{{end}}
|
||||
}
|
||||
{{end}}
|
||||
1353
server/boards/services/store/mattermostauthlayer/mattermostauthlayer.go
Обычный файл
1353
server/boards/services/store/mattermostauthlayer/mattermostauthlayer.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package mattermostauthlayer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
mockservicesapi "github.com/mattermost/mattermost-server/v6/server/boards/model/mocks"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var errTest = errors.New("failed to patch bot")
|
||||
|
||||
func TestGetBoardsBotID(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
|
||||
|
||||
mmAuthLayer, _ := New("test", nil, nil, mlog.CreateConsoleTestLogger(true, mlog.LvlError), servicesAPI, "")
|
||||
|
||||
servicesAPI.EXPECT().EnsureBot(model.FocalboardBot).Return("", errTest)
|
||||
_, err := mmAuthLayer.getBoardsBotID()
|
||||
require.NotEmpty(t, err)
|
||||
|
||||
servicesAPI.EXPECT().EnsureBot(model.FocalboardBot).Return("TestBotID", nil).Times(1)
|
||||
botID, err := mmAuthLayer.getBoardsBotID()
|
||||
require.Empty(t, err)
|
||||
require.NotEmpty(t, botID)
|
||||
require.Equal(t, "TestBotID", botID)
|
||||
|
||||
// Call again, should not call "EnsureBot"
|
||||
botID, err = mmAuthLayer.getBoardsBotID()
|
||||
require.Empty(t, err)
|
||||
require.NotEmpty(t, botID)
|
||||
require.Equal(t, "TestBotID", botID)
|
||||
}
|
||||
1854
server/boards/services/store/mockstore/mockstore.go
Обычный файл
1854
server/boards/services/store/mockstore/mockstore.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
1042
server/boards/services/store/sqlstore/blocks.go
Обычный файл
1042
server/boards/services/store/sqlstore/blocks.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
905
server/boards/services/store/sqlstore/board.go
Обычный файл
905
server/boards/services/store/sqlstore/board.go
Обычный файл
@@ -0,0 +1,905 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
//nolint:gosec
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func boardFields(tableAlias string) []string {
|
||||
if tableAlias != "" && !strings.HasSuffix(tableAlias, ".") {
|
||||
tableAlias += "."
|
||||
}
|
||||
|
||||
return []string{
|
||||
tableAlias + "id",
|
||||
tableAlias + "team_id",
|
||||
"COALESCE(" + tableAlias + "channel_id, '')",
|
||||
"COALESCE(" + tableAlias + "created_by, '')",
|
||||
tableAlias + "modified_by",
|
||||
tableAlias + "type",
|
||||
tableAlias + "minimum_role",
|
||||
tableAlias + "title",
|
||||
tableAlias + "description",
|
||||
tableAlias + "icon",
|
||||
tableAlias + "show_description",
|
||||
tableAlias + "is_template",
|
||||
tableAlias + "template_version",
|
||||
"COALESCE(" + tableAlias + "properties, '{}')",
|
||||
"COALESCE(" + tableAlias + "card_properties, '[]')",
|
||||
tableAlias + "create_at",
|
||||
tableAlias + "update_at",
|
||||
tableAlias + "delete_at",
|
||||
}
|
||||
}
|
||||
|
||||
func boardHistoryFields() []string {
|
||||
fields := []string{
|
||||
"id",
|
||||
"team_id",
|
||||
"COALESCE(channel_id, '')",
|
||||
"COALESCE(created_by, '')",
|
||||
"COALESCE(modified_by, '')",
|
||||
"type",
|
||||
"minimum_role",
|
||||
"COALESCE(title, '')",
|
||||
"COALESCE(description, '')",
|
||||
"COALESCE(icon, '')",
|
||||
"COALESCE(show_description, false)",
|
||||
"COALESCE(is_template, false)",
|
||||
"template_version",
|
||||
"COALESCE(properties, '{}')",
|
||||
"COALESCE(card_properties, '[]')",
|
||||
"COALESCE(create_at, 0)",
|
||||
"COALESCE(update_at, 0)",
|
||||
"COALESCE(delete_at, 0)",
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
var boardMemberFields = []string{
|
||||
"COALESCE(B.minimum_role, '')",
|
||||
"BM.board_id",
|
||||
"BM.user_id",
|
||||
"BM.roles",
|
||||
"BM.scheme_admin",
|
||||
"BM.scheme_editor",
|
||||
"BM.scheme_commenter",
|
||||
"BM.scheme_viewer",
|
||||
}
|
||||
|
||||
func (s *SQLStore) boardsFromRows(rows *sql.Rows) ([]*model.Board, error) {
|
||||
boards := []*model.Board{}
|
||||
|
||||
for rows.Next() {
|
||||
var board model.Board
|
||||
var propertiesBytes []byte
|
||||
var cardPropertiesBytes []byte
|
||||
|
||||
err := rows.Scan(
|
||||
&board.ID,
|
||||
&board.TeamID,
|
||||
&board.ChannelID,
|
||||
&board.CreatedBy,
|
||||
&board.ModifiedBy,
|
||||
&board.Type,
|
||||
&board.MinimumRole,
|
||||
&board.Title,
|
||||
&board.Description,
|
||||
&board.Icon,
|
||||
&board.ShowDescription,
|
||||
&board.IsTemplate,
|
||||
&board.TemplateVersion,
|
||||
&propertiesBytes,
|
||||
&cardPropertiesBytes,
|
||||
&board.CreateAt,
|
||||
&board.UpdateAt,
|
||||
&board.DeleteAt,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("boardsFromRows scan error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(propertiesBytes, &board.Properties)
|
||||
if err != nil {
|
||||
s.logger.Error("board properties unmarshal error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(cardPropertiesBytes, &board.CardProperties)
|
||||
if err != nil {
|
||||
s.logger.Error("board card properties unmarshal error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boards = append(boards, &board)
|
||||
}
|
||||
|
||||
return boards, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) boardMembersFromRows(rows *sql.Rows) ([]*model.BoardMember, error) {
|
||||
boardMembers := []*model.BoardMember{}
|
||||
|
||||
for rows.Next() {
|
||||
var boardMember model.BoardMember
|
||||
|
||||
err := rows.Scan(
|
||||
&boardMember.MinimumRole,
|
||||
&boardMember.BoardID,
|
||||
&boardMember.UserID,
|
||||
&boardMember.Roles,
|
||||
&boardMember.SchemeAdmin,
|
||||
&boardMember.SchemeEditor,
|
||||
&boardMember.SchemeCommenter,
|
||||
&boardMember.SchemeViewer,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardMembers = append(boardMembers, &boardMember)
|
||||
}
|
||||
|
||||
return boardMembers, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) boardMemberHistoryEntriesFromRows(rows *sql.Rows) ([]*model.BoardMemberHistoryEntry, error) {
|
||||
boardMemberHistoryEntries := []*model.BoardMemberHistoryEntry{}
|
||||
|
||||
for rows.Next() {
|
||||
var boardMemberHistoryEntry model.BoardMemberHistoryEntry
|
||||
var insertAt sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&boardMemberHistoryEntry.BoardID,
|
||||
&boardMemberHistoryEntry.UserID,
|
||||
&boardMemberHistoryEntry.Action,
|
||||
&insertAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// parse the insert_at timestamp which is different based on database type.
|
||||
dateTemplate := "2006-01-02T15:04:05Z0700"
|
||||
if s.dbType == model.MysqlDBType {
|
||||
dateTemplate = "2006-01-02 15:04:05.000000"
|
||||
}
|
||||
ts, err := time.Parse(dateTemplate, insertAt.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse datetime '%s' for board_members_history scan: %w", insertAt.String, err)
|
||||
}
|
||||
boardMemberHistoryEntry.InsertAt = ts
|
||||
|
||||
boardMemberHistoryEntries = append(boardMemberHistoryEntries, &boardMemberHistoryEntry)
|
||||
}
|
||||
|
||||
return boardMemberHistoryEntries, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardByCondition(db sq.BaseRunner, conditions ...interface{}) (*model.Board, error) {
|
||||
boards, err := s.getBoardsByCondition(db, conditions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return boards[0], nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardsByCondition(db sq.BaseRunner, conditions ...interface{}) ([]*model.Board, error) {
|
||||
return s.getBoardsFieldsByCondition(db, boardFields(""), conditions...)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardsFieldsByCondition(db sq.BaseRunner, fields []string, conditions ...interface{}) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(fields...).
|
||||
From(s.tablePrefix + "boards")
|
||||
for _, c := range conditions {
|
||||
query = query.Where(c)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getBoardsFieldsByCondition ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
boards, err := s.boardsFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(boards) == 0 {
|
||||
return nil, model.NewErrNotFound("boards")
|
||||
}
|
||||
|
||||
return boards, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoard(db sq.BaseRunner, boardID string) (*model.Board, error) {
|
||||
return s.getBoardByCondition(db, sq.Eq{"id": boardID})
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardsForUserAndTeam(db sq.BaseRunner, userID, teamID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
Distinct().
|
||||
From(s.tablePrefix + "boards as b").
|
||||
LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
|
||||
Where(sq.Eq{"b.team_id": teamID}).
|
||||
Where(sq.Eq{"b.is_template": false})
|
||||
|
||||
if includePublicBoards {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"b.type": model.BoardTypeOpen},
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
})
|
||||
} else {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
})
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getBoardsForUserAndTeam ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardsInTeamByIds(db sq.BaseRunner, boardIDs []string, teamID string) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards as b").
|
||||
Where(sq.Eq{"b.team_id": teamID}).
|
||||
Where(sq.Eq{"b.id": boardIDs})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getBoardsInTeamByIds ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
boards, err := s.boardsFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(boards) != len(boardIDs) {
|
||||
s.logger.Warn("getBoardsInTeamByIds mismatched number of boards found",
|
||||
mlog.Int("len(boards)", len(boards)),
|
||||
mlog.Int("len(boardIDs)", len(boardIDs)),
|
||||
)
|
||||
return boards, model.NewErrNotAllFound("board", boardIDs)
|
||||
}
|
||||
|
||||
return boards, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) insertBoard(db sq.BaseRunner, board *model.Board, userID string) (*model.Board, error) {
|
||||
// Generate tracking IDs for in-built templates
|
||||
if board.IsTemplate && board.TeamID == model.GlobalTeamID {
|
||||
//nolint:gosec
|
||||
// we don't need cryptographically secure hash, so MD5 is fine
|
||||
board.Properties["trackingTemplateId"] = fmt.Sprintf("%x", md5.Sum([]byte(board.Title)))
|
||||
}
|
||||
|
||||
propertiesBytes, err := s.MarshalJSONB(board.Properties)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"failed to marshal board.Properties",
|
||||
mlog.String("board_id", board.ID),
|
||||
mlog.String("board.Properties", fmt.Sprintf("%v", board.Properties)),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cardPropertiesBytes, err := s.MarshalJSONB(board.CardProperties)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"failed to marshal board.CardProperties",
|
||||
mlog.String("board_id", board.ID),
|
||||
mlog.String("board.CardProperties", fmt.Sprintf("%v", board.CardProperties)),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingBoard, err := s.getBoard(db, board.ID)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, fmt.Errorf("insertBoard error occurred while fetching existing board %s: %w", board.ID, err)
|
||||
}
|
||||
|
||||
insertQuery := s.getQueryBuilder(db).Insert("").
|
||||
Columns(boardFields("")...)
|
||||
|
||||
now := utils.GetMillis()
|
||||
board.ModifiedBy = userID
|
||||
board.UpdateAt = now
|
||||
|
||||
insertQueryValues := map[string]interface{}{
|
||||
"id": board.ID,
|
||||
"team_id": board.TeamID,
|
||||
"channel_id": board.ChannelID,
|
||||
"created_by": board.CreatedBy,
|
||||
"modified_by": board.ModifiedBy,
|
||||
"type": board.Type,
|
||||
"title": board.Title,
|
||||
"minimum_role": board.MinimumRole,
|
||||
"description": board.Description,
|
||||
"icon": board.Icon,
|
||||
"show_description": board.ShowDescription,
|
||||
"is_template": board.IsTemplate,
|
||||
"template_version": board.TemplateVersion,
|
||||
"properties": propertiesBytes,
|
||||
"card_properties": cardPropertiesBytes,
|
||||
"create_at": board.CreateAt,
|
||||
"update_at": board.UpdateAt,
|
||||
"delete_at": board.DeleteAt,
|
||||
}
|
||||
|
||||
if existingBoard != nil {
|
||||
query := s.getQueryBuilder(db).Update(s.tablePrefix+"boards").
|
||||
Where(sq.Eq{"id": board.ID}).
|
||||
Set("modified_by", board.ModifiedBy).
|
||||
Set("type", board.Type).
|
||||
Set("channel_id", board.ChannelID).
|
||||
Set("minimum_role", board.MinimumRole).
|
||||
Set("title", board.Title).
|
||||
Set("description", board.Description).
|
||||
Set("icon", board.Icon).
|
||||
Set("show_description", board.ShowDescription).
|
||||
Set("is_template", board.IsTemplate).
|
||||
Set("template_version", board.TemplateVersion).
|
||||
Set("properties", propertiesBytes).
|
||||
Set("card_properties", cardPropertiesBytes).
|
||||
Set("update_at", board.UpdateAt).
|
||||
Set("delete_at", board.DeleteAt)
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error(`InsertBoard error occurred while updating existing board`, mlog.String("boardID", board.ID), mlog.Err(err))
|
||||
return nil, fmt.Errorf("insertBoard error occurred while updating existing board %s: %w", board.ID, err)
|
||||
}
|
||||
} else {
|
||||
board.CreatedBy = userID
|
||||
board.CreateAt = now
|
||||
insertQueryValues["created_by"] = board.CreatedBy
|
||||
insertQueryValues["create_at"] = board.CreateAt
|
||||
|
||||
query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "boards")
|
||||
if _, err := query.Exec(); err != nil {
|
||||
return nil, fmt.Errorf("insertBoard error occurred while inserting board %s: %w", board.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// writing board history
|
||||
query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "boards_history")
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error("failed to insert board history", mlog.String("board_id", board.ID), mlog.Err(err))
|
||||
return nil, fmt.Errorf("failed to insert board %s history: %w", board.ID, err)
|
||||
}
|
||||
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) patchBoard(db sq.BaseRunner, boardID string, boardPatch *model.BoardPatch, userID string) (*model.Board, error) {
|
||||
existingBoard, err := s.getBoard(db, boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
board := boardPatch.Patch(existingBoard)
|
||||
return s.insertBoard(db, board, userID)
|
||||
}
|
||||
|
||||
func (s *SQLStore) deleteBoard(db sq.BaseRunner, boardID, userID string) error {
|
||||
return s.deleteBoardAndChildren(db, boardID, userID, false)
|
||||
}
|
||||
|
||||
func (s *SQLStore) deleteBoardAndChildren(db sq.BaseRunner, boardID, userID string, keepChildren bool) error {
|
||||
now := utils.GetMillis()
|
||||
|
||||
board, err := s.getBoard(db, boardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
propertiesBytes, err := s.MarshalJSONB(board.Properties)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cardPropertiesBytes, err := s.MarshalJSONB(board.CardProperties)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
insertQueryValues := map[string]interface{}{
|
||||
"id": board.ID,
|
||||
"team_id": board.TeamID,
|
||||
"channel_id": board.ChannelID,
|
||||
"created_by": board.CreatedBy,
|
||||
"modified_by": userID,
|
||||
"type": board.Type,
|
||||
"minimum_role": board.MinimumRole,
|
||||
"title": board.Title,
|
||||
"description": board.Description,
|
||||
"icon": board.Icon,
|
||||
"show_description": board.ShowDescription,
|
||||
"is_template": board.IsTemplate,
|
||||
"template_version": board.TemplateVersion,
|
||||
"properties": propertiesBytes,
|
||||
"card_properties": cardPropertiesBytes,
|
||||
"create_at": board.CreateAt,
|
||||
"update_at": now,
|
||||
"delete_at": now,
|
||||
}
|
||||
|
||||
// writing board history
|
||||
insertQuery := s.getQueryBuilder(db).Insert("").
|
||||
Columns(boardHistoryFields()...)
|
||||
|
||||
query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "boards_history")
|
||||
if _, err := query.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deleteQuery := s.getQueryBuilder(db).
|
||||
Delete(s.tablePrefix + "boards").
|
||||
Where(sq.Eq{"id": boardID}).
|
||||
Where(sq.Eq{"COALESCE(team_id, '0')": board.TeamID})
|
||||
|
||||
if _, err := deleteQuery.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if keepChildren {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.deleteBlockChildren(db, boardID, "", userID)
|
||||
}
|
||||
|
||||
func (s *SQLStore) insertBoardWithAdmin(db sq.BaseRunner, board *model.Board, userID string) (*model.Board, *model.BoardMember, error) {
|
||||
newBoard, err := s.insertBoard(db, board, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
bm := &model.BoardMember{
|
||||
BoardID: newBoard.ID,
|
||||
UserID: newBoard.CreatedBy,
|
||||
SchemeAdmin: true,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
nbm, err := s.saveMember(db, bm)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot save member %s while inserting board %s: %w", bm.UserID, bm.BoardID, err)
|
||||
}
|
||||
|
||||
return newBoard, nbm, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) saveMember(db sq.BaseRunner, bm *model.BoardMember) (*model.BoardMember, error) {
|
||||
queryValues := map[string]interface{}{
|
||||
"board_id": bm.BoardID,
|
||||
"user_id": bm.UserID,
|
||||
"roles": "",
|
||||
"scheme_admin": bm.SchemeAdmin,
|
||||
"scheme_editor": bm.SchemeEditor,
|
||||
"scheme_commenter": bm.SchemeCommenter,
|
||||
"scheme_viewer": bm.SchemeViewer,
|
||||
}
|
||||
|
||||
oldMember, err := s.getMemberForBoard(db, bm.BoardID, bm.UserID)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix + "board_members").
|
||||
SetMap(queryValues)
|
||||
|
||||
if s.dbType == model.MysqlDBType {
|
||||
query = query.Suffix(
|
||||
"ON DUPLICATE KEY UPDATE scheme_admin = ?, scheme_editor = ?, scheme_commenter = ?, scheme_viewer = ?",
|
||||
bm.SchemeAdmin, bm.SchemeEditor, bm.SchemeCommenter, bm.SchemeViewer)
|
||||
} else {
|
||||
query = query.Suffix(
|
||||
`ON CONFLICT (board_id, user_id)
|
||||
DO UPDATE SET scheme_admin = EXCLUDED.scheme_admin, scheme_editor = EXCLUDED.scheme_editor,
|
||||
scheme_commenter = EXCLUDED.scheme_commenter, scheme_viewer = EXCLUDED.scheme_viewer`,
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if oldMember == nil {
|
||||
addToMembersHistory := s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"board_members_history").
|
||||
Columns("board_id", "user_id", "action").
|
||||
Values(bm.BoardID, bm.UserID, "created")
|
||||
|
||||
if _, err := addToMembersHistory.Exec(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return bm, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) deleteMember(db sq.BaseRunner, boardID, userID string) error {
|
||||
deleteQuery := s.getQueryBuilder(db).
|
||||
Delete(s.tablePrefix + "board_members").
|
||||
Where(sq.Eq{"board_id": boardID}).
|
||||
Where(sq.Eq{"user_id": userID})
|
||||
|
||||
result, err := deleteQuery.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsAffected > 0 {
|
||||
addToMembersHistory := s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"board_members_history").
|
||||
Columns("board_id", "user_id", "action").
|
||||
Values(boardID, userID, "deleted")
|
||||
|
||||
if _, err := addToMembersHistory.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getMemberForBoard(db sq.BaseRunner, boardID, userID string) (*model.BoardMember, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardMemberFields...).
|
||||
From(s.tablePrefix + "board_members AS BM").
|
||||
LeftJoin(s.tablePrefix + "boards AS B ON B.id=BM.board_id").
|
||||
Where(sq.Eq{"BM.board_id": boardID}).
|
||||
Where(sq.Eq{"BM.user_id": userID})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getMemberForBoard ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
members, err := s.boardMembersFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(members) == 0 {
|
||||
message := fmt.Sprintf("board member BoardID=%s UserID=%s", boardID, userID)
|
||||
return nil, model.NewErrNotFound(message)
|
||||
}
|
||||
|
||||
return members[0], nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getMembersForUser(db sq.BaseRunner, userID string) ([]*model.BoardMember, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardMemberFields...).
|
||||
From(s.tablePrefix + "board_members AS BM").
|
||||
LeftJoin(s.tablePrefix + "boards AS B ON B.id=BM.board_id").
|
||||
Where(sq.Eq{"BM.user_id": userID})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getMembersForUser ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
members, err := s.boardMembersFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getMembersForBoard(db sq.BaseRunner, boardID string) ([]*model.BoardMember, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardMemberFields...).
|
||||
From(s.tablePrefix + "board_members AS BM").
|
||||
LeftJoin(s.tablePrefix + "boards AS B ON B.id=BM.board_id").
|
||||
Where(sq.Eq{"BM.board_id": boardID})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getMembersForBoard ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
return s.boardMembersFromRows(rows)
|
||||
}
|
||||
|
||||
// searchBoardsForUser returns all boards that match with the
|
||||
// term that are either private and which the user is a member of, or
|
||||
// they're open, regardless of the user membership.
|
||||
// Search is case-insensitive.
|
||||
func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
Distinct().
|
||||
From(s.tablePrefix + "boards as b").
|
||||
LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
|
||||
Where(sq.Eq{"b.is_template": false})
|
||||
|
||||
if includePublicBoards {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"b.type": model.BoardTypeOpen},
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
})
|
||||
} else {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
})
|
||||
}
|
||||
|
||||
if term != "" {
|
||||
if searchField == model.BoardSearchFieldPropertyName {
|
||||
switch s.dbType {
|
||||
case model.PostgresDBType:
|
||||
where := "b.properties->? is not null"
|
||||
query = query.Where(where, term)
|
||||
case model.MysqlDBType:
|
||||
where := "JSON_EXTRACT(b.properties, ?) IS NOT NULL"
|
||||
query = query.Where(where, "$."+term)
|
||||
default:
|
||||
where := "b.properties LIKE ?"
|
||||
query = query.Where(where, "%\""+term+"\"%")
|
||||
}
|
||||
} else { // model.BoardSearchFieldTitle
|
||||
// break search query into space separated words
|
||||
// and search for all words.
|
||||
// This should later be upgraded to industrial-strength
|
||||
// word tokenizer, that uses much more than space
|
||||
// to break words.
|
||||
conditions := sq.And{}
|
||||
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
|
||||
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
|
||||
}
|
||||
query = query.Where(conditions)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
// searchBoardsForUserInTeam returns all boards that match with the
|
||||
// term that are either private and which the user is a member of, or
|
||||
// they're open, regardless of the user membership.
|
||||
// Search is case-insensitive.
|
||||
func (s *SQLStore) searchBoardsForUserInTeam(db sq.BaseRunner, teamID, term, userID string) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
Distinct().
|
||||
From(s.tablePrefix + "boards as b").
|
||||
LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
|
||||
Where(sq.Eq{"b.is_template": false}).
|
||||
Where(sq.Eq{"b.team_id": teamID}).
|
||||
Where(sq.Or{
|
||||
sq.Eq{"b.type": model.BoardTypeOpen},
|
||||
sq.And{
|
||||
sq.Eq{"b.type": model.BoardTypePrivate},
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
},
|
||||
})
|
||||
|
||||
if term != "" {
|
||||
// break search query into space separated words
|
||||
// and search for all words.
|
||||
// This should later be upgraded to industrial-strength
|
||||
// word tokenizer, that uses much more than space
|
||||
// to break words.
|
||||
|
||||
conditions := sq.And{}
|
||||
|
||||
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
|
||||
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
|
||||
}
|
||||
|
||||
query = query.Where(conditions)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardHistory(db sq.BaseRunner, boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) {
|
||||
var order string
|
||||
if opts.Descending {
|
||||
order = " DESC "
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardHistoryFields()...).
|
||||
From(s.tablePrefix + "boards_history").
|
||||
Where(sq.Eq{"id": boardID}).
|
||||
OrderBy("insert_at " + order + ", update_at" + order)
|
||||
|
||||
if opts.BeforeUpdateAt != 0 {
|
||||
query = query.Where(sq.Lt{"update_at": opts.BeforeUpdateAt})
|
||||
}
|
||||
|
||||
if opts.AfterUpdateAt != 0 {
|
||||
query = query.Where(sq.Gt{"update_at": opts.AfterUpdateAt})
|
||||
}
|
||||
|
||||
if opts.Limit != 0 {
|
||||
query = query.Limit(opts.Limit)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getBoardHistory ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) undeleteBoard(db sq.BaseRunner, boardID string, modifiedBy string) error {
|
||||
boards, err := s.getBoardHistory(db, boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(boards) == 0 {
|
||||
s.logger.Warn("undeleteBlock board not found", mlog.String("board_id", boardID))
|
||||
return nil // undeleting non-existing board is not considered an error (for now)
|
||||
}
|
||||
board := boards[0]
|
||||
|
||||
if board.DeleteAt == 0 {
|
||||
s.logger.Warn("undeleteBlock board not deleted", mlog.String("board_id", board.ID))
|
||||
return nil // undeleting not deleted board is not considered an error (for now)
|
||||
}
|
||||
|
||||
propertiesJSON, err := s.MarshalJSONB(board.Properties)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cardPropertiesJSON, err := s.MarshalJSONB(board.CardProperties)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := utils.GetMillis()
|
||||
columns := []string{
|
||||
"id",
|
||||
"team_id",
|
||||
"channel_id",
|
||||
"created_by",
|
||||
"modified_by",
|
||||
"type",
|
||||
"title",
|
||||
"minimum_role",
|
||||
"description",
|
||||
"icon",
|
||||
"show_description",
|
||||
"is_template",
|
||||
"template_version",
|
||||
"properties",
|
||||
"card_properties",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
}
|
||||
|
||||
values := []interface{}{
|
||||
board.ID,
|
||||
board.TeamID,
|
||||
"",
|
||||
board.CreatedBy,
|
||||
modifiedBy,
|
||||
board.Type,
|
||||
board.Title,
|
||||
board.MinimumRole,
|
||||
board.Description,
|
||||
board.Icon,
|
||||
board.ShowDescription,
|
||||
board.IsTemplate,
|
||||
board.TemplateVersion,
|
||||
propertiesJSON,
|
||||
cardPropertiesJSON,
|
||||
board.CreateAt,
|
||||
now,
|
||||
0,
|
||||
}
|
||||
insertHistoryQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "boards_history").
|
||||
Columns(columns...).
|
||||
Values(values...)
|
||||
insertQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "boards").
|
||||
Columns(columns...).
|
||||
Values(values...)
|
||||
|
||||
if _, err := insertHistoryQuery.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := insertQuery.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.undeleteBlockChildren(db, board.ID, "", modifiedBy)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardMemberHistory(db sq.BaseRunner, boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select("board_id", "user_id", "action", "insert_at").
|
||||
From(s.tablePrefix + "board_members_history").
|
||||
Where(sq.Eq{"board_id": boardID}).
|
||||
Where(sq.Eq{"user_id": userID}).
|
||||
OrderBy("insert_at DESC")
|
||||
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getBoardMemberHistory ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
memberHistory, err := s.boardMemberHistoryEntriesFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memberHistory, nil
|
||||
}
|
||||
154
server/boards/services/store/sqlstore/board_insights.go
Обычный файл
154
server/boards/services/store/sqlstore/board_insights.go
Обычный файл
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (s *SQLStore) getTeamBoardsInsights(db sq.BaseRunner, teamID string, since int64, offset int, limit int, boardIDs []string) (*model.BoardInsightsList, error) {
|
||||
boardsHistoryQuery := s.getQueryBuilder(db).
|
||||
Select("boards.id, boards.icon, boards.title, count(boards_history.id) as count, boards_history.modified_by, boards.created_by").
|
||||
From(s.tablePrefix + "boards_history as boards_history").
|
||||
Join(s.tablePrefix + "boards as boards on boards_history.id = boards.id").
|
||||
Where(sq.Gt{"boards_history.insert_at": mm_model.GetTimeForMillis(since).Format(time.RFC3339)}).
|
||||
Where(sq.Eq{"boards.team_id": teamID}).
|
||||
Where(sq.Eq{"boards.id": boardIDs}).
|
||||
Where(sq.NotEq{"boards_history.modified_by": "system"}).
|
||||
Where(sq.Eq{"boards.delete_at": 0}).
|
||||
GroupBy("boards.id, boards_history.id, boards_history.modified_by")
|
||||
|
||||
blocksHistoryQuery := s.getQueryBuilder(db).
|
||||
Select("boards.id, boards.icon, boards.title, count(blocks_history.id) as count, blocks_history.modified_by, boards.created_by").
|
||||
Prefix("UNION ALL").
|
||||
From(s.tablePrefix + "blocks_history as blocks_history").
|
||||
Join(s.tablePrefix + "boards as boards on blocks_history.board_id = boards.id").
|
||||
Where(sq.Gt{"blocks_history.insert_at": mm_model.GetTimeForMillis(since).Format(time.RFC3339)}).
|
||||
Where(sq.Eq{"boards.team_id": teamID}).
|
||||
Where(sq.Eq{"boards.id": boardIDs}).
|
||||
Where(sq.NotEq{"blocks_history.modified_by": "system"}).
|
||||
Where(sq.Eq{"boards.delete_at": 0}).
|
||||
GroupBy("boards.id, blocks_history.board_id, blocks_history.modified_by")
|
||||
|
||||
boardsActivity := boardsHistoryQuery.SuffixExpr(blocksHistoryQuery)
|
||||
|
||||
insightsQuery := s.getQueryBuilder(db).Select(
|
||||
fmt.Sprintf("id, title, icon, sum(count) as activity_count, %s as active_users, created_by", s.concatenationSelector("distinct modified_by", ",")),
|
||||
).
|
||||
FromSelect(boardsActivity, "boards_and_blocks_history").
|
||||
GroupBy("id, title, icon, created_by").
|
||||
OrderBy("activity_count desc").
|
||||
Offset(uint64(offset)).
|
||||
Limit(uint64(limit))
|
||||
|
||||
rows, err := insightsQuery.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`Team insights query ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
boardsInsights, err := boardsInsightsFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boardInsightsPaginated := model.GetTopBoardInsightsListWithPagination(boardsInsights, limit)
|
||||
|
||||
return boardInsightsPaginated, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getUserBoardsInsights(db sq.BaseRunner, teamID string, userID string, since int64, offset int, limit int, boardIDs []string) (*model.BoardInsightsList, error) {
|
||||
boardsHistoryQuery := s.getQueryBuilder(db).
|
||||
Select("boards.id, boards.icon, boards.title, count(boards_history.id) as count, boards_history.modified_by, boards.created_by").
|
||||
From(s.tablePrefix + "boards_history as boards_history").
|
||||
Join(s.tablePrefix + "boards as boards on boards_history.id = boards.id").
|
||||
Where(sq.Gt{"boards_history.insert_at": mm_model.GetTimeForMillis(since).Format(time.RFC3339)}).
|
||||
Where(sq.Eq{"boards.team_id": teamID}).
|
||||
Where(sq.Eq{"boards.id": boardIDs}).
|
||||
Where(sq.NotEq{"boards_history.modified_by": "system"}).
|
||||
Where(sq.Eq{"boards.delete_at": 0}).
|
||||
GroupBy("boards.id, boards_history.id, boards_history.modified_by")
|
||||
|
||||
blocksHistoryQuery := s.getQueryBuilder(db).
|
||||
Select("boards.id, boards.icon, boards.title, count(blocks_history.id) as count, blocks_history.modified_by, boards.created_by").
|
||||
Prefix("UNION ALL").
|
||||
From(s.tablePrefix + "blocks_history as blocks_history").
|
||||
Join(s.tablePrefix + "boards as boards on blocks_history.board_id = boards.id").
|
||||
Where(sq.Gt{"blocks_history.insert_at": mm_model.GetTimeForMillis(since).Format(time.RFC3339)}).
|
||||
Where(sq.Eq{"boards.team_id": teamID}).
|
||||
Where(sq.Eq{"boards.id": boardIDs}).
|
||||
Where(sq.NotEq{"blocks_history.modified_by": "system"}).
|
||||
Where(sq.Eq{"boards.delete_at": 0}).
|
||||
GroupBy("boards.id, blocks_history.board_id, blocks_history.modified_by")
|
||||
|
||||
boardsActivity := boardsHistoryQuery.SuffixExpr(blocksHistoryQuery)
|
||||
|
||||
insightsQuery := s.getQueryBuilder(db).Select(
|
||||
fmt.Sprintf("id, title, icon, sum(count) as activity_count, %s as active_users, created_by", s.concatenationSelector("distinct modified_by", ",")),
|
||||
).
|
||||
FromSelect(boardsActivity, "boards_and_blocks_history").
|
||||
GroupBy("id, title, icon, created_by").
|
||||
OrderBy("activity_count desc")
|
||||
|
||||
userQuery := s.getQueryBuilder(db).Select("*").
|
||||
FromSelect(insightsQuery, "boards_and_blocks_history_for_user").
|
||||
Where(sq.Or{
|
||||
sq.Eq{
|
||||
"created_by": userID,
|
||||
},
|
||||
sq.Expr(s.elementInColumn("active_users"), userID),
|
||||
}).
|
||||
Offset(uint64(offset)).
|
||||
Limit(uint64(limit))
|
||||
|
||||
rows, err := userQuery.Query()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error(`Team insights query ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
boardsInsights, err := boardsInsightsFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boardInsightsPaginated := model.GetTopBoardInsightsListWithPagination(boardsInsights, limit)
|
||||
|
||||
return boardInsightsPaginated, nil
|
||||
}
|
||||
|
||||
func boardsInsightsFromRows(rows *sql.Rows) ([]*model.BoardInsight, error) {
|
||||
boardsInsights := []*model.BoardInsight{}
|
||||
for rows.Next() {
|
||||
var boardInsight model.BoardInsight
|
||||
var activeUsersString string
|
||||
err := rows.Scan(
|
||||
&boardInsight.BoardID,
|
||||
&boardInsight.Title,
|
||||
&boardInsight.Icon,
|
||||
&boardInsight.ActivityCount,
|
||||
&activeUsersString,
|
||||
&boardInsight.CreatedBy,
|
||||
)
|
||||
// split activeUsersString into slice
|
||||
boardInsight.ActiveUsers = strings.Split(activeUsersString, ",")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boardsInsights = append(boardsInsights, &boardInsight)
|
||||
}
|
||||
return boardsInsights, nil
|
||||
}
|
||||
187
server/boards/services/store/sqlstore/boards_and_blocks.go
Обычный файл
187
server/boards/services/store/sqlstore/boards_and_blocks.go
Обычный файл
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type BlockDoesntBelongToBoardsErr struct {
|
||||
blockID string
|
||||
}
|
||||
|
||||
func (e BlockDoesntBelongToBoardsErr) Error() string {
|
||||
return fmt.Sprintf("block %s doesn't belong to any of the boards in the delete request", e.blockID)
|
||||
}
|
||||
|
||||
func (s *SQLStore) createBoardsAndBlocksWithAdmin(db sq.BaseRunner, bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error) {
|
||||
newBab, err := s.createBoardsAndBlocks(db, bab, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
members := []*model.BoardMember{}
|
||||
for _, board := range newBab.Boards {
|
||||
bm := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: board.CreatedBy,
|
||||
SchemeAdmin: true,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
nbm, err := s.saveMember(db, bm)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
members = append(members, nbm)
|
||||
}
|
||||
|
||||
return newBab, members, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) createBoardsAndBlocks(db sq.BaseRunner, bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) {
|
||||
boards := []*model.Board{}
|
||||
blocks := []*model.Block{}
|
||||
|
||||
for _, board := range bab.Boards {
|
||||
newBoard, err := s.insertBoard(db, board, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boards = append(boards, newBoard)
|
||||
}
|
||||
|
||||
for _, block := range bab.Blocks {
|
||||
b := block
|
||||
err := s.insertBlock(db, b, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
|
||||
newBab := &model.BoardsAndBlocks{
|
||||
Boards: boards,
|
||||
Blocks: blocks,
|
||||
}
|
||||
|
||||
return newBab, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) patchBoardsAndBlocks(db sq.BaseRunner, pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) {
|
||||
bab := &model.BoardsAndBlocks{}
|
||||
for i, boardID := range pbab.BoardIDs {
|
||||
board, err := s.patchBoard(db, boardID, pbab.BoardPatches[i], userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bab.Boards = append(bab.Boards, board)
|
||||
}
|
||||
|
||||
for i, blockID := range pbab.BlockIDs {
|
||||
if err := s.patchBlock(db, blockID, pbab.BlockPatches[i], userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := s.getBlock(db, blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bab.Blocks = append(bab.Blocks, block)
|
||||
}
|
||||
|
||||
return bab, nil
|
||||
}
|
||||
|
||||
// deleteBoardsAndBlocks deletes all the boards and blocks entities of
|
||||
// the DeleteBoardsAndBlocks struct, making sure that all the blocks
|
||||
// belong to the boards in the struct.
|
||||
func (s *SQLStore) deleteBoardsAndBlocks(db sq.BaseRunner, dbab *model.DeleteBoardsAndBlocks, userID string) error {
|
||||
boardIDMap := map[string]bool{}
|
||||
for _, boardID := range dbab.Boards {
|
||||
boardIDMap[boardID] = true
|
||||
}
|
||||
|
||||
// delete the blocks first, since deleting the board will clean up any children and we'll get
|
||||
// not found errors when deleting the blocks after.
|
||||
for _, blockID := range dbab.Blocks {
|
||||
block, err := s.getBlock(db, blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := boardIDMap[block.BoardID]; !ok {
|
||||
return BlockDoesntBelongToBoardsErr{blockID}
|
||||
}
|
||||
|
||||
if err := s.deleteBlock(db, blockID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, boardID := range dbab.Boards {
|
||||
if err := s.deleteBoard(db, boardID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) duplicateBoard(db sq.BaseRunner, boardID string, userID string, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) {
|
||||
bab := &model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{},
|
||||
Blocks: []*model.Block{},
|
||||
}
|
||||
|
||||
board, err := s.getBoard(db, boardID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// todo: server localization
|
||||
if asTemplate == board.IsTemplate {
|
||||
// board -> board or template -> template
|
||||
board.Title += " copy"
|
||||
} else if asTemplate {
|
||||
// template from board
|
||||
board.Title = "New board template"
|
||||
}
|
||||
|
||||
// make new board private
|
||||
board.Type = "P"
|
||||
board.IsTemplate = asTemplate
|
||||
board.CreatedBy = userID
|
||||
board.ChannelID = ""
|
||||
|
||||
if toTeam != "" {
|
||||
board.TeamID = toTeam
|
||||
}
|
||||
|
||||
bab.Boards = []*model.Board{board}
|
||||
blocks, err := s.getBlocksForBoard(db, boardID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
newBlocks := []*model.Block{}
|
||||
for _, b := range blocks {
|
||||
if b.Type != model.TypeComment {
|
||||
newBlocks = append(newBlocks, b)
|
||||
}
|
||||
}
|
||||
bab.Blocks = newBlocks
|
||||
|
||||
bab, err = model.GenerateBoardsAndBlocksIDs(bab, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return s.createBoardsAndBlocksWithAdmin(db, bab, userID)
|
||||
}
|
||||
255
server/boards/services/store/sqlstore/boards_migrator.go
Обычный файл
255
server/boards/services/store/sqlstore/boards_migrator.go
Обычный файл
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
|
||||
"github.com/mattermost/morph"
|
||||
"github.com/mattermost/morph/drivers"
|
||||
"github.com/mattermost/morph/drivers/mysql"
|
||||
"github.com/mattermost/morph/drivers/postgres"
|
||||
embedded "github.com/mattermost/morph/sources/embedded"
|
||||
"github.com/mgdelacroix/foundation"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/db"
|
||||
mmSqlStore "github.com/mattermost/mattermost-server/v6/server/channels/store/sqlstore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
var tablePrefix = "focalboard_"
|
||||
|
||||
type BoardsMigrator struct {
|
||||
connString string
|
||||
driverName string
|
||||
db *sql.DB
|
||||
store *SQLStore
|
||||
morphEngine *morph.Morph
|
||||
morphDriver drivers.Driver
|
||||
}
|
||||
|
||||
func NewBoardsMigrator(store *SQLStore) *BoardsMigrator {
|
||||
return &BoardsMigrator{
|
||||
connString: store.connectionString,
|
||||
driverName: store.dbType,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) runMattermostMigrations() error {
|
||||
assets := db.Assets()
|
||||
assetsList, err := assets.ReadDir(filepath.Join("migrations", bm.driverName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
assetNames := make([]string, len(assetsList))
|
||||
for i, entry := range assetsList {
|
||||
assetNames[i] = entry.Name()
|
||||
}
|
||||
|
||||
src, err := embedded.WithInstance(&embedded.AssetSource{
|
||||
Names: assetNames,
|
||||
AssetFunc: func(name string) ([]byte, error) {
|
||||
return assets.ReadFile(filepath.Join("migrations", bm.driverName, name))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
driver, err := bm.getDriver()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
options := []morph.EngineOption{
|
||||
morph.SetStatementTimeoutInSeconds(1000000),
|
||||
}
|
||||
|
||||
engine, err := morph.New(context.Background(), driver, src, options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer engine.Close()
|
||||
|
||||
return engine.ApplyAll()
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) getDriver() (drivers.Driver, error) {
|
||||
var driver drivers.Driver
|
||||
var err error
|
||||
switch bm.driverName {
|
||||
case model.PostgresDBType:
|
||||
driver, err = postgres.WithInstance(bm.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case model.MysqlDBType:
|
||||
driver, err = mysql.WithInstance(bm.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return driver, nil
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) getMorphConnection() (*morph.Morph, drivers.Driver, error) {
|
||||
driver, err := bm.getDriver()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
assetsList, err := Assets.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
assetNamesForDriver := make([]string, len(assetsList))
|
||||
for i, dirEntry := range assetsList {
|
||||
assetNamesForDriver[i] = dirEntry.Name()
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"prefix": tablePrefix,
|
||||
"postgres": bm.driverName == model.PostgresDBType,
|
||||
"mysql": bm.driverName == model.MysqlDBType,
|
||||
"plugin": true, // TODO: to be removed
|
||||
"singleUser": false,
|
||||
}
|
||||
|
||||
migrationAssets := &embedded.AssetSource{
|
||||
Names: assetNamesForDriver,
|
||||
AssetFunc: func(name string) ([]byte, error) {
|
||||
asset, mErr := Assets.ReadFile("migrations/" + name)
|
||||
if mErr != nil {
|
||||
return nil, mErr
|
||||
}
|
||||
|
||||
tmpl, pErr := template.New("sql").Funcs(bm.store.GetTemplateHelperFuncs()).Parse(string(asset))
|
||||
if pErr != nil {
|
||||
return nil, pErr
|
||||
}
|
||||
buffer := bytes.NewBufferString("")
|
||||
|
||||
err = tmpl.Execute(buffer, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
},
|
||||
}
|
||||
|
||||
src, err := embedded.WithInstance(migrationAssets)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
engine, err := morph.New(context.Background(), driver, src, morph.SetMigrationTableName(fmt.Sprintf("%sschema_migrations", tablePrefix)))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return engine, driver, nil
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) Setup() error {
|
||||
var err error
|
||||
if bm.driverName == model.MysqlDBType {
|
||||
bm.connString, err = mmSqlStore.ResetReadTimeout(bm.connString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bm.connString, err = mmSqlStore.AppendMultipleStatementsFlag(bm.connString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var dbErr error
|
||||
bm.db, dbErr = sql.Open(bm.driverName, bm.connString)
|
||||
if dbErr != nil {
|
||||
return dbErr
|
||||
}
|
||||
|
||||
if err2 := bm.db.Ping(); err2 != nil {
|
||||
return err2
|
||||
}
|
||||
|
||||
if err3 := bm.runMattermostMigrations(); err3 != nil {
|
||||
return err3
|
||||
}
|
||||
|
||||
storeParams := Params{
|
||||
DBType: bm.driverName,
|
||||
ConnectionString: bm.connString,
|
||||
TablePrefix: tablePrefix,
|
||||
Logger: mlog.CreateConsoleTestLogger(false, mlog.LvlDebug),
|
||||
DB: bm.db,
|
||||
IsPlugin: true, // TODO: to be removed
|
||||
SkipMigrations: true,
|
||||
}
|
||||
bm.store, err = New(storeParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
morphEngine, morphDriver, err := bm.getMorphConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bm.morphEngine = morphEngine
|
||||
bm.morphDriver = morphDriver
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) MigrateToStep(step int) error {
|
||||
applied, err := bm.morphDriver.AppliedMigrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentVersion := len(applied)
|
||||
|
||||
if _, err := bm.morphEngine.Apply(step - currentVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) Interceptors() map[int]foundation.Interceptor {
|
||||
return map[int]foundation.Interceptor{
|
||||
18: bm.store.RunDeletedMembershipBoardsMigration,
|
||||
}
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) TearDown() error {
|
||||
if err := bm.morphEngine.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := bm.db.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) DriverName() string {
|
||||
return bm.driverName
|
||||
}
|
||||
|
||||
func (bm *BoardsMigrator) DB() *sql.DB {
|
||||
return bm.db
|
||||
}
|
||||
249
server/boards/services/store/sqlstore/category.go
Обычный файл
249
server/boards/services/store/sqlstore/category.go
Обычный файл
@@ -0,0 +1,249 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const categorySortOrderGap = 10
|
||||
|
||||
func (s *SQLStore) categoryFields() []string {
|
||||
return []string{
|
||||
"id",
|
||||
"name",
|
||||
"user_id",
|
||||
"team_id",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
"collapsed",
|
||||
"COALESCE(sort_order, 0)",
|
||||
"type",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) getCategory(db sq.BaseRunner, id string) (*model.Category, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(s.categoryFields()...).
|
||||
From(s.tablePrefix + "categories").
|
||||
Where(sq.Eq{"id": id})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error("getCategory error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
categories, err := s.categoriesFromRows(rows)
|
||||
if err != nil {
|
||||
s.logger.Error("getCategory row scan error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(categories) == 0 {
|
||||
return nil, model.NewErrNotFound("category ID=" + id)
|
||||
}
|
||||
|
||||
return &categories[0], nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) error {
|
||||
// A new category should always end up at the top.
|
||||
// So we first insert the provided category, then bump up
|
||||
// existing user-team categories' order
|
||||
|
||||
// creating provided category
|
||||
query := s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"categories").
|
||||
Columns(
|
||||
"id",
|
||||
"name",
|
||||
"user_id",
|
||||
"team_id",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
"collapsed",
|
||||
"sort_order",
|
||||
"type",
|
||||
).
|
||||
Values(
|
||||
category.ID,
|
||||
category.Name,
|
||||
category.UserID,
|
||||
category.TeamID,
|
||||
category.CreateAt,
|
||||
category.UpdateAt,
|
||||
category.DeleteAt,
|
||||
category.Collapsed,
|
||||
category.SortOrder,
|
||||
category.Type,
|
||||
)
|
||||
|
||||
_, err := query.Exec()
|
||||
if err != nil {
|
||||
s.logger.Error("Error creating category", mlog.String("category name", category.Name), mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// bumping up order of existing categories
|
||||
updateQuery := s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"categories").
|
||||
Set("sort_order", sq.Expr(fmt.Sprintf("sort_order + %d", categorySortOrderGap))).
|
||||
Where(
|
||||
sq.Eq{
|
||||
"user_id": category.UserID,
|
||||
"team_id": category.TeamID,
|
||||
"delete_at": 0,
|
||||
},
|
||||
)
|
||||
|
||||
if _, err := updateQuery.Exec(); err != nil {
|
||||
s.logger.Error(
|
||||
"createCategory failed to update sort order of existing user-team categories",
|
||||
mlog.String("user_id", category.UserID),
|
||||
mlog.String("team_id", category.TeamID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) updateCategory(db sq.BaseRunner, category model.Category) error {
|
||||
query := s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"categories").
|
||||
Set("name", category.Name).
|
||||
Set("update_at", category.UpdateAt).
|
||||
Set("collapsed", category.Collapsed).
|
||||
Where(sq.Eq{
|
||||
"id": category.ID,
|
||||
"delete_at": 0,
|
||||
})
|
||||
|
||||
_, err := query.Exec()
|
||||
if err != nil {
|
||||
s.logger.Error("Error updating category", mlog.String("category_id", category.ID), mlog.String("category_name", category.Name), mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) deleteCategory(db sq.BaseRunner, categoryID, userID, teamID string) error {
|
||||
query := s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"categories").
|
||||
Set("delete_at", utils.GetMillis()).
|
||||
Where(sq.Eq{
|
||||
"id": categoryID,
|
||||
"user_id": userID,
|
||||
"team_id": teamID,
|
||||
"delete_at": 0,
|
||||
})
|
||||
|
||||
_, err := query.Exec()
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Error updating category",
|
||||
mlog.String("category_id", categoryID),
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("team_id", teamID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getUserCategories(db sq.BaseRunner, userID, teamID string) ([]model.Category, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(s.categoryFields()...).
|
||||
From(s.tablePrefix+"categories").
|
||||
Where(sq.Eq{
|
||||
"user_id": userID,
|
||||
"team_id": teamID,
|
||||
"delete_at": 0,
|
||||
}).
|
||||
OrderBy("sort_order", "name")
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error("getUserCategories error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.categoriesFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) categoriesFromRows(rows *sql.Rows) ([]model.Category, error) {
|
||||
var categories []model.Category
|
||||
|
||||
for rows.Next() {
|
||||
category := model.Category{}
|
||||
err := rows.Scan(
|
||||
&category.ID,
|
||||
&category.Name,
|
||||
&category.UserID,
|
||||
&category.TeamID,
|
||||
&category.CreateAt,
|
||||
&category.UpdateAt,
|
||||
&category.DeleteAt,
|
||||
&category.Collapsed,
|
||||
&category.SortOrder,
|
||||
&category.Type,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("categoriesFromRows row parsing error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
categories = append(categories, category)
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) reorderCategories(db sq.BaseRunner, userID, teamID string, newCategoryOrder []string) ([]string, error) {
|
||||
if len(newCategoryOrder) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
updateCase := sq.Case("id")
|
||||
for i, categoryID := range newCategoryOrder {
|
||||
updateCase = updateCase.When("'"+categoryID+"'", sq.Expr(fmt.Sprintf("%d", i*categorySortOrderGap)))
|
||||
}
|
||||
updateCase = updateCase.Else("sort_order")
|
||||
|
||||
query := s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"categories").
|
||||
Set("sort_order", updateCase).
|
||||
Where(sq.Eq{
|
||||
"user_id": userID,
|
||||
"team_id": teamID,
|
||||
})
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error(
|
||||
"reorderCategories failed to update category order",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("team_id", teamID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCategoryOrder, nil
|
||||
}
|
||||
195
server/boards/services/store/sqlstore/category_boards.go
Обычный файл
195
server/boards/services/store/sqlstore/category_boards.go
Обычный файл
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (s *SQLStore) getUserCategoryBoards(db sq.BaseRunner, userID, teamID string) ([]model.CategoryBoards, error) {
|
||||
categories, err := s.getUserCategories(db, userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userCategoryBoards := []model.CategoryBoards{}
|
||||
for _, category := range categories {
|
||||
boardMetadata, err := s.getCategoryBoardAttributes(db, category.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userCategoryBoard := model.CategoryBoards{
|
||||
Category: category,
|
||||
BoardMetadata: boardMetadata,
|
||||
}
|
||||
|
||||
userCategoryBoards = append(userCategoryBoards, userCategoryBoard)
|
||||
}
|
||||
|
||||
return userCategoryBoards, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID string) ([]model.CategoryBoardMetadata, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select("board_id, COALESCE(hidden, false)").
|
||||
From(s.tablePrefix + "category_boards").
|
||||
Where(sq.Eq{
|
||||
"category_id": categoryID,
|
||||
}).
|
||||
OrderBy("sort_order")
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error("getCategoryBoards error fetching categoryblocks", mlog.String("categoryID", categoryID), mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.categoryBoardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) addUpdateCategoryBoard(db sq.BaseRunner, userID, categoryID string, boardIDsParam []string) error {
|
||||
// we need to de-duplicate this array as Postgres failes to
|
||||
// handle upsert if there are multiple incoming rows
|
||||
// that conflict the same existing row.
|
||||
// For example, having the entry "1" in DB and trying to upsert "1" and "1" will fail
|
||||
// as there are multiple duplicates of the same "1".
|
||||
//
|
||||
// Source: https://stackoverflow.com/questions/42994373/postgresql-on-conflict-cannot-affect-row-a-second-time
|
||||
boardIDs := utils.DedupeStringArr(boardIDsParam)
|
||||
|
||||
if len(boardIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"category_boards").
|
||||
Columns(
|
||||
"id",
|
||||
"user_id",
|
||||
"category_id",
|
||||
"board_id",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"sort_order",
|
||||
"hidden",
|
||||
)
|
||||
|
||||
now := utils.GetMillis()
|
||||
for _, boardID := range boardIDs {
|
||||
query = query.Values(
|
||||
utils.NewID(utils.IDTypeNone),
|
||||
userID,
|
||||
categoryID,
|
||||
boardID,
|
||||
now,
|
||||
now,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
if s.dbType == model.MysqlDBType {
|
||||
query = query.Suffix(
|
||||
"ON DUPLICATE KEY UPDATE category_id = ?",
|
||||
categoryID,
|
||||
)
|
||||
} else {
|
||||
query = query.Suffix(
|
||||
`ON CONFLICT (user_id, board_id)
|
||||
DO UPDATE SET category_id = EXCLUDED.category_id, update_at = EXCLUDED.update_at`,
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"store addUpdateCategoryBoard: failed to upsert user-board-category userID: %s, categoryID: %s, board_count: %d, error: %w",
|
||||
userID, categoryID, len(boardIDs), err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) categoryBoardsFromRows(rows *sql.Rows) ([]model.CategoryBoardMetadata, error) {
|
||||
metadata := []model.CategoryBoardMetadata{}
|
||||
|
||||
for rows.Next() {
|
||||
datum := model.CategoryBoardMetadata{}
|
||||
err := rows.Scan(&datum.BoardID, &datum.Hidden)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("categoryBoardsFromRows row scan error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata = append(metadata, datum)
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) reorderCategoryBoards(db sq.BaseRunner, categoryID string, newBoardsOrder []string) ([]string, error) {
|
||||
if len(newBoardsOrder) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
updateCase := sq.Case("board_id")
|
||||
for i, boardID := range newBoardsOrder {
|
||||
updateCase = updateCase.When("'"+boardID+"'", sq.Expr(fmt.Sprintf("%d", i+model.CategoryBoardsSortOrderGap)))
|
||||
}
|
||||
updateCase.Else("sort_order")
|
||||
|
||||
query := s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"category_boards").
|
||||
Set("sort_order", updateCase).
|
||||
Where(sq.Eq{
|
||||
"category_id": categoryID,
|
||||
})
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error(
|
||||
"reorderCategoryBoards failed to update category board order",
|
||||
mlog.String("category_id", categoryID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newBoardsOrder, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) setBoardVisibility(db sq.BaseRunner, userID, categoryID, boardID string, visible bool) error {
|
||||
query := s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"category_boards").
|
||||
Set("hidden", !visible).
|
||||
Where(sq.Eq{
|
||||
"user_id": userID,
|
||||
"category_id": categoryID,
|
||||
"board_id": boardID,
|
||||
})
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error(
|
||||
"SQLStore setBoardVisibility: failed to update board visibility",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("board_id", boardID),
|
||||
mlog.Bool("visible", visible),
|
||||
mlog.Err(err),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
118
server/boards/services/store/sqlstore/cloud.go
Обычный файл
118
server/boards/services/store/sqlstore/cloud.go
Обычный файл
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/store"
|
||||
)
|
||||
|
||||
var ErrInvalidCardLimitValue = errors.New("card limit value is invalid")
|
||||
|
||||
// activeCardsQuery applies the necessary filters to the query for it
|
||||
// to fetch an active cards window if the cardLimit is set, or all the
|
||||
// active cards if it's 0.
|
||||
func (s *SQLStore) activeCardsQuery(builder sq.StatementBuilderType, selectStr string, cardLimit int) sq.SelectBuilder {
|
||||
query := builder.
|
||||
Select(selectStr).
|
||||
From(s.tablePrefix + "blocks b").
|
||||
Join(s.tablePrefix + "boards bd on b.board_id=bd.id").
|
||||
Where(sq.Eq{
|
||||
"b.delete_at": 0,
|
||||
"b.type": model.TypeCard,
|
||||
"bd.is_template": false,
|
||||
})
|
||||
|
||||
if cardLimit != 0 {
|
||||
query = query.
|
||||
Limit(1).
|
||||
Offset(uint64(cardLimit - 1))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// getUsedCardsCount returns the amount of active cards in the server.
|
||||
func (s *SQLStore) getUsedCardsCount(db sq.BaseRunner) (int, error) {
|
||||
row := s.activeCardsQuery(s.getQueryBuilder(db), "count(b.id)", 0).
|
||||
QueryRow()
|
||||
|
||||
var usedCards int
|
||||
err := row.Scan(&usedCards)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return usedCards, nil
|
||||
}
|
||||
|
||||
// getCardLimitTimestamp returns the timestamp value from the
|
||||
// system_settings table or zero if it doesn't exist.
|
||||
func (s *SQLStore) getCardLimitTimestamp(db sq.BaseRunner) (int64, error) {
|
||||
scanner := s.getQueryBuilder(db).
|
||||
Select("value").
|
||||
From(s.tablePrefix + "system_settings").
|
||||
Where(sq.Eq{"id": store.CardLimitTimestampSystemKey}).
|
||||
QueryRow()
|
||||
|
||||
var result string
|
||||
err := scanner.Scan(&result)
|
||||
if errors.Is(sql.ErrNoRows, err) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cardLimitTimestamp, err := strconv.Atoi(result)
|
||||
if err != nil {
|
||||
return 0, ErrInvalidCardLimitValue
|
||||
}
|
||||
|
||||
return int64(cardLimitTimestamp), nil
|
||||
}
|
||||
|
||||
// updateCardLimitTimestamp updates the card limit value in the
|
||||
// system_settings table with the timestamp of the nth last updated
|
||||
// card, being nth the value of the cardLimit parameter. If cardLimit
|
||||
// is zero, the timestamp will be set to zero.
|
||||
func (s *SQLStore) updateCardLimitTimestamp(db sq.BaseRunner, cardLimit int) (int64, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"system_settings").
|
||||
Columns("id", "value")
|
||||
|
||||
var value interface{} = 0
|
||||
if cardLimit != 0 {
|
||||
value = s.activeCardsQuery(sq.StatementBuilder, "b.update_at", cardLimit).
|
||||
OrderBy("b.update_at DESC").
|
||||
Prefix("COALESCE((").Suffix("), 0)")
|
||||
}
|
||||
query = query.Values(store.CardLimitTimestampSystemKey, value)
|
||||
|
||||
if s.dbType == model.MysqlDBType {
|
||||
query = query.Suffix("ON DUPLICATE KEY UPDATE value = ?", value)
|
||||
} else {
|
||||
query = query.Suffix(
|
||||
`ON CONFLICT (id)
|
||||
DO UPDATE SET value = EXCLUDED.value`,
|
||||
)
|
||||
}
|
||||
|
||||
result, err := query.Exec()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := result.RowsAffected(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return s.getCardLimitTimestamp(db)
|
||||
}
|
||||
245
server/boards/services/store/sqlstore/compliance.go
Обычный файл
245
server/boards/services/store/sqlstore/compliance.go
Обычный файл
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (s *SQLStore) getBoardsForCompliance(db sq.BaseRunner, opts model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards as b")
|
||||
|
||||
if opts.TeamID != "" {
|
||||
query = query.Where(sq.Eq{"b.team_id": opts.TeamID})
|
||||
}
|
||||
|
||||
if opts.Page != 0 {
|
||||
query = query.Offset(uint64(opts.Page * opts.PerPage))
|
||||
}
|
||||
|
||||
if opts.PerPage > 0 {
|
||||
// N+1 to check if there's a next page for pagination
|
||||
query = query.Limit(uint64(opts.PerPage) + 1)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`GetBoardsForCompliance ERROR`, mlog.Err(err))
|
||||
return nil, false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
boards, err := s.boardsFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var hasMore bool
|
||||
if opts.PerPage > 0 && len(boards) > opts.PerPage {
|
||||
boards = boards[0:opts.PerPage]
|
||||
hasMore = true
|
||||
}
|
||||
return boards, hasMore, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardsComplianceHistory(db sq.BaseRunner, opts model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) {
|
||||
queryDescendentLastUpdate := s.getQueryBuilder(db).
|
||||
Select("MAX(blk1.update_at)").
|
||||
From(s.tablePrefix + "blocks_history as blk1").
|
||||
Where("blk1.board_id=bh.id")
|
||||
|
||||
if !opts.IncludeDeleted {
|
||||
queryDescendentLastUpdate.Where(sq.Eq{"blk1.delete_at": 0})
|
||||
}
|
||||
|
||||
sqlDescendentLastUpdate, _, _ := queryDescendentLastUpdate.ToSql()
|
||||
|
||||
queryDescendentFirstUpdate := s.getQueryBuilder(db).
|
||||
Select("MIN(blk2.update_at)").
|
||||
From(s.tablePrefix + "blocks_history as blk2").
|
||||
Where("blk2.board_id=bh.id")
|
||||
|
||||
if !opts.IncludeDeleted {
|
||||
queryDescendentFirstUpdate.Where(sq.Eq{"blk2.delete_at": 0})
|
||||
}
|
||||
|
||||
sqlDescendentFirstUpdate, _, _ := queryDescendentFirstUpdate.ToSql()
|
||||
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(
|
||||
"bh.id",
|
||||
"bh.team_id",
|
||||
"CASE WHEN bh.delete_at=0 THEN false ELSE true END AS isDeleted",
|
||||
"COALESCE(("+sqlDescendentLastUpdate+"),0) as decendentLastUpdateAt",
|
||||
"COALESCE(("+sqlDescendentFirstUpdate+"),0) as decendentFirstUpdateAt",
|
||||
"bh.created_by",
|
||||
"bh.modified_by",
|
||||
).
|
||||
From(s.tablePrefix + "boards_history as bh")
|
||||
|
||||
if !opts.IncludeDeleted {
|
||||
// filtering out deleted boards; join with boards table to ensure no history
|
||||
// for deleted boards are returned. Deleted boards won't exist in boards table.
|
||||
query = query.Join(s.tablePrefix + "boards as b ON b.id=bh.id")
|
||||
}
|
||||
|
||||
query = query.Where(sq.Gt{"bh.update_at": opts.ModifiedSince}).
|
||||
GroupBy("bh.id", "bh.team_id", "bh.delete_at", "bh.created_by", "bh.modified_by").
|
||||
OrderBy("decendentLastUpdateAt desc", "bh.id")
|
||||
|
||||
if opts.TeamID != "" {
|
||||
query = query.Where(sq.Eq{"bh.team_id": opts.TeamID})
|
||||
}
|
||||
|
||||
if opts.Page != 0 {
|
||||
query = query.Offset(uint64(opts.Page * opts.PerPage))
|
||||
}
|
||||
|
||||
if opts.PerPage > 0 {
|
||||
// N+1 to check if there's a next page for pagination
|
||||
query = query.Limit(uint64(opts.PerPage) + 1)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`GetBoardsComplianceHistory ERROR`, mlog.Err(err))
|
||||
return nil, false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
history, err := s.boardsHistoryFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var hasMore bool
|
||||
if opts.PerPage > 0 && len(history) > opts.PerPage {
|
||||
history = history[0:opts.PerPage]
|
||||
hasMore = true
|
||||
}
|
||||
return history, hasMore, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBlocksComplianceHistory(db sq.BaseRunner, opts model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(
|
||||
"bh.id",
|
||||
"brd.team_id",
|
||||
"bh.board_id",
|
||||
"bh.type",
|
||||
"CASE WHEN bh.delete_at=0 THEN false ELSE true END AS isDeleted",
|
||||
"max(bh.update_at) as lastUpdateAt",
|
||||
"min(bh.update_at) as firstUpdateAt",
|
||||
"bh.created_by",
|
||||
"bh.modified_by",
|
||||
).
|
||||
From(s.tablePrefix + "blocks_history as bh").
|
||||
Join(s.tablePrefix + "boards_history as brd on brd.id=bh.board_id")
|
||||
|
||||
if !opts.IncludeDeleted {
|
||||
// filtering out deleted blocks; join with blocks table to ensure no history
|
||||
// for deleted blocks are returned. Deleted blocks won't exist in blocks table.
|
||||
query = query.Join(s.tablePrefix + "blocks as b ON b.id=bh.id")
|
||||
}
|
||||
|
||||
query = query.Where(sq.Gt{"bh.update_at": opts.ModifiedSince}).
|
||||
GroupBy("bh.id", "brd.team_id", "bh.board_id", "bh.type", "bh.delete_at", "bh.created_by", "bh.modified_by").
|
||||
OrderBy("lastUpdateAt desc", "bh.id")
|
||||
|
||||
if opts.TeamID != "" {
|
||||
query = query.Where(sq.Eq{"brd.team_id": opts.TeamID})
|
||||
}
|
||||
|
||||
if opts.BoardID != "" {
|
||||
query = query.Where(sq.Eq{"bh.board_id": opts.BoardID})
|
||||
}
|
||||
|
||||
if opts.Page != 0 {
|
||||
query = query.Offset(uint64(opts.Page * opts.PerPage))
|
||||
}
|
||||
|
||||
if opts.PerPage > 0 {
|
||||
// N+1 to check if there's a next page for pagination
|
||||
query = query.Limit(uint64(opts.PerPage) + 1)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`GetBlocksComplianceHistory ERROR`, mlog.Err(err))
|
||||
return nil, false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
history, err := s.blocksHistoryFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var hasMore bool
|
||||
if opts.PerPage > 0 && len(history) > opts.PerPage {
|
||||
history = history[0:opts.PerPage]
|
||||
hasMore = true
|
||||
}
|
||||
return history, hasMore, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) boardsHistoryFromRows(rows *sql.Rows) ([]*model.BoardHistory, error) {
|
||||
history := []*model.BoardHistory{}
|
||||
|
||||
for rows.Next() {
|
||||
boardHistory := &model.BoardHistory{}
|
||||
|
||||
err := rows.Scan(
|
||||
&boardHistory.ID,
|
||||
&boardHistory.TeamID,
|
||||
&boardHistory.IsDeleted,
|
||||
&boardHistory.DescendantLastUpdateAt,
|
||||
&boardHistory.DescendantFirstUpdateAt,
|
||||
&boardHistory.CreatedBy,
|
||||
&boardHistory.LastModifiedBy,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("boardsHistoryFromRows scan error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
history = append(history, boardHistory)
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) blocksHistoryFromRows(rows *sql.Rows) ([]*model.BlockHistory, error) {
|
||||
history := []*model.BlockHistory{}
|
||||
|
||||
for rows.Next() {
|
||||
blockHistory := &model.BlockHistory{}
|
||||
|
||||
err := rows.Scan(
|
||||
&blockHistory.ID,
|
||||
&blockHistory.TeamID,
|
||||
&blockHistory.BoardID,
|
||||
&blockHistory.Type,
|
||||
&blockHistory.IsDeleted,
|
||||
&blockHistory.LastUpdateAt,
|
||||
&blockHistory.FirstUpdateAt,
|
||||
&blockHistory.CreatedBy,
|
||||
&blockHistory.LastModifiedBy,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("blocksHistoryFromRows scan error", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
history = append(history, blockHistory)
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
887
server/boards/services/store/sqlstore/data_migrations.go
Обычный файл
887
server/boards/services/store/sqlstore/data_migrations.go
Обычный файл
@@ -0,0 +1,887 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// we group the inserts on batches of 1000 because PostgreSQL
|
||||
// supports a limit of around 64K values (not rows) on an insert
|
||||
// query, so we want to stay safely below.
|
||||
CategoryInsertBatch = 1000
|
||||
|
||||
TemplatesToTeamsMigrationKey = "TemplatesToTeamsMigrationComplete"
|
||||
UniqueIDsMigrationKey = "UniqueIDsMigrationComplete"
|
||||
CategoryUUIDIDMigrationKey = "CategoryUuidIdMigrationComplete"
|
||||
TeamLessBoardsMigrationKey = "TeamLessBoardsMigrationComplete"
|
||||
DeletedMembershipBoardsMigrationKey = "DeletedMembershipBoardsMigrationComplete"
|
||||
DeDuplicateCategoryBoardTableMigrationKey = "DeDuplicateCategoryBoardTableComplete"
|
||||
)
|
||||
|
||||
func (s *SQLStore) getBlocksWithSameID(db sq.BaseRunner) ([]*model.Block, error) {
|
||||
subquery, _, _ := s.getQueryBuilder(db).
|
||||
Select("id").
|
||||
From(s.tablePrefix + "blocks").
|
||||
Having("count(id) > 1").
|
||||
GroupBy("id").
|
||||
ToSql()
|
||||
|
||||
blocksFields := []string{
|
||||
"id",
|
||||
"parent_id",
|
||||
"root_id",
|
||||
"created_by",
|
||||
"modified_by",
|
||||
s.escapeField("schema"),
|
||||
"type",
|
||||
"title",
|
||||
"COALESCE(fields, '{}')",
|
||||
s.timestampToCharField("insert_at", "insertAt"),
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
"COALESCE(workspace_id, '0')",
|
||||
}
|
||||
|
||||
rows, err := s.getQueryBuilder(db).
|
||||
Select(blocksFields...).
|
||||
From(s.tablePrefix + "blocks").
|
||||
Where(fmt.Sprintf("id IN (%s)", subquery)).
|
||||
Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getBlocksWithSameID ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
return s.blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) RunUniqueIDsMigration() error {
|
||||
setting, err := s.GetSystemSetting(UniqueIDsMigrationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot get migration state: %w", err)
|
||||
}
|
||||
|
||||
// If the migration is already completed, do not run it again.
|
||||
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Debug("Running Unique IDs migration")
|
||||
|
||||
tx, txErr := s.db.BeginTx(context.Background(), nil)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
blocks, err := s.getBlocksWithSameID(tx)
|
||||
if err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "getBlocksWithSameID"))
|
||||
}
|
||||
return fmt.Errorf("cannot get blocks with same ID: %w", err)
|
||||
}
|
||||
|
||||
blocksByID := map[string][]*model.Block{}
|
||||
for _, block := range blocks {
|
||||
blocksByID[block.ID] = append(blocksByID[block.ID], block)
|
||||
}
|
||||
|
||||
for _, blocks := range blocksByID {
|
||||
for i, block := range blocks {
|
||||
if i == 0 {
|
||||
// do nothing for the first ID, only updating the others
|
||||
continue
|
||||
}
|
||||
|
||||
newID := utils.NewID(model.BlockType2IDType(block.Type))
|
||||
if err := s.replaceBlockID(tx, block.ID, newID, block.WorkspaceID); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "replaceBlockID"))
|
||||
}
|
||||
return fmt.Errorf("cannot replace blockID %s: %w", block.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.setSystemSetting(tx, UniqueIDsMigrationKey, strconv.FormatBool(true)); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
||||
}
|
||||
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("cannot commit unique IDs transaction: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Unique IDs migration finished successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunCategoryUUIDIDMigration takes care of deriving the categories
|
||||
// from the boards and its memberships. The name references UUID
|
||||
// because of the preexisting purpose of this migration, and has been
|
||||
// preserved for compatibility with already migrated instances.
|
||||
func (s *SQLStore) RunCategoryUUIDIDMigration() error {
|
||||
setting, err := s.GetSystemSetting(CategoryUUIDIDMigrationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot get migration state: %w", err)
|
||||
}
|
||||
|
||||
// If the migration is already completed, do not run it again.
|
||||
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Debug("Running category UUID ID migration")
|
||||
|
||||
tx, txErr := s.db.BeginTx(context.Background(), nil)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
if s.isPlugin {
|
||||
if err := s.createCategories(tx); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("category UUIDs insert categories transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.createCategoryBoards(tx); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("category UUIDs insert category boards transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.setSystemSetting(tx, CategoryUUIDIDMigrationKey, strconv.FormatBool(true)); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("category UUIDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
||||
}
|
||||
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("cannot commit category UUIDs transaction: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("category UUIDs migration finished successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) createCategories(db sq.BaseRunner) error {
|
||||
rows, err := s.getQueryBuilder(db).
|
||||
Select("c.DisplayName, cm.UserId, c.TeamId, cm.ChannelId").
|
||||
From(s.tablePrefix + "boards boards").
|
||||
Join("ChannelMembers cm on boards.channel_id = cm.ChannelId").
|
||||
Join("Channels c on cm.ChannelId = c.id and (c.Type = 'O' or c.Type = 'P')").
|
||||
GroupBy("cm.UserId, c.TeamId, cm.ChannelId, c.DisplayName").
|
||||
Query()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("get boards data error", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
initQuery := func() sq.InsertBuilder {
|
||||
return s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"categories").
|
||||
Columns(
|
||||
"id",
|
||||
"name",
|
||||
"user_id",
|
||||
"team_id",
|
||||
"channel_id",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
)
|
||||
}
|
||||
// query will accumulate the insert values until the limit is
|
||||
// reached, and then it will be stored and reset
|
||||
query := initQuery()
|
||||
// queryList stores those queries that already reached the limit
|
||||
// to be run when all the data is processed
|
||||
queryList := []sq.InsertBuilder{}
|
||||
counter := 0
|
||||
now := model.GetMillis()
|
||||
|
||||
for rows.Next() {
|
||||
var displayName string
|
||||
var userID string
|
||||
var teamID string
|
||||
var channelID string
|
||||
|
||||
err := rows.Scan(
|
||||
&displayName,
|
||||
&userID,
|
||||
&teamID,
|
||||
&channelID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot scan result while trying to create categories: %w", err)
|
||||
}
|
||||
|
||||
query = query.Values(
|
||||
utils.NewID(utils.IDTypeNone),
|
||||
displayName,
|
||||
userID,
|
||||
teamID,
|
||||
channelID,
|
||||
now,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
counter++
|
||||
if counter%CategoryInsertBatch == 0 {
|
||||
queryList = append(queryList, query)
|
||||
query = initQuery()
|
||||
}
|
||||
}
|
||||
|
||||
if counter%CategoryInsertBatch != 0 {
|
||||
queryList = append(queryList, query)
|
||||
}
|
||||
|
||||
for _, q := range queryList {
|
||||
if _, err := q.Exec(); err != nil {
|
||||
return fmt.Errorf("cannot create category values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) createCategoryBoards(db sq.BaseRunner) error {
|
||||
rows, err := s.getQueryBuilder(db).
|
||||
Select("categories.user_id, categories.id, boards.id").
|
||||
From(s.tablePrefix + "categories categories").
|
||||
Join(s.tablePrefix + "boards boards on categories.channel_id = boards.channel_id AND boards.is_template = false").
|
||||
Query()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("get categories data error", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
initQuery := func() sq.InsertBuilder {
|
||||
return s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"category_boards").
|
||||
Columns(
|
||||
"id",
|
||||
"user_id",
|
||||
"category_id",
|
||||
"board_id",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
)
|
||||
}
|
||||
// query will accumulate the insert values until the limit is
|
||||
// reached, and then it will be stored and reset
|
||||
query := initQuery()
|
||||
// queryList stores those queries that already reached the limit
|
||||
// to be run when all the data is processed
|
||||
queryList := []sq.InsertBuilder{}
|
||||
counter := 0
|
||||
now := model.GetMillis()
|
||||
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var categoryID string
|
||||
var boardID string
|
||||
|
||||
err := rows.Scan(
|
||||
&userID,
|
||||
&categoryID,
|
||||
&boardID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot scan result while trying to create category boards: %w", err)
|
||||
}
|
||||
|
||||
query = query.Values(
|
||||
utils.NewID(utils.IDTypeNone),
|
||||
userID,
|
||||
categoryID,
|
||||
boardID,
|
||||
now,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
counter++
|
||||
if counter%CategoryInsertBatch == 0 {
|
||||
queryList = append(queryList, query)
|
||||
query = initQuery()
|
||||
}
|
||||
}
|
||||
|
||||
if counter%CategoryInsertBatch != 0 {
|
||||
queryList = append(queryList, query)
|
||||
}
|
||||
|
||||
for _, q := range queryList {
|
||||
if _, err := q.Exec(); err != nil {
|
||||
return fmt.Errorf("cannot create category boards values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// We no longer support boards existing in DMs and private
|
||||
// group messages. This function migrates all boards
|
||||
// belonging to a DM to the best possible team.
|
||||
func (s *SQLStore) RunTeamLessBoardsMigration() error {
|
||||
if !s.isPlugin {
|
||||
return nil
|
||||
}
|
||||
|
||||
setting, err := s.GetSystemSetting(TeamLessBoardsMigrationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot get teamless boards migration state: %w", err)
|
||||
}
|
||||
|
||||
// If the migration is already completed, do not run it again.
|
||||
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
boards, err := s.getDMBoards(s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Debug("Migrating teamless boards to a team", mlog.Int("count", len(boards)))
|
||||
|
||||
// cache for best suitable team for a DM. Since a DM can
|
||||
// contain multiple boards, caching this avoids
|
||||
// duplicate queries for the same DM.
|
||||
channelToTeamCache := map[string]string{}
|
||||
|
||||
tx, err := s.db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
s.logger.Error("error starting transaction in runTeamLessBoardsMigration", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range boards {
|
||||
// check the cache first
|
||||
teamID, ok := channelToTeamCache[boards[i].ChannelID]
|
||||
|
||||
// query DB if entry not found in cache
|
||||
if !ok {
|
||||
teamID, err = s.getBestTeamForBoard(s.db, boards[i])
|
||||
if err != nil {
|
||||
// don't let one board's error spoil
|
||||
// the mood for others
|
||||
s.logger.Error("could not find the best team for board during team less boards migration. Continuing", mlog.String("boardID", boards[i].ID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
channelToTeamCache[boards[i].ChannelID] = teamID
|
||||
boards[i].TeamID = teamID
|
||||
|
||||
query := s.getQueryBuilder(tx).
|
||||
Update(s.tablePrefix+"boards").
|
||||
Set("team_id", teamID).
|
||||
Set("type", model.BoardTypePrivate).
|
||||
Where(sq.Eq{"id": boards[i].ID})
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error("failed to set team id for board", mlog.String("board_id", boards[i].ID), mlog.String("team_id", teamID), mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.setSystemSetting(tx, TeamLessBoardsMigrationKey, strconv.FormatBool(true)); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "runTeamLessBoardsMigration"))
|
||||
}
|
||||
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
s.logger.Error("failed to commit runTeamLessBoardsMigration transaction", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getDMBoards(tx sq.BaseRunner) ([]*model.Board, error) {
|
||||
conditions := sq.And{
|
||||
sq.Eq{"team_id": ""},
|
||||
sq.Or{
|
||||
sq.Eq{"type": "D"},
|
||||
sq.Eq{"type": "G"},
|
||||
},
|
||||
}
|
||||
|
||||
boards, err := s.getLegacyBoardsByCondition(tx, conditions)
|
||||
if err != nil && model.IsErrNotFound(err) {
|
||||
return []*model.Board{}, nil
|
||||
}
|
||||
|
||||
return boards, err
|
||||
}
|
||||
|
||||
// The destination is selected as the first team where all members
|
||||
// of the DM are a part of. If no such team exists,
|
||||
// we use the first team to which DM creator belongs to.
|
||||
func (s *SQLStore) getBestTeamForBoard(tx sq.BaseRunner, board *model.Board) (string, error) {
|
||||
userTeams, err := s.getBoardUserTeams(tx, board)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
teams := [][]interface{}{}
|
||||
for _, userTeam := range userTeams {
|
||||
userTeamInterfaces := make([]interface{}, len(userTeam))
|
||||
for i := range userTeam {
|
||||
userTeamInterfaces[i] = userTeam[i]
|
||||
}
|
||||
teams = append(teams, userTeamInterfaces)
|
||||
}
|
||||
|
||||
commonTeams := utils.Intersection(teams...)
|
||||
var teamID string
|
||||
if len(commonTeams) > 0 {
|
||||
teamID = commonTeams[0].(string)
|
||||
} else {
|
||||
// no common teams found. Let's try finding the best suitable team
|
||||
if board.Type == "D" {
|
||||
// get DM's creator and pick one of their team
|
||||
channel, err := (s.servicesAPI).GetChannelByID(board.ChannelID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to fetch DM channel for board",
|
||||
mlog.String("board_id", board.ID),
|
||||
mlog.String("channel_id", board.ChannelID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, ok := userTeams[channel.CreatorId]; !ok {
|
||||
s.logger.Error("channel creator not found in user teams",
|
||||
mlog.String("board_id", board.ID),
|
||||
mlog.String("channel_id", board.ChannelID),
|
||||
mlog.String("creator_id", channel.CreatorId),
|
||||
)
|
||||
err := fmt.Errorf("%w board_id: %s, channel_id: %s, creator_id: %s", errChannelCreatorNotInTeam, board.ID, board.ChannelID, channel.CreatorId)
|
||||
return "", err
|
||||
}
|
||||
|
||||
teamID = userTeams[channel.CreatorId][0]
|
||||
} else if board.Type == "G" {
|
||||
// pick the team that has the most users as members
|
||||
teamFrequency := map[string]int{}
|
||||
highestFrequencyTeam := ""
|
||||
highestFrequencyTeamFrequency := -1
|
||||
|
||||
for _, teams := range userTeams {
|
||||
for _, teamID := range teams {
|
||||
teamFrequency[teamID]++
|
||||
|
||||
if teamFrequency[teamID] > highestFrequencyTeamFrequency {
|
||||
highestFrequencyTeamFrequency = teamFrequency[teamID]
|
||||
highestFrequencyTeam = teamID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teamID = highestFrequencyTeam
|
||||
}
|
||||
}
|
||||
|
||||
return teamID, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardUserTeams(tx sq.BaseRunner, board *model.Board) (map[string][]string, error) {
|
||||
query := s.getQueryBuilder(tx).
|
||||
Select("tm.UserId", "tm.TeamId").
|
||||
From("ChannelMembers cm").
|
||||
Join("TeamMembers tm ON cm.UserId = tm.UserId").
|
||||
Join("Teams t ON tm.TeamId = t.Id").
|
||||
Where(sq.Eq{
|
||||
"cm.ChannelId": board.ChannelID,
|
||||
"t.DeleteAt": 0,
|
||||
"tm.DeleteAt": 0,
|
||||
})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to fetch user teams for board", mlog.String("boardID", board.ID), mlog.String("channelID", board.ChannelID), mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
userTeams := map[string][]string{}
|
||||
|
||||
for rows.Next() {
|
||||
var userID, teamID string
|
||||
err := rows.Scan(&userID, &teamID)
|
||||
if err != nil {
|
||||
s.logger.Error("getBoardUserTeams failed to scan SQL query result", mlog.String("boardID", board.ID), mlog.String("channelID", board.ChannelID), mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userTeams[userID] = append(userTeams[userID], teamID)
|
||||
}
|
||||
|
||||
return userTeams, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) RunDeletedMembershipBoardsMigration() error {
|
||||
if !s.isPlugin {
|
||||
return nil
|
||||
}
|
||||
|
||||
setting, err := s.GetSystemSetting(DeletedMembershipBoardsMigrationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot get deleted membership boards migration state: %w", err)
|
||||
}
|
||||
|
||||
// If the migration is already completed, do not run it again.
|
||||
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
boards, err := s.getDeletedMembershipBoards(s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(boards) == 0 {
|
||||
s.logger.Debug("No boards with owner not anymore on their team found, marking runDeletedMembershipBoardsMigration as done")
|
||||
if sErr := s.SetSystemSetting(DeletedMembershipBoardsMigrationKey, strconv.FormatBool(true)); sErr != nil {
|
||||
return fmt.Errorf("cannot mark migration as completed: %w", sErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Debug("Migrating boards with owner not anymore on their team", mlog.Int("count", len(boards)))
|
||||
|
||||
tx, err := s.db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
s.logger.Error("error starting transaction in runDeletedMembershipBoardsMigration", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range boards {
|
||||
teamID, err := s.getBestTeamForBoard(s.db, boards[i])
|
||||
if err != nil {
|
||||
// don't let one board's error spoil
|
||||
// the mood for others
|
||||
s.logger.Error("could not find the best team for board during deleted membership boards migration. Continuing", mlog.String("boardID", boards[i].ID))
|
||||
continue
|
||||
}
|
||||
|
||||
boards[i].TeamID = teamID
|
||||
|
||||
query := s.getQueryBuilder(tx).
|
||||
Update(s.tablePrefix+"boards").
|
||||
Set("team_id", teamID).
|
||||
Where(sq.Eq{"id": boards[i].ID})
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error("failed to set team id for board", mlog.String("board_id", boards[i].ID), mlog.String("team_id", teamID), mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.setSystemSetting(tx, DeletedMembershipBoardsMigrationKey, strconv.FormatBool(true)); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "runDeletedMembershipBoardsMigration"))
|
||||
}
|
||||
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
s.logger.Error("failed to commit runDeletedMembershipBoardsMigration transaction", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDeletedMembershipBoards retrieves those boards whose creator is
|
||||
// associated to the board's team with a deleted team membership.
|
||||
func (s *SQLStore) getDeletedMembershipBoards(tx sq.BaseRunner) ([]*model.Board, error) {
|
||||
rows, err := s.getQueryBuilder(tx).
|
||||
Select(legacyBoardFields("b.")...).
|
||||
From(s.tablePrefix + "boards b").
|
||||
Join("TeamMembers tm ON b.created_by = tm.UserId").
|
||||
Where("b.team_id = tm.TeamId").
|
||||
Where(sq.NotEq{"tm.DeleteAt": 0}).
|
||||
Query()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
boards, err := s.boardsFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return boards, err
|
||||
}
|
||||
|
||||
func (s *SQLStore) RunFixCollationsAndCharsetsMigration() error {
|
||||
// This is for MySQL only
|
||||
if s.dbType != model.MysqlDBType {
|
||||
return nil
|
||||
}
|
||||
|
||||
// get collation and charSet setting that Channels is using.
|
||||
// when personal server or unit testing, no channels tables exist so just set to a default.
|
||||
var collation string
|
||||
var charSet string
|
||||
var err error
|
||||
if !s.isPlugin || os.Getenv("FOCALBOARD_UNIT_TESTING") == "1" {
|
||||
collation = "utf8mb4_general_ci"
|
||||
charSet = "utf8mb4"
|
||||
} else {
|
||||
collation, charSet, err = s.getCollationAndCharset("Channels")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// get all FocalBoard tables
|
||||
tableNames, err := s.getFocalBoardTableNames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
|
||||
// alter each table if there is a collation or charset mismatch
|
||||
for _, name := range tableNames {
|
||||
tableCollation, tableCharSet, err := s.getCollationAndCharset(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if collation == tableCollation && charSet == tableCharSet {
|
||||
// nothing to do
|
||||
continue
|
||||
}
|
||||
|
||||
s.logger.Warn(
|
||||
"found collation/charset mismatch, fixing table",
|
||||
mlog.String("tableName", name),
|
||||
mlog.String("tableCollation", tableCollation),
|
||||
mlog.String("tableCharSet", tableCharSet),
|
||||
mlog.String("collation", collation),
|
||||
mlog.String("charSet", charSet),
|
||||
)
|
||||
|
||||
sql := fmt.Sprintf("ALTER TABLE %s CONVERT TO CHARACTER SET '%s' COLLATE '%s'", name, charSet, collation)
|
||||
result, err := s.db.Exec(sql)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
continue
|
||||
}
|
||||
num, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
}
|
||||
if num > 0 {
|
||||
s.logger.Debug("table collation and/or charSet fixed",
|
||||
mlog.String("table_name", name),
|
||||
)
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
func (s *SQLStore) getFocalBoardTableNames() ([]string, error) {
|
||||
if s.dbType != model.MysqlDBType {
|
||||
return nil, newErrInvalidDBType("getFocalBoardTableNames requires MySQL")
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder(s.db).
|
||||
Select("table_name").
|
||||
From("information_schema.tables").
|
||||
Where(sq.Like{"table_name": s.tablePrefix + "%"}).
|
||||
Where("table_schema=(SELECT DATABASE())")
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching FocalBoard table names: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
names := make([]string, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
|
||||
err := rows.Scan(&tableName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot scan result while fetching table names: %w", err)
|
||||
}
|
||||
|
||||
names = append(names, tableName)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getCollationAndCharset(tableName string) (string, string, error) {
|
||||
if s.dbType != model.MysqlDBType {
|
||||
return "", "", newErrInvalidDBType("getCollationAndCharset requires MySQL")
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder(s.db).
|
||||
Select("table_collation").
|
||||
From("information_schema.tables").
|
||||
Where(sq.Eq{"table_name": tableName}).
|
||||
Where("table_schema=(SELECT DATABASE())")
|
||||
|
||||
row := query.QueryRow()
|
||||
|
||||
var collation string
|
||||
err := row.Scan(&collation)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error fetching collation for table %s: %w", tableName, err)
|
||||
}
|
||||
|
||||
// obtains the charset from the first column that has it set
|
||||
query = s.getQueryBuilder(s.db).
|
||||
Select("CHARACTER_SET_NAME").
|
||||
From("information_schema.columns").
|
||||
Where(sq.Eq{
|
||||
"table_name": tableName,
|
||||
}).
|
||||
Where("table_schema=(SELECT DATABASE())").
|
||||
Where(sq.NotEq{"CHARACTER_SET_NAME": "NULL"}).
|
||||
Limit(1)
|
||||
|
||||
row = query.QueryRow()
|
||||
|
||||
var charSet string
|
||||
err = row.Scan(&charSet)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error fetching charSet: %w", err)
|
||||
}
|
||||
|
||||
return collation, charSet, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) RunDeDuplicateCategoryBoardsMigration(currentMigration int) error {
|
||||
setting, err := s.GetSystemSetting(DeDuplicateCategoryBoardTableMigrationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot get DeDuplicateCategoryBoardTableMigration state: %w", err)
|
||||
}
|
||||
|
||||
// If the migration is already completed, do not run it again.
|
||||
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
if currentMigration >= (deDuplicateCategoryBoards + 1) {
|
||||
// if the migration for which we're fixing the data is already applied,
|
||||
// no need to check fix anything
|
||||
|
||||
if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil {
|
||||
return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
needed, err := s.doesDuplicateCategoryBoardsExist()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !needed {
|
||||
if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil {
|
||||
return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr)
|
||||
}
|
||||
}
|
||||
|
||||
if s.dbType == model.MysqlDBType {
|
||||
return s.runMySQLDeDuplicateCategoryBoardsMigration()
|
||||
} else if s.dbType == model.PostgresDBType {
|
||||
return s.runPostgresDeDuplicateCategoryBoardsMigration()
|
||||
}
|
||||
|
||||
if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil {
|
||||
return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) doesDuplicateCategoryBoardsExist() (bool, error) {
|
||||
subQuery := s.getQueryBuilder(s.db).
|
||||
Select("user_id", "board_id", "count(*) AS count").
|
||||
From(s.tablePrefix+"category_boards").
|
||||
GroupBy("user_id", "board_id").
|
||||
Having("count(*) > 1")
|
||||
|
||||
query := s.getQueryBuilder(s.db).
|
||||
Select("COUNT(user_id)").
|
||||
FromSelect(subQuery, "duplicate_dataset")
|
||||
|
||||
row := query.QueryRow()
|
||||
|
||||
count := 0
|
||||
if err := row.Scan(&count); err != nil {
|
||||
s.logger.Error("Error occurred reading number of duplicate records in category_boards table", mlog.Err(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) runMySQLDeDuplicateCategoryBoardsMigration() error {
|
||||
query := "WITH duplicates AS (SELECT id, ROW_NUMBER() OVER(PARTITION BY user_id, board_id) AS rownum " +
|
||||
"FROM " + s.tablePrefix + "category_boards) " +
|
||||
"DELETE " + s.tablePrefix + "category_boards FROM " + s.tablePrefix + "category_boards " +
|
||||
"JOIN duplicates USING(id) WHERE duplicates.rownum > 1;"
|
||||
if _, err := s.db.Exec(query); err != nil {
|
||||
s.logger.Error("Failed to de-duplicate data in category_boards table", mlog.Err(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) runPostgresDeDuplicateCategoryBoardsMigration() error {
|
||||
query := "WITH duplicates AS (SELECT id, ROW_NUMBER() OVER(PARTITION BY user_id, board_id) AS rownum " +
|
||||
"FROM " + s.tablePrefix + "category_boards) " +
|
||||
"DELETE FROM " + s.tablePrefix + "category_boards USING duplicates " +
|
||||
"WHERE " + s.tablePrefix + "category_boards.id = duplicates.id AND duplicates.rownum > 1;"
|
||||
if _, err := s.db.Exec(query); err != nil {
|
||||
s.logger.Error("Failed to de-duplicate data in category_boards table", mlog.Err(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
265
server/boards/services/store/sqlstore/data_migrations_test.go
Обычный файл
265
server/boards/services/store/sqlstore/data_migrations_test.go
Обычный файл
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetBlocksWithSameID(t *testing.T) {
|
||||
t.Skip("we need to setup a test with the database migrated up to version 14 and then run these tests")
|
||||
|
||||
RunStoreTestsWithSqlStore(t, func(t *testing.T, sqlStore *SQLStore) {
|
||||
container1 := "1"
|
||||
container2 := "2"
|
||||
container3 := "3"
|
||||
|
||||
block1 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"}
|
||||
block2 := &model.Block{ID: "block-id-2", BoardID: "board-id-2"}
|
||||
block3 := &model.Block{ID: "block-id-3", BoardID: "board-id-3"}
|
||||
|
||||
block4 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"}
|
||||
block5 := &model.Block{ID: "block-id-2", BoardID: "board-id-2"}
|
||||
|
||||
block6 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"}
|
||||
block7 := &model.Block{ID: "block-id-7", BoardID: "board-id-7"}
|
||||
block8 := &model.Block{ID: "block-id-8", BoardID: "board-id-8"}
|
||||
|
||||
for _, block := range []*model.Block{block1, block2, block3} {
|
||||
err := sqlStore.insertLegacyBlock(sqlStore.db, container1, block, "user-id")
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
for _, block := range []*model.Block{block4, block5} {
|
||||
err := sqlStore.insertLegacyBlock(sqlStore.db, container2, block, "user-id")
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
for _, block := range []*model.Block{block6, block7, block8} {
|
||||
err := sqlStore.insertLegacyBlock(sqlStore.db, container3, block, "user-id")
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
blocksWithDuplicatedID := []*model.Block{block1, block2, block4, block5, block6}
|
||||
|
||||
blocks, err := sqlStore.getBlocksWithSameID(sqlStore.db)
|
||||
require.NoError(t, err)
|
||||
|
||||
// we process the found blocks to remove extra information and be
|
||||
// able to compare both expected and found sets
|
||||
foundBlocks := []*model.Block{}
|
||||
for _, foundBlock := range blocks {
|
||||
foundBlocks = append(foundBlocks, &model.Block{ID: foundBlock.ID, BoardID: foundBlock.BoardID})
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, blocksWithDuplicatedID, foundBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReplaceBlockID(t *testing.T) {
|
||||
t.Skip("we need to setup a test with the database migrated up to version 14 and then run these tests")
|
||||
|
||||
RunStoreTestsWithSqlStore(t, func(t *testing.T, sqlStore *SQLStore) {
|
||||
container1 := "1"
|
||||
container2 := "2"
|
||||
|
||||
// blocks from team1
|
||||
block1 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"}
|
||||
block2 := &model.Block{ID: "block-id-2", BoardID: "board-id-2", ParentID: "block-id-1"}
|
||||
block3 := &model.Block{ID: "block-id-3", BoardID: "block-id-1"}
|
||||
block4 := &model.Block{ID: "block-id-4", BoardID: "block-id-2"}
|
||||
block5 := &model.Block{ID: "block-id-5", BoardID: "block-id-1", ParentID: "block-id-1"}
|
||||
block8 := &model.Block{
|
||||
ID: "block-id-8", BoardID: "board-id-2", Type: model.TypeCard,
|
||||
Fields: map[string]interface{}{"contentOrder": []string{"block-id-1", "block-id-2"}},
|
||||
}
|
||||
|
||||
// blocks from team2. They're identical to blocks 1 and 2,
|
||||
// but they shouldn't change
|
||||
block6 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"}
|
||||
block7 := &model.Block{ID: "block-id-2", BoardID: "board-id-2", ParentID: "block-id-1"}
|
||||
block9 := &model.Block{
|
||||
ID: "block-id-8", BoardID: "board-id-2", Type: model.TypeCard,
|
||||
Fields: map[string]interface{}{"contentOrder": []string{"block-id-1", "block-id-2"}},
|
||||
}
|
||||
|
||||
for _, block := range []*model.Block{block1, block2, block3, block4, block5, block8} {
|
||||
err := sqlStore.insertLegacyBlock(sqlStore.db, container1, block, "user-id")
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
for _, block := range []*model.Block{block6, block7, block9} {
|
||||
err := sqlStore.insertLegacyBlock(sqlStore.db, container2, block, "user-id")
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
currentID := "block-id-1"
|
||||
newID := "new-id-1"
|
||||
err := sqlStore.replaceBlockID(sqlStore.db, currentID, newID, "1")
|
||||
require.NoError(t, err)
|
||||
|
||||
newBlock1, err := sqlStore.getLegacyBlock(sqlStore.db, container1, newID)
|
||||
require.NoError(t, err)
|
||||
newBlock2, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block2.ID)
|
||||
require.NoError(t, err)
|
||||
newBlock3, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block3.ID)
|
||||
require.NoError(t, err)
|
||||
newBlock5, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block5.ID)
|
||||
require.NoError(t, err)
|
||||
newBlock6, err := sqlStore.getLegacyBlock(sqlStore.db, container2, block6.ID)
|
||||
require.NoError(t, err)
|
||||
newBlock7, err := sqlStore.getLegacyBlock(sqlStore.db, container2, block7.ID)
|
||||
require.NoError(t, err)
|
||||
newBlock8, err := sqlStore.GetBlock(block8.ID)
|
||||
require.NoError(t, err)
|
||||
newBlock9, err := sqlStore.GetBlock(block9.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, newID, newBlock1.ID)
|
||||
require.Equal(t, newID, newBlock2.ParentID)
|
||||
require.Equal(t, newID, newBlock3.BoardID)
|
||||
require.Equal(t, newID, newBlock5.BoardID)
|
||||
require.Equal(t, newID, newBlock5.ParentID)
|
||||
require.Equal(t, newBlock8.Fields["contentOrder"].([]interface{})[0], newID)
|
||||
require.Equal(t, newBlock8.Fields["contentOrder"].([]interface{})[1], "block-id-2")
|
||||
|
||||
require.Equal(t, currentID, newBlock6.ID)
|
||||
require.Equal(t, currentID, newBlock7.ParentID)
|
||||
require.Equal(t, newBlock9.Fields["contentOrder"].([]interface{})[0], "block-id-1")
|
||||
require.Equal(t, newBlock9.Fields["contentOrder"].([]interface{})[1], "block-id-2")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunUniqueIDsMigration(t *testing.T) {
|
||||
t.Skip("we need to setup a test with the database migrated up to version 14 and then run these tests")
|
||||
|
||||
RunStoreTestsWithSqlStore(t, func(t *testing.T, sqlStore *SQLStore) {
|
||||
// we need to mark the migration as not done so we can run it
|
||||
// again with the test data
|
||||
keyErr := sqlStore.SetSystemSetting(UniqueIDsMigrationKey, "false")
|
||||
require.NoError(t, keyErr)
|
||||
|
||||
container1 := "1"
|
||||
container2 := "2"
|
||||
container3 := "3"
|
||||
|
||||
// blocks from workspace1. They shouldn't change, as the first
|
||||
// duplicated ID is preserved
|
||||
block1 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"}
|
||||
block2 := &model.Block{ID: "block-id-2", BoardID: "board-id-2", ParentID: "block-id-1"}
|
||||
block3 := &model.Block{ID: "block-id-3", BoardID: "block-id-1"}
|
||||
|
||||
// blocks from workspace2. They're identical to blocks 1, 2 and 3,
|
||||
// and they should change
|
||||
block4 := &model.Block{ID: "block-id-1", BoardID: "board-id-1"}
|
||||
block5 := &model.Block{ID: "block-id-2", BoardID: "board-id-2", ParentID: "block-id-1"}
|
||||
block6 := &model.Block{ID: "block-id-6", BoardID: "block-id-1", ParentID: "block-id-2"}
|
||||
|
||||
// block from workspace3. It should change as well
|
||||
block7 := &model.Block{ID: "block-id-2", BoardID: "board-id-2"}
|
||||
|
||||
for _, block := range []*model.Block{block1, block2, block3} {
|
||||
err := sqlStore.insertLegacyBlock(sqlStore.db, container1, block, "user-id-2")
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
for _, block := range []*model.Block{block4, block5, block6} {
|
||||
err := sqlStore.insertLegacyBlock(sqlStore.db, container2, block, "user-id-2")
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
for _, block := range []*model.Block{block7} {
|
||||
err := sqlStore.insertLegacyBlock(sqlStore.db, container3, block, "user-id-2")
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
err := sqlStore.RunUniqueIDsMigration()
|
||||
require.NoError(t, err)
|
||||
|
||||
// blocks from workspace 1 haven't changed, so we can simply fetch them
|
||||
newBlock1, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block1.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newBlock1)
|
||||
newBlock2, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block2.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newBlock2)
|
||||
newBlock3, err := sqlStore.getLegacyBlock(sqlStore.db, container1, block3.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newBlock3)
|
||||
|
||||
// first two blocks from workspace 2 have changed, so we fetch
|
||||
// them through the third one, which points to the new IDs
|
||||
newBlock6, err := sqlStore.getLegacyBlock(sqlStore.db, container2, block6.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newBlock6)
|
||||
newBlock4, err := sqlStore.getLegacyBlock(sqlStore.db, container2, newBlock6.BoardID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newBlock4)
|
||||
newBlock5, err := sqlStore.getLegacyBlock(sqlStore.db, container2, newBlock6.ParentID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newBlock5)
|
||||
|
||||
// block from workspace 3 changed as well, so we shouldn't be able
|
||||
// to fetch it
|
||||
newBlock7, err := sqlStore.getLegacyBlock(sqlStore.db, container3, block7.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, newBlock7)
|
||||
|
||||
// workspace 1 block links are maintained
|
||||
require.Equal(t, newBlock1.ID, newBlock2.ParentID)
|
||||
require.Equal(t, newBlock1.ID, newBlock3.BoardID)
|
||||
|
||||
// workspace 2 first two block IDs have changed
|
||||
require.NotEqual(t, block4.ID, newBlock4.BoardID)
|
||||
require.NotEqual(t, block5.ID, newBlock5.ParentID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckForMismatchedCollation(t *testing.T) {
|
||||
RunStoreTestsWithSqlStore(t, func(t *testing.T, sqlStore *SQLStore) {
|
||||
if sqlStore.dbType != model.MysqlDBType {
|
||||
return
|
||||
}
|
||||
|
||||
// make sure all collations are consistent.
|
||||
tableNames, err := sqlStore.getFocalBoardTableNames()
|
||||
require.NoError(t, err)
|
||||
|
||||
sqlCollation := "SELECT table_collation FROM information_schema.tables WHERE table_name=? and table_schema=(SELECT DATABASE())"
|
||||
stmtCollation, err := sqlStore.db.Prepare(sqlCollation)
|
||||
require.NoError(t, err)
|
||||
defer stmtCollation.Close()
|
||||
|
||||
var collation string
|
||||
|
||||
// make sure the correct charset is applied to each table.
|
||||
for i, name := range tableNames {
|
||||
row := stmtCollation.QueryRow(name)
|
||||
|
||||
var actualCollation string
|
||||
err = row.Scan(&actualCollation)
|
||||
require.NoError(t, err)
|
||||
|
||||
if collation == "" {
|
||||
collation = actualCollation
|
||||
}
|
||||
|
||||
assert.Equalf(t, collation, actualCollation, "for table_name='%s', index=%d", name, i)
|
||||
}
|
||||
})
|
||||
}
|
||||
179
server/boards/services/store/sqlstore/data_retention.go
Обычный файл
179
server/boards/services/store/sqlstore/data_retention.go
Обычный файл
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
_ "github.com/lib/pq" // postgres driver
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type RetentionTableDeletionInfo struct {
|
||||
Table string
|
||||
PrimaryKeys []string
|
||||
BoardIDColumn string
|
||||
}
|
||||
|
||||
func (s *SQLStore) runDataRetention(db sq.BaseRunner, globalRetentionDate int64, batchSize int64) (int64, error) {
|
||||
s.logger.Info("Start Boards Data Retention",
|
||||
mlog.String("Global Retention Date", time.Unix(globalRetentionDate/1000, 0).String()),
|
||||
mlog.Int64("Raw Date", globalRetentionDate))
|
||||
deleteTables := []RetentionTableDeletionInfo{
|
||||
{
|
||||
Table: "blocks",
|
||||
PrimaryKeys: []string{"id"},
|
||||
BoardIDColumn: "board_id",
|
||||
},
|
||||
{
|
||||
Table: "blocks_history",
|
||||
PrimaryKeys: []string{"id"},
|
||||
BoardIDColumn: "board_id",
|
||||
},
|
||||
{
|
||||
Table: "boards",
|
||||
PrimaryKeys: []string{"id"},
|
||||
BoardIDColumn: "id",
|
||||
},
|
||||
{
|
||||
Table: "boards_history",
|
||||
PrimaryKeys: []string{"id"},
|
||||
BoardIDColumn: "id",
|
||||
},
|
||||
{
|
||||
Table: "board_members",
|
||||
PrimaryKeys: []string{"board_id"},
|
||||
BoardIDColumn: "board_id",
|
||||
},
|
||||
{
|
||||
Table: "board_members_history",
|
||||
PrimaryKeys: []string{"board_id"},
|
||||
BoardIDColumn: "board_id",
|
||||
},
|
||||
{
|
||||
Table: "sharing",
|
||||
PrimaryKeys: []string{"id"},
|
||||
BoardIDColumn: "id",
|
||||
},
|
||||
{
|
||||
Table: "category_boards",
|
||||
PrimaryKeys: []string{"id"},
|
||||
BoardIDColumn: "board_id",
|
||||
},
|
||||
}
|
||||
|
||||
subBuilder := s.getQueryBuilder(db).
|
||||
Select("board_id, MAX(update_at) AS maxDate").
|
||||
From(s.tablePrefix + "blocks").
|
||||
GroupBy("board_id")
|
||||
|
||||
subQuery, _, _ := subBuilder.ToSql()
|
||||
|
||||
builder := s.getQueryBuilder(db).
|
||||
Select("id").
|
||||
From(s.tablePrefix + "boards").
|
||||
LeftJoin("( " + subQuery + " ) As subquery ON (subquery.board_id = id)").
|
||||
Where(sq.Lt{"maxDate": globalRetentionDate}).
|
||||
Where(sq.NotEq{"team_id": "0"}).
|
||||
Where(sq.Eq{"is_template": false})
|
||||
|
||||
rows, err := builder.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`dataRetention subquery ERROR`, mlog.Err(err))
|
||||
return 0, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
deleteIds, err := idsFromRows(rows)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
totalAffected := 0
|
||||
if len(deleteIds) > 0 {
|
||||
for _, table := range deleteTables {
|
||||
affected, err := s.genericRetentionPoliciesDeletion(db, table, deleteIds, batchSize)
|
||||
if err != nil {
|
||||
return int64(totalAffected), err
|
||||
}
|
||||
totalAffected += int(affected)
|
||||
}
|
||||
}
|
||||
s.logger.Info("Complete Boards Data Retention",
|
||||
mlog.Int("Total deletion ids", len(deleteIds)),
|
||||
mlog.Int("TotalAffected", totalAffected))
|
||||
return int64(totalAffected), nil
|
||||
}
|
||||
|
||||
func idsFromRows(rows *sql.Rows) ([]string, error) {
|
||||
deleteIds := []string{}
|
||||
for rows.Next() {
|
||||
var boardID string
|
||||
err := rows.Scan(
|
||||
&boardID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deleteIds = append(deleteIds, boardID)
|
||||
}
|
||||
return deleteIds, nil
|
||||
}
|
||||
|
||||
// genericRetentionPoliciesDeletion actually executes the DELETE query
|
||||
// using a sq.SelectBuilder which selects the rows to delete.
|
||||
func (s *SQLStore) genericRetentionPoliciesDeletion(
|
||||
db sq.BaseRunner,
|
||||
info RetentionTableDeletionInfo,
|
||||
deleteIds []string,
|
||||
batchSize int64,
|
||||
) (int64, error) {
|
||||
whereClause := info.BoardIDColumn + " IN ('" + strings.Join(deleteIds, "','") + "')"
|
||||
deleteQuery := s.getQueryBuilder(db).
|
||||
Delete(s.tablePrefix + info.Table).
|
||||
Where(whereClause)
|
||||
|
||||
if batchSize > 0 {
|
||||
deleteQuery.Limit(uint64(batchSize))
|
||||
primaryKeysStr := "(" + strings.Join(info.PrimaryKeys, ",") + ")"
|
||||
if s.dbType != model.MysqlDBType {
|
||||
selectQuery := s.getQueryBuilder(db).
|
||||
Select(primaryKeysStr).
|
||||
From(s.tablePrefix + info.Table).
|
||||
Where(whereClause).
|
||||
Limit(uint64(batchSize))
|
||||
|
||||
selectString, _, _ := selectQuery.ToSql()
|
||||
|
||||
deleteQuery = s.getQueryBuilder(db).
|
||||
Delete(s.tablePrefix + info.Table).
|
||||
Where(primaryKeysStr + " IN (" + selectString + ")")
|
||||
}
|
||||
}
|
||||
|
||||
var totalRowsAffected int64
|
||||
var batchRowsAffected int64
|
||||
for {
|
||||
result, err := deleteQuery.Exec()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to delete "+info.Table)
|
||||
}
|
||||
|
||||
batchRowsAffected, err = result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get rows affected for "+info.Table)
|
||||
}
|
||||
totalRowsAffected += batchRowsAffected
|
||||
if batchRowsAffected != batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return totalRowsAffected, nil
|
||||
}
|
||||
95
server/boards/services/store/sqlstore/file.go
Обычный файл
95
server/boards/services/store/sqlstore/file.go
Обычный файл
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (s *SQLStore) saveFileInfo(db sq.BaseRunner, fileInfo *mm_model.FileInfo) error {
|
||||
query := s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"file_info").
|
||||
Columns(
|
||||
"id",
|
||||
"create_at",
|
||||
"name",
|
||||
"extension",
|
||||
"size",
|
||||
"delete_at",
|
||||
"path",
|
||||
"archived",
|
||||
).
|
||||
Values(
|
||||
fileInfo.Id,
|
||||
fileInfo.CreateAt,
|
||||
fileInfo.Name,
|
||||
fileInfo.Extension,
|
||||
fileInfo.Size,
|
||||
fileInfo.DeleteAt,
|
||||
fileInfo.Path,
|
||||
false,
|
||||
)
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error(
|
||||
"failed to save fileinfo",
|
||||
mlog.String("file_name", fileInfo.Name),
|
||||
mlog.Int64("size", fileInfo.Size),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getFileInfo(db sq.BaseRunner, id string) (*mm_model.FileInfo, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(
|
||||
"id",
|
||||
"create_at",
|
||||
"delete_at",
|
||||
"name",
|
||||
"extension",
|
||||
"size",
|
||||
"archived",
|
||||
"path",
|
||||
).
|
||||
From(s.tablePrefix + "file_info").
|
||||
Where(sq.Eq{"Id": id})
|
||||
|
||||
row := query.QueryRow()
|
||||
|
||||
fileInfo := mm_model.FileInfo{}
|
||||
|
||||
err := row.Scan(
|
||||
&fileInfo.Id,
|
||||
&fileInfo.CreateAt,
|
||||
&fileInfo.DeleteAt,
|
||||
&fileInfo.Name,
|
||||
&fileInfo.Extension,
|
||||
&fileInfo.Size,
|
||||
&fileInfo.Archived,
|
||||
&fileInfo.Path,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, model.NewErrNotFound("file info ID=" + id)
|
||||
}
|
||||
|
||||
s.logger.Error("error scanning fileinfo row", mlog.String("id", id), mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &fileInfo, nil
|
||||
}
|
||||
263
server/boards/services/store/sqlstore/legacy_blocks.go
Обычный файл
263
server/boards/services/store/sqlstore/legacy_blocks.go
Обычный файл
@@ -0,0 +1,263 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func legacyBoardFields(prefix string) []string {
|
||||
// substitute new columns with `"\"\""` (empty string) so as to allow
|
||||
// row scan to continue to work with new models.
|
||||
|
||||
fields := []string{
|
||||
"id",
|
||||
"team_id",
|
||||
"COALESCE(channel_id, '')",
|
||||
"COALESCE(created_by, '')",
|
||||
"modified_by",
|
||||
"type",
|
||||
"''", // substitute for minimum_role column.
|
||||
"title",
|
||||
"description",
|
||||
"icon",
|
||||
"show_description",
|
||||
"is_template",
|
||||
"template_version",
|
||||
"COALESCE(properties, '{}')",
|
||||
"COALESCE(card_properties, '[]')",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
}
|
||||
|
||||
if prefix == "" {
|
||||
return fields
|
||||
}
|
||||
|
||||
prefixedFields := make([]string, len(fields))
|
||||
for i, field := range fields {
|
||||
switch {
|
||||
case strings.HasPrefix(field, "COALESCE("):
|
||||
prefixedFields[i] = strings.Replace(field, "COALESCE(", "COALESCE("+prefix, 1)
|
||||
case field == "''":
|
||||
prefixedFields[i] = field
|
||||
default:
|
||||
prefixedFields[i] = prefix + field
|
||||
}
|
||||
}
|
||||
return prefixedFields
|
||||
}
|
||||
|
||||
// legacyBlocksFromRows is the old getBlock version that still uses
|
||||
// the old block model. This method is kept to enable the unique IDs
|
||||
// data migration.
|
||||
//
|
||||
//nolint:unused
|
||||
func (s *SQLStore) legacyBlocksFromRows(rows *sql.Rows) ([]*model.Block, error) {
|
||||
results := []*model.Block{}
|
||||
|
||||
for rows.Next() {
|
||||
var block model.Block
|
||||
var fieldsJSON string
|
||||
var modifiedBy sql.NullString
|
||||
var insertAt string
|
||||
|
||||
err := rows.Scan(
|
||||
&block.ID,
|
||||
&block.ParentID,
|
||||
&block.BoardID,
|
||||
&block.CreatedBy,
|
||||
&modifiedBy,
|
||||
&block.Schema,
|
||||
&block.Type,
|
||||
&block.Title,
|
||||
&fieldsJSON,
|
||||
&insertAt,
|
||||
&block.CreateAt,
|
||||
&block.UpdateAt,
|
||||
&block.DeleteAt,
|
||||
&block.WorkspaceID)
|
||||
if err != nil {
|
||||
// handle this error
|
||||
s.logger.Error(`ERROR blocksFromRows`, mlog.Err(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if modifiedBy.Valid {
|
||||
block.ModifiedBy = modifiedBy.String
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(fieldsJSON), &block.Fields)
|
||||
if err != nil {
|
||||
// handle this error
|
||||
s.logger.Error(`ERROR blocksFromRows fields`, mlog.Err(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results = append(results, &block)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// getLegacyBlock is the old getBlock version that still uses the old
|
||||
// block model. This method is kept to enable the unique IDs data
|
||||
// migration.
|
||||
//
|
||||
//nolint:unused
|
||||
func (s *SQLStore) getLegacyBlock(db sq.BaseRunner, workspaceID string, blockID string) (*model.Block, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(
|
||||
"id",
|
||||
"parent_id",
|
||||
"root_id",
|
||||
"created_by",
|
||||
"modified_by",
|
||||
s.escapeField("schema"),
|
||||
"type",
|
||||
"title",
|
||||
"COALESCE(fields, '{}')",
|
||||
"insert_at",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
"COALESCE(workspace_id, '0')",
|
||||
).
|
||||
From(s.tablePrefix + "blocks").
|
||||
Where(sq.Eq{"id": blockID}).
|
||||
Where(sq.Eq{"coalesce(workspace_id, '0')": workspaceID})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`GetBlock ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blocks, err := s.legacyBlocksFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return blocks[0], nil
|
||||
}
|
||||
|
||||
// insertLegacyBlock is the old insertBlock version that still uses
|
||||
// the old block model. This method is kept to enable the unique IDs
|
||||
// data migration.
|
||||
//
|
||||
//nolint:unused
|
||||
func (s *SQLStore) insertLegacyBlock(db sq.BaseRunner, workspaceID string, block *model.Block, userID string) error {
|
||||
if block.BoardID == "" {
|
||||
return ErrEmptyBoardID{}
|
||||
}
|
||||
|
||||
fieldsJSON, err := json.Marshal(block.Fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingBlock, err := s.getLegacyBlock(db, workspaceID, block.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block.UpdateAt = utils.GetMillis()
|
||||
block.ModifiedBy = userID
|
||||
|
||||
insertQuery := s.getQueryBuilder(db).Insert("").
|
||||
Columns(
|
||||
"workspace_id",
|
||||
"id",
|
||||
"parent_id",
|
||||
"root_id",
|
||||
"created_by",
|
||||
"modified_by",
|
||||
s.escapeField("schema"),
|
||||
"type",
|
||||
"title",
|
||||
"fields",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
)
|
||||
|
||||
insertQueryValues := map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"id": block.ID,
|
||||
"parent_id": block.ParentID,
|
||||
"root_id": block.BoardID,
|
||||
s.escapeField("schema"): block.Schema,
|
||||
"type": block.Type,
|
||||
"title": block.Title,
|
||||
"fields": fieldsJSON,
|
||||
"delete_at": block.DeleteAt,
|
||||
"created_by": block.CreatedBy,
|
||||
"modified_by": block.ModifiedBy,
|
||||
"create_at": block.CreateAt,
|
||||
"update_at": block.UpdateAt,
|
||||
}
|
||||
|
||||
if existingBlock != nil {
|
||||
// block with ID exists, so this is an update operation
|
||||
query := s.getQueryBuilder(db).Update(s.tablePrefix+"blocks").
|
||||
Where(sq.Eq{"id": block.ID}).
|
||||
Where(sq.Eq{"COALESCE(workspace_id, '0')": workspaceID}).
|
||||
Set("parent_id", block.ParentID).
|
||||
Set("root_id", block.BoardID).
|
||||
Set("modified_by", block.ModifiedBy).
|
||||
Set(s.escapeField("schema"), block.Schema).
|
||||
Set("type", block.Type).
|
||||
Set("title", block.Title).
|
||||
Set("fields", fieldsJSON).
|
||||
Set("update_at", block.UpdateAt).
|
||||
Set("delete_at", block.DeleteAt)
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error(`InsertBlock error occurred while updating existing block`, mlog.String("blockID", block.ID), mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
block.CreatedBy = userID
|
||||
block.CreateAt = utils.GetMillis()
|
||||
|
||||
insertQueryValues["created_by"] = block.CreatedBy
|
||||
insertQueryValues["create_at"] = block.CreateAt
|
||||
insertQueryValues["update_at"] = block.UpdateAt
|
||||
insertQueryValues["modified_by"] = block.ModifiedBy
|
||||
|
||||
query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "blocks")
|
||||
if _, err := query.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// writing block history
|
||||
query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "blocks_history")
|
||||
if _, err := query.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getLegacyBoardsByCondition(db sq.BaseRunner, conditions ...interface{}) ([]*model.Board, error) {
|
||||
return s.getBoardsFieldsByCondition(db, legacyBoardFields(""), conditions...)
|
||||
}
|
||||
22
server/boards/services/store/sqlstore/main_test.go
Обычный файл
22
server/boards/services/store/sqlstore/main_test.go
Обычный файл
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
mainStoreTypes = initStores(false)
|
||||
|
||||
status := m.Run()
|
||||
|
||||
for _, st := range mainStoreTypes {
|
||||
_ = st.Store.Shutdown()
|
||||
_ = st.Logger.Shutdown()
|
||||
}
|
||||
|
||||
os.Exit(status)
|
||||
}
|
||||
655
server/boards/services/store/sqlstore/migrate.go
Обычный файл
655
server/boards/services/store/sqlstore/migrate.go
Обычный файл
@@ -0,0 +1,655 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"text/template"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/store/sqlstore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
"github.com/mattermost/morph"
|
||||
drivers "github.com/mattermost/morph/drivers"
|
||||
mysql "github.com/mattermost/morph/drivers/mysql"
|
||||
postgres "github.com/mattermost/morph/drivers/postgres"
|
||||
embedded "github.com/mattermost/morph/sources/embedded"
|
||||
|
||||
_ "github.com/lib/pq" // postgres driver
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var Assets embed.FS
|
||||
|
||||
const (
|
||||
uniqueIDsMigrationRequiredVersion = 14
|
||||
teamLessBoardsMigrationRequiredVersion = 18
|
||||
categoriesUUIDIDMigrationRequiredVersion = 20
|
||||
deDuplicateCategoryBoards = 35
|
||||
|
||||
tempSchemaMigrationTableName = "temp_schema_migration"
|
||||
)
|
||||
|
||||
var errChannelCreatorNotInTeam = errors.New("channel creator not found in user teams")
|
||||
|
||||
// migrations in MySQL need to run with the multiStatements flag
|
||||
// enabled, so this method creates a new connection ensuring that it's
|
||||
// enabled.
|
||||
func (s *SQLStore) getMigrationConnection() (*sql.DB, error) {
|
||||
connectionString := s.connectionString
|
||||
if s.dbType == model.MysqlDBType {
|
||||
var err error
|
||||
connectionString, err = sqlstore.ResetReadTimeout(connectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectionString, err = sqlstore.AppendMultipleStatementsFlag(connectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var settings mm_model.SqlSettings
|
||||
settings.SetDefaults(false)
|
||||
if s.configFn != nil {
|
||||
settings = s.configFn().SqlSettings
|
||||
}
|
||||
*settings.DriverName = s.dbType
|
||||
|
||||
db := sqlstore.SetupConnection("master", connectionString, &settings)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) Migrate() error {
|
||||
if err := s.EnsureSchemaMigrationFormat(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
// the old schema migration table deletion happens after the
|
||||
// migrations have run, to be able to recover its information
|
||||
// in case there would be errors during the process.
|
||||
if err := s.deleteOldSchemaMigrationTable(); err != nil {
|
||||
s.logger.Error("cannot delete the old schema migration table", mlog.Err(err))
|
||||
}
|
||||
}()
|
||||
|
||||
var driver drivers.Driver
|
||||
var err error
|
||||
var db *sql.DB
|
||||
s.logger.Debug("Getting migrations connection")
|
||||
db, err = s.getMigrationConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
s.logger.Debug("Closing migrations connection")
|
||||
db.Close()
|
||||
}()
|
||||
|
||||
if s.dbType == model.PostgresDBType {
|
||||
driver, err = postgres.WithInstance(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if s.dbType == model.MysqlDBType {
|
||||
driver, err = mysql.WithInstance(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
assetsList, err := Assets.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assetNamesForDriver := make([]string, len(assetsList))
|
||||
for i, dirEntry := range assetsList {
|
||||
assetNamesForDriver[i] = dirEntry.Name()
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"prefix": s.tablePrefix,
|
||||
"postgres": s.dbType == model.PostgresDBType,
|
||||
"mysql": s.dbType == model.MysqlDBType,
|
||||
"plugin": s.isPlugin,
|
||||
"singleUser": s.isSingleUser,
|
||||
}
|
||||
|
||||
migrationAssets := &embedded.AssetSource{
|
||||
Names: assetNamesForDriver,
|
||||
AssetFunc: func(name string) ([]byte, error) {
|
||||
asset, mErr := Assets.ReadFile("migrations/" + name)
|
||||
if mErr != nil {
|
||||
return nil, mErr
|
||||
}
|
||||
|
||||
tmpl, pErr := template.New("sql").Funcs(s.GetTemplateHelperFuncs()).Parse(string(asset))
|
||||
if pErr != nil {
|
||||
return nil, pErr
|
||||
}
|
||||
|
||||
buffer := bytes.NewBufferString("")
|
||||
|
||||
err = tmpl.Execute(buffer, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Trace("migration template",
|
||||
mlog.String("name", name),
|
||||
mlog.String("sql", buffer.String()),
|
||||
)
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
},
|
||||
}
|
||||
|
||||
src, err := embedded.WithInstance(migrationAssets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := []morph.EngineOption{
|
||||
morph.WithLock("boards-lock-key"),
|
||||
morph.SetMigrationTableName(fmt.Sprintf("%sschema_migrations", s.tablePrefix)),
|
||||
morph.SetStatementTimeoutInSeconds(1000000),
|
||||
}
|
||||
|
||||
s.logger.Debug("Creating migration engine")
|
||||
engine, err := morph.New(context.Background(), driver, src, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
s.logger.Debug("Closing migration engine")
|
||||
engine.Close()
|
||||
}()
|
||||
|
||||
return s.runMigrationSequence(engine, driver)
|
||||
}
|
||||
|
||||
// runMigrationSequence executes all the migrations in order, both
|
||||
// plain SQL and data migrations.
|
||||
func (s *SQLStore) runMigrationSequence(engine *morph.Morph, driver drivers.Driver) error {
|
||||
if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, uniqueIDsMigrationRequiredVersion); mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
|
||||
if mErr := s.RunUniqueIDsMigration(); mErr != nil {
|
||||
return fmt.Errorf("error running unique IDs migration: %w", mErr)
|
||||
}
|
||||
|
||||
if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, teamLessBoardsMigrationRequiredVersion); mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
|
||||
if mErr := s.RunTeamLessBoardsMigration(); mErr != nil {
|
||||
return fmt.Errorf("error running teamless boards migration: %w", mErr)
|
||||
}
|
||||
|
||||
if mErr := s.RunDeletedMembershipBoardsMigration(); mErr != nil {
|
||||
return fmt.Errorf("error running deleted membership boards migration: %w", mErr)
|
||||
}
|
||||
|
||||
if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, categoriesUUIDIDMigrationRequiredVersion); mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
|
||||
if mErr := s.RunCategoryUUIDIDMigration(); mErr != nil {
|
||||
return fmt.Errorf("error running categoryID migration: %w", mErr)
|
||||
}
|
||||
|
||||
appliedMigrations, err := driver.AppliedMigrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, deDuplicateCategoryBoards); mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
|
||||
currentMigrationVersion := len(appliedMigrations)
|
||||
if mErr := s.RunDeDuplicateCategoryBoardsMigration(currentMigrationVersion); mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
|
||||
s.logger.Debug("== Applying all remaining migrations ====================",
|
||||
mlog.Int("current_version", len(appliedMigrations)),
|
||||
)
|
||||
|
||||
if err := engine.ApplyAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// always run the collations & charset fix-ups
|
||||
if mErr := s.RunFixCollationsAndCharsetsMigration(); mErr != nil {
|
||||
return fmt.Errorf("error running fix collations and charsets migration: %w", mErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) ensureMigrationsAppliedUpToVersion(engine *morph.Morph, driver drivers.Driver, version int) error {
|
||||
applied, err := driver.AppliedMigrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentVersion := len(applied)
|
||||
|
||||
s.logger.Debug("== Ensuring migrations applied up to version ====================",
|
||||
mlog.Int("version", version),
|
||||
mlog.Int("current_version", currentVersion))
|
||||
|
||||
// if the target version is below or equal to the current one, do
|
||||
// not migrate either because is not needed (both are equal) or
|
||||
// because it would downgrade the database (is below)
|
||||
if version <= currentVersion {
|
||||
s.logger.Debug("-- There is no need of applying any migration --------------------")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, migration := range applied {
|
||||
s.logger.Debug("-- Found applied migration --------------------", mlog.Uint32("version", migration.Version), mlog.String("name", migration.Name))
|
||||
}
|
||||
|
||||
if _, err = engine.Apply(version - currentVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetTemplateHelperFuncs() template.FuncMap {
|
||||
funcs := template.FuncMap{
|
||||
"addColumnIfNeeded": s.genAddColumnIfNeeded,
|
||||
"dropColumnIfNeeded": s.genDropColumnIfNeeded,
|
||||
"createIndexIfNeeded": s.genCreateIndexIfNeeded,
|
||||
"renameTableIfNeeded": s.genRenameTableIfNeeded,
|
||||
"renameColumnIfNeeded": s.genRenameColumnIfNeeded,
|
||||
"doesTableExist": s.doesTableExist,
|
||||
"doesColumnExist": s.doesColumnExist,
|
||||
"addConstraintIfNeeded": s.genAddConstraintIfNeeded,
|
||||
}
|
||||
return funcs
|
||||
}
|
||||
|
||||
func (s *SQLStore) genAddColumnIfNeeded(tableName, columnName, datatype, constraint string) (string, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType:
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": tableName,
|
||||
"norm_table_name": normTableName,
|
||||
"column_name": columnName,
|
||||
"data_type": datatype,
|
||||
"constraint": constraint,
|
||||
}
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND column_name = '[[column_name]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE [[norm_table_name]] ADD COLUMN [[column_name]] [[data_type]] [[constraint]];'
|
||||
));
|
||||
PREPARE addColumnIfNeeded FROM @stmt;
|
||||
EXECUTE addColumnIfNeeded;
|
||||
DEALLOCATE PREPARE addColumnIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return fmt.Sprintf("\nALTER TABLE %s ADD COLUMN IF NOT EXISTS %s %s %s;\n", normTableName, columnName, datatype, constraint), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) genDropColumnIfNeeded(tableName, columnName string) (string, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType:
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": tableName,
|
||||
"norm_table_name": normTableName,
|
||||
"column_name": columnName,
|
||||
}
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND column_name = '[[column_name]]'
|
||||
) > 0,
|
||||
'ALTER TABLE [[norm_table_name]] DROP COLUMN [[column_name]];',
|
||||
'SELECT 1;'
|
||||
));
|
||||
PREPARE dropColumnIfNeeded FROM @stmt;
|
||||
EXECUTE dropColumnIfNeeded;
|
||||
DEALLOCATE PREPARE dropColumnIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return fmt.Sprintf("\nALTER TABLE %s DROP COLUMN IF EXISTS %s;\n", normTableName, columnName), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) genCreateIndexIfNeeded(tableName, columns string) (string, error) {
|
||||
indexName := getIndexName(tableName, columns)
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType:
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": tableName,
|
||||
"norm_table_name": normTableName,
|
||||
"index_name": indexName,
|
||||
"columns": columns,
|
||||
}
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(index_name) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND index_name = '[[index_name]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX [[index_name]] ON [[norm_table_name]] ([[columns]]);'
|
||||
));
|
||||
PREPARE createIndexIfNeeded FROM @stmt;
|
||||
EXECUTE createIndexIfNeeded;
|
||||
DEALLOCATE PREPARE createIndexIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return fmt.Sprintf("\nCREATE INDEX IF NOT EXISTS %s ON %s (%s);\n", indexName, normTableName, columns), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) genRenameTableIfNeeded(oldTableName, newTableName string) (string, error) {
|
||||
oldTableName = addPrefixIfNeeded(oldTableName, s.tablePrefix)
|
||||
newTableName = addPrefixIfNeeded(newTableName, s.tablePrefix)
|
||||
|
||||
normOldTableName := normalizeTablename(s.schemaName, oldTableName)
|
||||
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": newTableName,
|
||||
"norm_old_table_name": normOldTableName,
|
||||
"new_table_name": newTableName,
|
||||
}
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType:
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'RENAME TABLE [[norm_old_table_name]] TO [[new_table_name]];'
|
||||
));
|
||||
PREPARE renameTableIfNeeded FROM @stmt;
|
||||
EXECUTE renameTableIfNeeded;
|
||||
DEALLOCATE PREPARE renameTableIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return replaceVars(`
|
||||
do $$
|
||||
begin
|
||||
if (SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE table_name = '[[new_table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
) = 0 then
|
||||
ALTER TABLE [[norm_old_table_name]] RENAME TO [[new_table_name]];
|
||||
end if;
|
||||
end$$;
|
||||
`, vars), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) genRenameColumnIfNeeded(tableName, oldColumnName, newColumnName, dataType string) (string, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": tableName,
|
||||
"norm_table_name": normTableName,
|
||||
"old_column_name": oldColumnName,
|
||||
"new_column_name": newColumnName,
|
||||
"data_type": dataType,
|
||||
}
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType:
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND column_name = '[[new_column_name]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE [[norm_table_name]] CHANGE [[old_column_name]] [[new_column_name]] [[data_type]];'
|
||||
));
|
||||
PREPARE renameColumnIfNeeded FROM @stmt;
|
||||
EXECUTE renameColumnIfNeeded;
|
||||
DEALLOCATE PREPARE renameColumnIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return replaceVars(`
|
||||
do $$
|
||||
begin
|
||||
if (SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND column_name = '[[new_column_name]]'
|
||||
) = 0 then
|
||||
ALTER TABLE [[norm_table_name]] RENAME COLUMN [[old_column_name]] TO [[new_column_name]];
|
||||
end if;
|
||||
end$$;
|
||||
`, vars), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) doesTableExist(tableName string) (bool, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
|
||||
query := s.getQueryBuilder(s.db).
|
||||
Select("table_name").
|
||||
From("INFORMATION_SCHEMA.TABLES").
|
||||
Where(sq.Eq{
|
||||
"table_name": tableName,
|
||||
"table_schema": s.schemaName,
|
||||
})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`doesTableExist ERROR`, mlog.Err(err))
|
||||
return false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
exists := rows.Next()
|
||||
sql, _, _ := query.ToSql()
|
||||
|
||||
s.logger.Trace("doesTableExist",
|
||||
mlog.String("table", tableName),
|
||||
mlog.Bool("exists", exists),
|
||||
mlog.String("sql", sql),
|
||||
)
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) doesColumnExist(tableName, columnName string) (bool, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
|
||||
query := s.getQueryBuilder(s.db).
|
||||
Select("table_name").
|
||||
From("INFORMATION_SCHEMA.COLUMNS").
|
||||
Where(sq.Eq{
|
||||
"table_name": tableName,
|
||||
"table_schema": s.schemaName,
|
||||
"column_name": columnName,
|
||||
})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`doesColumnExist ERROR`, mlog.Err(err))
|
||||
return false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
exists := rows.Next()
|
||||
sql, _, _ := query.ToSql()
|
||||
|
||||
s.logger.Trace("doesColumnExist",
|
||||
mlog.String("table", tableName),
|
||||
mlog.String("column", columnName),
|
||||
mlog.Bool("exists", exists),
|
||||
mlog.String("sql", sql),
|
||||
)
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) genAddConstraintIfNeeded(tableName, constraintName, constraintType, constraintDefinition string) (string, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
var query string
|
||||
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"constraint_name": constraintName,
|
||||
"constraint_type": constraintType,
|
||||
"table_name": tableName,
|
||||
"constraint_definition": constraintDefinition,
|
||||
"norm_table_name": normTableName,
|
||||
}
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType:
|
||||
query = replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE constraint_schema = '[[schema]]'
|
||||
AND constraint_name = '[[constraint_name]]'
|
||||
AND constraint_type = '[[constraint_type]]'
|
||||
AND table_name = '[[table_name]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE [[norm_table_name]] ADD CONSTRAINT [[constraint_name]] [[constraint_definition]];'
|
||||
));
|
||||
PREPARE addConstraintIfNeeded FROM @stmt;
|
||||
EXECUTE addConstraintIfNeeded;
|
||||
DEALLOCATE PREPARE addConstraintIfNeeded;
|
||||
`, vars)
|
||||
case model.PostgresDBType:
|
||||
query = replaceVars(`
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE constraint_schema = '[[schema]]'
|
||||
AND constraint_name = '[[constraint_name]]'
|
||||
AND constraint_type = '[[constraint_type]]'
|
||||
AND table_name = '[[table_name]]'
|
||||
) THEN
|
||||
ALTER TABLE [[norm_table_name]] ADD CONSTRAINT [[constraint_name]] [[constraint_definition]];
|
||||
END IF;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
`, vars)
|
||||
}
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func addPrefixIfNeeded(s, prefix string) string {
|
||||
if !strings.HasPrefix(s, prefix) {
|
||||
return prefix + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeTablename(schemaName, tableName string) string {
|
||||
if schemaName != "" && !strings.HasPrefix(tableName, schemaName+".") {
|
||||
tableName = schemaName + "." + tableName
|
||||
}
|
||||
return tableName
|
||||
}
|
||||
|
||||
func getIndexName(tableName string, columns string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
_, _ = sb.WriteString("idx_")
|
||||
_, _ = sb.WriteString(tableName)
|
||||
|
||||
// allow developers to separate column names with spaces and/or commas
|
||||
columns = strings.ReplaceAll(columns, ",", " ")
|
||||
cols := strings.Split(columns, " ")
|
||||
|
||||
for _, s := range cols {
|
||||
sub := strings.TrimSpace(s)
|
||||
if sub == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = sb.WriteString("_")
|
||||
_, _ = sb.WriteString(s)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// replaceVars replaces instances of variable placeholders with the
|
||||
// values provided via a map. Variable placeholders are of the form
|
||||
// `[[var_name]]`.
|
||||
func replaceVars(s string, vars map[string]string) string {
|
||||
for key, val := range vars {
|
||||
placeholder := "[[" + key + "]]"
|
||||
val = strings.ReplaceAll(val, "'", "\\'")
|
||||
s = strings.ReplaceAll(s, placeholder, val)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}blocks (
|
||||
id VARCHAR(36),
|
||||
{{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}}
|
||||
{{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}}
|
||||
parent_id VARCHAR(36),
|
||||
{{if .mysql}}`schema`{{else}}schema{{end}} BIGINT,
|
||||
type TEXT,
|
||||
title TEXT,
|
||||
fields {{if .postgres}}JSON{{else}}TEXT{{end}},
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id, insert_at)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}system_settings (
|
||||
id VARCHAR(100),
|
||||
value TEXT,
|
||||
PRIMARY KEY (id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,2 @@
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "root_id" "varchar(36)" ""}}
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}users (
|
||||
id VARCHAR(100),
|
||||
username VARCHAR(100),
|
||||
email VARCHAR(255),
|
||||
password VARCHAR(100),
|
||||
mfa_secret VARCHAR(100),
|
||||
auth_service VARCHAR(20),
|
||||
auth_data VARCHAR(255),
|
||||
props {{if .postgres}}JSON{{else}}TEXT{{end}},
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}sessions (
|
||||
id VARCHAR(100),
|
||||
token VARCHAR(100),
|
||||
user_id VARCHAR(100),
|
||||
props {{if .postgres}}JSON{{else}}TEXT{{end}},
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
PRIMARY KEY (id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,2 @@
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "modified_by" "varchar(36)" ""}}
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}sharing (
|
||||
id VARCHAR(36),
|
||||
enabled BOOLEAN,
|
||||
token VARCHAR(100),
|
||||
modified_by VARCHAR(36),
|
||||
update_at BIGINT,
|
||||
PRIMARY KEY (id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}workspaces (
|
||||
id VARCHAR(36),
|
||||
signup_token VARCHAR(100) NOT NULL,
|
||||
settings {{if .postgres}}JSON{{else}}TEXT{{end}},
|
||||
modified_by VARCHAR(36),
|
||||
update_at BIGINT,
|
||||
PRIMARY KEY (id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "workspace_id" "varchar(36)" ""}}
|
||||
|
||||
{{ addColumnIfNeeded "sharing" "workspace_id" "varchar(36)" ""}}
|
||||
|
||||
{{ addColumnIfNeeded "sessions" "auth_service" "varchar(20)" ""}}
|
||||
|
||||
UPDATE {{.prefix}}blocks SET workspace_id = '0' WHERE workspace_id = '' OR workspace_id IS NULL;
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,40 @@
|
||||
{{- /* Only perform this migration if the blocks_history table does not already exist */ -}}
|
||||
|
||||
{{- /* doesTableExist tableName */ -}}
|
||||
{{if doesTableExist "blocks_history" }}
|
||||
|
||||
SELECT 1;
|
||||
|
||||
{{else}}
|
||||
|
||||
{{- /* renameTableIfNeeded oldTableName newTableName */ -}}
|
||||
{{ renameTableIfNeeded "blocks" "blocks_history" }}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}blocks (
|
||||
id VARCHAR(36),
|
||||
{{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}}
|
||||
{{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}}
|
||||
parent_id VARCHAR(36),
|
||||
{{if .mysql}}`schema`{{else}}schema{{end}} BIGINT,
|
||||
type TEXT,
|
||||
title TEXT,
|
||||
fields {{if .postgres}}JSON{{else}}TEXT{{end}},
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
root_id VARCHAR(36),
|
||||
modified_by VARCHAR(36),
|
||||
workspace_id VARCHAR(36),
|
||||
PRIMARY KEY (workspace_id,id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
{{if .mysql}}
|
||||
INSERT IGNORE INTO {{.prefix}}blocks (SELECT * FROM {{.prefix}}blocks_history ORDER BY insert_at DESC);
|
||||
{{end}}
|
||||
{{if .postgres}}
|
||||
INSERT INTO {{.prefix}}blocks (SELECT * FROM {{.prefix}}blocks_history ORDER BY insert_at DESC) ON CONFLICT DO NOTHING;
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
|
||||
DELETE FROM {{.prefix}}blocks where delete_at > 0;
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,7 @@
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint) */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "created_by" "varchar(36)" ""}}
|
||||
{{ addColumnIfNeeded "blocks_history" "created_by" "varchar(36)" ""}}
|
||||
|
||||
UPDATE {{.prefix}}blocks SET created_by =
|
||||
COALESCE(NULLIF((select modified_by from {{.prefix}}blocks_history where {{.prefix}}blocks_history.id = {{.prefix}}blocks.id ORDER BY {{.prefix}}blocks_history.insert_at ASC limit 1), ''), 'system')
|
||||
WHERE created_by IS NULL;
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,7 @@
|
||||
{{- /* All tables have collation fixed via code at startup so this migration is no longer needed. */ -}}
|
||||
{{- /* See https://github.com/mattermost/focalboard/pull/4002 */ -}}
|
||||
|
||||
SELECT 1;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,54 @@
|
||||
{{if and .mysql .plugin}}
|
||||
-- this migration applies collation on column level.
|
||||
-- collation of mattermost's Channels table
|
||||
SET @mattermostCollation = (SELECT table_collation from information_schema.tables WHERE table_name = 'Channels' AND table_schema = (SELECT DATABASE()));
|
||||
-- charset of mattermost's CHannels table's Name column
|
||||
SET @mattermostCharset = (SELECT CHARACTER_SET_NAME from information_schema.columns WHERE table_name = 'Channels' AND table_schema = (SELECT DATABASE()) AND COLUMN_NAME = 'Name');
|
||||
|
||||
-- blocks
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}blocks CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- blocks history
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}blocks_history CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- sessions
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}sessions CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- sharing
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}sharing CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- system settings
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}system_settings CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- users
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}users CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- workspaces
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}workspaces CONVERT TO CHARACTER SET ', @mattermostCharset, ' COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
{{else}}
|
||||
-- We need a query here otherwise the migration will result
|
||||
-- in an empty query when the if condition is false.
|
||||
-- Empty query causes a "Query was empty" error.
|
||||
SELECT 1;
|
||||
{{end}}
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
UPDATE {{.prefix}}users SET create_at = create_at*1000, update_at = update_at*1000, delete_at = delete_at*1000
|
||||
WHERE create_at < 1000000000000;
|
||||
|
||||
UPDATE {{.prefix}}blocks SET create_at = create_at*1000, update_at = update_at*1000, delete_at = delete_at*1000
|
||||
WHERE create_at < 1000000000000;
|
||||
|
||||
UPDATE {{.prefix}}blocks_history SET create_at = create_at*1000, update_at = update_at*1000, delete_at = delete_at*1000
|
||||
WHERE create_at < 1000000000000;
|
||||
|
||||
UPDATE {{.prefix}}workspaces SET update_at = update_at*1000
|
||||
WHERE update_at < 1000000000000;
|
||||
|
||||
UPDATE {{.prefix}}sharing SET update_at = update_at*1000
|
||||
WHERE update_at < 1000000000000;
|
||||
|
||||
UPDATE {{.prefix}}sessions SET create_at = create_at*1000, update_at = update_at*1000
|
||||
WHERE create_at < 1000000000000;
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,12 @@
|
||||
UPDATE {{.prefix}}blocks SET created_by = 'system' where created_by IS NULL;
|
||||
UPDATE {{.prefix}}blocks SET modified_by = 'system' where modified_by IS NULL;
|
||||
|
||||
{{if .mysql}}
|
||||
ALTER TABLE {{.prefix}}blocks MODIFY created_by varchar(36) NOT NULL;
|
||||
ALTER TABLE {{.prefix}}blocks MODIFY modified_by varchar(36) NOT NULL;
|
||||
{{end}}
|
||||
|
||||
{{if .postgres}}
|
||||
ALTER TABLE {{.prefix}}blocks ALTER COLUMN created_by set NOT NULL;
|
||||
ALTER TABLE {{.prefix}}blocks ALTER COLUMN modified_by set NOT NULL;
|
||||
{{end}}
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,105 @@
|
||||
{{if .mysql}}
|
||||
|
||||
UPDATE {{.prefix}}blocks_history AS bh SET bh.parent_id='' WHERE bh.parent_id IS NULL;
|
||||
UPDATE {{.prefix}}blocks_history AS bh SET bh.schema=1 WHERE bh.schema IS NULL;
|
||||
UPDATE {{.prefix}}blocks_history AS bh SET bh.type='' WHERE bh.type IS NULL;
|
||||
UPDATE {{.prefix}}blocks_history AS bh SET bh.title='' WHERE bh.title IS NULL;
|
||||
UPDATE {{.prefix}}blocks_history AS bh SET bh.fields='' WHERE bh.fields IS NULL;
|
||||
UPDATE {{.prefix}}blocks_history AS bh SET bh.create_at=0 WHERE bh.create_at IS NULL;
|
||||
UPDATE {{.prefix}}blocks_history AS bh SET bh.root_id='' WHERE bh.root_id IS NULL;
|
||||
UPDATE {{.prefix}}blocks_history AS bh SET bh.created_by='system' WHERE bh.created_by IS NULL;
|
||||
|
||||
{{else}}
|
||||
|
||||
/* parent_id */
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET parent_id = COALESCE(
|
||||
(SELECT bh2.parent_id
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.parent_id IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, '')
|
||||
WHERE parent_id IS NULL;
|
||||
|
||||
/* schema */
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET schema = COALESCE(
|
||||
(SELECT bh2.schema
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.schema IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, 1)
|
||||
WHERE schema IS NULL;
|
||||
|
||||
/* type */
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET type = COALESCE(
|
||||
(SELECT bh2.type
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.type IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, '')
|
||||
WHERE type IS NULL;
|
||||
|
||||
/* title */
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET title = COALESCE(
|
||||
(SELECT bh2.title
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.title IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, '')
|
||||
WHERE title IS NULL;
|
||||
|
||||
/* fields */
|
||||
{{if .postgres}}
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET fields = COALESCE(
|
||||
(SELECT bh2.fields
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.fields IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, '{}'::json)
|
||||
WHERE fields IS NULL;
|
||||
{{else}}
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET fields = COALESCE(
|
||||
(SELECT bh2.fields
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.fields IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, '')
|
||||
WHERE fields IS NULL;
|
||||
{{end}}
|
||||
|
||||
/* create_at */
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET create_at = COALESCE(
|
||||
(SELECT bh2.create_at
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.create_at IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, bh1.update_at)
|
||||
WHERE create_at IS NULL;
|
||||
|
||||
/* root_id */
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET root_id = COALESCE(
|
||||
(SELECT bh2.root_id
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.root_id IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, '')
|
||||
WHERE root_id IS NULL;
|
||||
|
||||
/* created_by */
|
||||
UPDATE {{.prefix}}blocks_history AS bh1
|
||||
SET created_by = COALESCE(
|
||||
(SELECT bh2.created_by
|
||||
FROM {{.prefix}}blocks_history AS bh2
|
||||
WHERE bh1.id = bh2.id AND bh2.created_by IS NOT NULL
|
||||
ORDER BY bh2.insert_at ASC limit 1)
|
||||
, 'system')
|
||||
WHERE created_by IS NULL;
|
||||
|
||||
{{end}}
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}subscriptions (
|
||||
block_type VARCHAR(10),
|
||||
block_id VARCHAR(36),
|
||||
workspace_id VARCHAR(36),
|
||||
subscriber_type VARCHAR(10),
|
||||
subscriber_id VARCHAR(36),
|
||||
notified_at BIGINT,
|
||||
create_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (block_id, subscriber_id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}notification_hints (
|
||||
block_type VARCHAR(10),
|
||||
block_id VARCHAR(36),
|
||||
workspace_id VARCHAR(36),
|
||||
modified_by_id VARCHAR(36),
|
||||
create_at BIGINT,
|
||||
notify_at BIGINT,
|
||||
PRIMARY KEY (block_id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user