Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -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 &notifier{
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),
)
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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