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

117
server/platform/shared/driver/conn.go Обычный файл
Просмотреть файл

@@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package driver
import (
"context"
"database/sql/driver"
"github.com/mattermost/mattermost-server/v6/plugin"
)
// Conn is a DB driver conn implementation
// which executes queries using the Plugin DB API.
type Conn struct {
id string
api plugin.Driver
}
// driverConn is a super-interface combining the basic
// driver.Conn interface with some new additions later.
type driverConn interface {
driver.Conn
driver.ConnBeginTx
driver.ConnPrepareContext
driver.ExecerContext
driver.QueryerContext
driver.Pinger
}
var (
// Compile-time check to ensure Conn implements the interface.
_ driverConn = &Conn{}
)
func (c *Conn) Begin() (tx driver.Tx, err error) {
txID, err := c.api.Tx(c.id, driver.TxOptions{})
if err != nil {
return nil, err
}
t := &wrapperTx{
id: txID,
api: c.api,
}
return t, nil
}
func (c *Conn) BeginTx(_ context.Context, opts driver.TxOptions) (driver.Tx, error) {
txID, err := c.api.Tx(c.id, opts)
if err != nil {
return nil, err
}
t := &wrapperTx{
id: txID,
api: c.api,
}
return t, nil
}
func (c *Conn) Prepare(q string) (driver.Stmt, error) {
stID, err := c.api.Stmt(c.id, q)
if err != nil {
return nil, err
}
st := &wrapperStmt{
id: stID,
api: c.api,
}
return st, nil
}
func (c *Conn) PrepareContext(_ context.Context, q string) (driver.Stmt, error) {
stID, err := c.api.Stmt(c.id, q)
if err != nil {
return nil, err
}
st := &wrapperStmt{
id: stID,
api: c.api,
}
return st, nil
}
func (c *Conn) ExecContext(_ context.Context, q string, args []driver.NamedValue) (driver.Result, error) {
resultContainer, err := c.api.ConnExec(c.id, q, args)
if err != nil {
return nil, err
}
res := &wrapperResult{
res: resultContainer,
}
return res, nil
}
func (c *Conn) QueryContext(_ context.Context, q string, args []driver.NamedValue) (driver.Rows, error) {
rowsID, err := c.api.ConnQuery(c.id, q, args)
if err != nil {
return nil, err
}
rows := &wrapperRows{
id: rowsID,
api: c.api,
}
return rows, nil
}
func (c *Conn) Ping(_ context.Context) error {
return c.api.ConnPing(c.id)
}
func (c *Conn) Close() error {
return c.api.ConnClose(c.id)
}

57
server/platform/shared/driver/driver.go Обычный файл
Просмотреть файл

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// package driver implements a DB driver that can be used by plugins
// to make SQL queries using RPC. This helps to avoid opening new connections
// for every plugin, and lets everyone use the central connection
// pool in the server.
// The tests for this package are at app/plugin_api_tests/test_db_driver/main.go.
package driver
import (
"context"
"database/sql/driver"
"github.com/mattermost/mattermost-server/v6/plugin"
)
var (
// Compile-time check to ensure Connector implements the interface.
_ driver.Connector = &Connector{}
)
// Connector is the DB connector which is used to
// communicate with the DB API.
type Connector struct {
api plugin.Driver
isMaster bool
}
// NewConnector returns a DB connector that can be used to return a sql.DB object.
// It takes a plugin.Driver implementation and a boolean flag to indicate whether
// to connect to a master or replica DB instance.
func NewConnector(api plugin.Driver, isMaster bool) *Connector {
return &Connector{api: api, isMaster: isMaster}
}
func (c *Connector) Connect(_ context.Context) (driver.Conn, error) {
connID, err := c.api.Conn(c.isMaster)
if err != nil {
return nil, err
}
return &Conn{id: connID, api: c.api}, nil
}
func (c *Connector) Driver() driver.Driver {
return &Driver{c: c}
}
// Driver is a DB driver implementation.
type Driver struct {
c *Connector
}
func (d Driver) Open(name string) (driver.Conn, error) {
return d.c.Connect(context.Background())
}

114
server/platform/shared/driver/objects.go Обычный файл
Просмотреть файл

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package driver
import (
"context"
"database/sql/driver"
"github.com/mattermost/mattermost-server/v6/plugin"
)
type wrapperTx struct {
driver.Tx
id string
api plugin.Driver
}
func (t *wrapperTx) Commit() error {
return t.api.TxCommit(t.id)
}
func (t *wrapperTx) Rollback() error {
return t.api.TxRollback(t.id)
}
type wrapperStmt struct {
driver.Stmt
id string
api plugin.Driver
}
func (s *wrapperStmt) Close() error {
return s.api.StmtClose(s.id)
}
func (s *wrapperStmt) NumInput() int {
return s.api.StmtNumInput(s.id)
}
func (s *wrapperStmt) ExecContext(_ context.Context, args []driver.NamedValue) (driver.Result, error) {
resultContainer, err := s.api.StmtExec(s.id, args)
if err != nil {
return nil, err
}
res := &wrapperResult{
res: resultContainer,
}
return res, nil
}
func (s *wrapperStmt) QueryContext(_ context.Context, args []driver.NamedValue) (driver.Rows, error) {
rowsID, err := s.api.StmtQuery(s.id, args)
if err != nil {
return nil, err
}
rows := &wrapperRows{
id: rowsID,
api: s.api,
}
return rows, nil
}
// wrapperResult implements the driver.Result interface.
// This differs from other objects because it already contains the
// information for its methods. This does two things:
//
// 1. Simplifies server-side code by avoiding to track result ids
// in a map.
// 2. Avoids round-trip to compute result methods.
type wrapperResult struct {
res plugin.ResultContainer
}
func (r *wrapperResult) LastInsertId() (int64, error) {
return r.res.LastID, r.res.LastIDError
}
func (r *wrapperResult) RowsAffected() (int64, error) {
return r.res.RowsAffected, r.res.RowsAffectedError
}
type wrapperRows struct {
id string
api plugin.Driver
}
func (r *wrapperRows) Columns() []string {
return r.api.RowsColumns(r.id)
}
func (r *wrapperRows) Close() error {
return r.api.RowsClose(r.id)
}
func (r *wrapperRows) Next(dest []driver.Value) error {
return r.api.RowsNext(r.id, dest)
}
func (r *wrapperRows) HasNextResultSet() bool {
return r.api.RowsHasNextResultSet(r.id)
}
func (r *wrapperRows) NextResultSet() error {
return r.api.RowsNextResultSet(r.id)
}
func (r *wrapperRows) ColumnTypeDatabaseTypeName(index int) string {
return r.api.RowsColumnTypeDatabaseTypeName(r.id, index)
}
func (r *wrapperRows) ColumnTypePrecisionScale(index int) (precision, scale int64, ok bool) {
return r.api.RowsColumnTypePrecisionScale(r.id, index)
}

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

@@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package filestore
import (
"context"
"io"
"time"
"github.com/pkg/errors"
)
const (
driverS3 = "amazons3"
driverLocal = "local"
)
type ReadCloseSeeker interface {
io.ReadCloser
io.Seeker
}
type FileBackend interface {
TestConnection() error
Reader(path string) (ReadCloseSeeker, error)
ReadFile(path string) ([]byte, error)
FileExists(path string) (bool, error)
FileSize(path string) (int64, error)
CopyFile(oldPath, newPath string) error
MoveFile(oldPath, newPath string) error
WriteFile(fr io.Reader, path string) (int64, error)
AppendFile(fr io.Reader, path string) (int64, error)
RemoveFile(path string) error
FileModTime(path string) (time.Time, error)
ListDirectory(path string) ([]string, error)
ListDirectoryRecursively(path string) ([]string, error)
RemoveDirectory(path string) error
}
type FileBackendSettings struct {
DriverName string
Directory string
AmazonS3AccessKeyId string
AmazonS3SecretAccessKey string
AmazonS3Bucket string
AmazonS3PathPrefix string
AmazonS3Region string
AmazonS3Endpoint string
AmazonS3SSL bool
AmazonS3SignV2 bool
AmazonS3SSE bool
AmazonS3Trace bool
SkipVerify bool
AmazonS3RequestTimeoutMilliseconds int64
}
func (settings *FileBackendSettings) CheckMandatoryS3Fields() error {
if settings.AmazonS3Bucket == "" {
return errors.New("missing s3 bucket settings")
}
// if S3 endpoint is not set call the set defaults to set that
if settings.AmazonS3Endpoint == "" {
settings.AmazonS3Endpoint = "s3.amazonaws.com"
}
return nil
}
func NewFileBackend(settings FileBackendSettings) (FileBackend, error) {
switch settings.DriverName {
case driverS3:
backend, err := NewS3FileBackend(settings)
if err != nil {
return nil, errors.Wrap(err, "unable to connect to the s3 backend")
}
return backend, nil
case driverLocal:
return &LocalFileBackend{
directory: settings.Directory,
}, nil
}
return nil, errors.New("no valid filestorage driver found")
}
// TryWriteFileContext checks if the file backend supports context writes and passes the context in that case.
// Should the file backend not support contexts, it just calls WriteFile instead. This can be used to disable
// the timeouts for long writes (like exports).
func TryWriteFileContext(fb FileBackend, ctx context.Context, fr io.Reader, path string) (int64, error) {
type ContextWriter interface {
WriteFileContext(context.Context, io.Reader, string) (int64, error)
}
if cw, ok := fb.(ContextWriter); ok {
return cw.WriteFileContext(ctx, fr, path)
}
return fb.WriteFile(fr, path)
}

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

@@ -0,0 +1,623 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package filestore
import (
"bytes"
"context"
"fmt"
"io"
"math/rand"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/xtgo/uuid"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func randomString() string {
return uuid.NewRandom().String()
}
type FileBackendTestSuite struct {
suite.Suite
settings FileBackendSettings
backend FileBackend
}
func TestLocalFileBackendTestSuite(t *testing.T) {
// Setup a global logger to catch tests logging outside of app context
// The global logger will be stomped by apps initializing but that's fine for testing. Ideally this won't happen.
logger := mlog.CreateConsoleTestLogger(true, mlog.LvlError)
defer logger.Shutdown()
mlog.InitGlobalLogger(logger)
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
suite.Run(t, &FileBackendTestSuite{
settings: FileBackendSettings{
DriverName: driverLocal,
Directory: dir,
},
})
}
func TestS3FileBackendTestSuite(t *testing.T) {
runBackendTest(t, false)
}
func TestS3FileBackendTestSuiteWithEncryption(t *testing.T) {
runBackendTest(t, true)
}
func runBackendTest(t *testing.T, encrypt bool) {
s3Host := os.Getenv("CI_MINIO_HOST")
if s3Host == "" {
s3Host = "localhost"
}
s3Port := os.Getenv("CI_MINIO_PORT")
if s3Port == "" {
s3Port = "9000"
}
s3Endpoint := fmt.Sprintf("%s:%s", s3Host, s3Port)
suite.Run(t, &FileBackendTestSuite{
settings: FileBackendSettings{
DriverName: driverS3,
AmazonS3AccessKeyId: "minioaccesskey",
AmazonS3SecretAccessKey: "miniosecretkey",
AmazonS3Bucket: "mattermost-test",
AmazonS3Region: "",
AmazonS3Endpoint: s3Endpoint,
AmazonS3PathPrefix: "",
AmazonS3SSL: false,
AmazonS3SSE: encrypt,
AmazonS3RequestTimeoutMilliseconds: 5000,
},
})
}
func (s *FileBackendTestSuite) SetupTest() {
backend, err := NewFileBackend(s.settings)
require.NoError(s.T(), err)
s.backend = backend
// This is needed to create the bucket if it doesn't exist.
err = s.backend.TestConnection()
if _, ok := err.(*S3FileBackendNoBucketError); ok {
s3Backend := s.backend.(*S3FileBackend)
s.NoError(s3Backend.MakeBucket())
} else {
s.NoError(err)
}
}
func (s *FileBackendTestSuite) TestConnection() {
s.Nil(s.backend.TestConnection())
}
func (s *FileBackendTestSuite) TestReadWriteFile() {
b := []byte("test")
path := "tests/" + randomString()
written, err := s.backend.WriteFile(bytes.NewReader(b), path)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
defer s.backend.RemoveFile(path)
read, err := s.backend.ReadFile(path)
s.Nil(err)
readString := string(read)
s.EqualValues(readString, "test")
}
func (s *FileBackendTestSuite) TestReadWriteFileContext() {
type ContextWriter interface {
WriteFileContext(context.Context, io.Reader, string) (int64, error)
}
data := "test"
s.T().Run("no deadline", func(t *testing.T) {
var (
written int64
err error
)
path := "tests/" + randomString()
ctx := context.Background()
if cw, ok := s.backend.(ContextWriter); ok {
written, err = cw.WriteFileContext(ctx, strings.NewReader(data), path)
} else {
written, err = s.backend.WriteFile(strings.NewReader(data), path)
}
s.NoError(err)
s.EqualValues(len(data), written, "expected given number of bytes to have been written")
defer s.backend.RemoveFile(path)
read, err := s.backend.ReadFile(path)
s.NoError(err)
readString := string(read)
s.Equal(readString, data)
})
s.T().Run("long deadline", func(t *testing.T) {
var (
written int64
err error
)
path := "tests/" + randomString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if cw, ok := s.backend.(ContextWriter); ok {
written, err = cw.WriteFileContext(ctx, strings.NewReader(data), path)
} else {
written, err = s.backend.WriteFile(strings.NewReader(data), path)
}
s.NoError(err)
s.EqualValues(len(data), written, "expected given number of bytes to have been written")
defer s.backend.RemoveFile(path)
read, err := s.backend.ReadFile(path)
s.NoError(err)
readString := string(read)
s.Equal(readString, data)
})
s.T().Run("missed deadline", func(t *testing.T) {
var (
written int64
err error
)
path := "tests/" + randomString()
r, w := io.Pipe()
go func() {
// close the writer after a short time
time.Sleep(500 * time.Millisecond)
w.Close()
}()
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if cw, ok := s.backend.(ContextWriter); ok {
written, err = cw.WriteFileContext(ctx, r, path)
} else {
// this test works only with a context writer
return
}
s.Error(err)
s.Zero(written)
})
}
func (s *FileBackendTestSuite) TestReadWriteFileImage() {
b := []byte("testimage")
path := "tests/" + randomString() + ".png"
written, err := s.backend.WriteFile(bytes.NewReader(b), path)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
defer s.backend.RemoveFile(path)
read, err := s.backend.ReadFile(path)
s.Nil(err)
readString := string(read)
s.EqualValues(readString, "testimage")
}
func (s *FileBackendTestSuite) TestFileExists() {
b := []byte("testimage")
path := "tests/" + randomString() + ".png"
_, err := s.backend.WriteFile(bytes.NewReader(b), path)
s.Nil(err)
defer s.backend.RemoveFile(path)
res, err := s.backend.FileExists(path)
s.Nil(err)
s.True(res)
res, err = s.backend.FileExists("tests/idontexist.png")
s.Nil(err)
s.False(res)
}
func (s *FileBackendTestSuite) TestCopyFile() {
b := []byte("test")
path1 := "tests/" + randomString()
path2 := "tests/" + randomString()
written, err := s.backend.WriteFile(bytes.NewReader(b), path1)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
defer s.backend.RemoveFile(path1)
err = s.backend.CopyFile(path1, path2)
s.Nil(err)
defer s.backend.RemoveFile(path2)
data1, err := s.backend.ReadFile(path1)
s.Nil(err)
data2, err := s.backend.ReadFile(path2)
s.Nil(err)
s.Equal(b, data1)
s.Equal(b, data2)
}
func (s *FileBackendTestSuite) TestCopyFileToDirectoryThatDoesntExist() {
b := []byte("test")
path1 := "tests/" + randomString()
path2 := "tests/newdirectory/" + randomString()
written, err := s.backend.WriteFile(bytes.NewReader(b), path1)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
defer s.backend.RemoveFile(path1)
err = s.backend.CopyFile(path1, path2)
s.Nil(err)
defer s.backend.RemoveFile(path2)
_, err = s.backend.ReadFile(path1)
s.Nil(err)
_, err = s.backend.ReadFile(path2)
s.Nil(err)
}
func (s *FileBackendTestSuite) TestMoveFile() {
b := []byte("test")
path1 := "tests/" + randomString()
path2 := "tests/" + randomString()
written, err := s.backend.WriteFile(bytes.NewReader(b), path1)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
defer s.backend.RemoveFile(path1)
s.Nil(s.backend.MoveFile(path1, path2))
defer s.backend.RemoveFile(path2)
_, err = s.backend.ReadFile(path1)
s.Error(err)
data, err := s.backend.ReadFile(path2)
s.Nil(err)
s.Equal(b, data)
}
func (s *FileBackendTestSuite) TestRemoveFile() {
b := []byte("test")
path := "tests/" + randomString()
written, err := s.backend.WriteFile(bytes.NewReader(b), path)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
s.Nil(s.backend.RemoveFile(path))
_, err = s.backend.ReadFile(path)
s.Error(err)
written, err = s.backend.WriteFile(bytes.NewReader(b), "tests2/foo")
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
written, err = s.backend.WriteFile(bytes.NewReader(b), "tests2/bar")
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
written, err = s.backend.WriteFile(bytes.NewReader(b), "tests2/asdf")
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
s.Nil(s.backend.RemoveDirectory("tests2"))
}
func (s *FileBackendTestSuite) TestListDirectory() {
b := []byte("test")
path1 := "19700101/" + randomString()
path2 := "19800101/" + randomString()
paths, err := s.backend.ListDirectory("19700101")
s.Nil(err)
s.Len(paths, 0)
written, err := s.backend.WriteFile(bytes.NewReader(b), path1)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
written, err = s.backend.WriteFile(bytes.NewReader(b), path2)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
paths, err = s.backend.ListDirectory("19700101")
s.Nil(err)
s.Len(paths, 1)
s.Equal(path1, (paths)[0])
paths, err = s.backend.ListDirectory("19800101/")
s.Nil(err)
s.Len(paths, 1)
s.Equal(path2, (paths)[0])
if s.settings.DriverName == driverLocal {
paths, err = s.backend.ListDirectory("19800102")
s.Nil(err)
s.Len(paths, 0)
}
paths, err = s.backend.ListDirectory("")
s.Nil(err)
found1 := false
found2 := false
for _, path := range paths {
if path == "19700101" {
found1 = true
} else if path == "19800101" {
found2 = true
}
}
s.True(found1)
s.True(found2)
s.backend.RemoveFile(path1)
s.backend.RemoveFile(path2)
}
func (s *FileBackendTestSuite) TestListDirectoryRecursively() {
b := []byte("test")
path1 := "19700101/" + randomString()
path2 := "19800101/" + randomString()
longPath := "19800102/this/is/a/way/too/long/path/for/this/function/to/handle" + randomString()
paths, err := s.backend.ListDirectoryRecursively("19700101")
s.Nil(err)
s.Len(paths, 0)
written, err := s.backend.WriteFile(bytes.NewReader(b), path1)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
written, err = s.backend.WriteFile(bytes.NewReader(b), path2)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
written, err = s.backend.WriteFile(bytes.NewReader(b), longPath)
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
paths, err = s.backend.ListDirectoryRecursively("19700101")
s.Nil(err)
s.Len(paths, 1)
s.Equal(path1, (paths)[0])
paths, err = s.backend.ListDirectoryRecursively("19800101/")
s.Nil(err)
s.Len(paths, 1)
s.Equal(path2, (paths)[0])
if s.settings.DriverName == driverLocal {
paths, err = s.backend.ListDirectory("19800102")
s.Nil(err)
s.Len(paths, 1)
}
paths, err = s.backend.ListDirectoryRecursively("")
s.Nil(err)
found1 := false
found2 := false
found3 := false
for _, path := range paths {
if path == path1 {
found1 = true
} else if path == path2 {
found2 = true
} else if path == longPath {
found3 = true
}
}
s.True(found1)
s.True(found2)
if s.settings.DriverName == driverLocal {
s.False(found3)
}
s.backend.RemoveFile(path1)
s.backend.RemoveFile(path2)
s.backend.RemoveFile(longPath)
}
func (s *FileBackendTestSuite) TestRemoveDirectory() {
b := []byte("test")
written, err := s.backend.WriteFile(bytes.NewReader(b), "tests2/foo")
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
written, err = s.backend.WriteFile(bytes.NewReader(b), "tests2/bar")
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
written, err = s.backend.WriteFile(bytes.NewReader(b), "tests2/aaa")
s.Nil(err)
s.EqualValues(len(b), written, "expected given number of bytes to have been written")
s.Nil(s.backend.RemoveDirectory("tests2"))
_, err = s.backend.ReadFile("tests2/foo")
s.Error(err)
_, err = s.backend.ReadFile("tests2/bar")
s.Error(err)
_, err = s.backend.ReadFile("tests2/asdf")
s.Error(err)
}
func (s *FileBackendTestSuite) TestAppendFile() {
s.Run("should fail if target file is missing", func() {
path := "tests/" + randomString()
b := make([]byte, 1024)
written, err := s.backend.AppendFile(bytes.NewReader(b), path)
s.Error(err)
s.Zero(written)
})
s.Run("should correctly append the data", func() {
// First part needs to be at least 5MB for the S3 implementation to work.
size := 5 * 1024 * 1024
b := make([]byte, size)
for i := range b {
b[i] = 'A'
}
path := "tests/" + randomString()
written, err := s.backend.WriteFile(bytes.NewReader(b), path)
s.Nil(err)
s.EqualValues(len(b), written)
defer s.backend.RemoveFile(path)
b2 := make([]byte, 1024)
for i := range b2 {
b2[i] = 'B'
}
written, err = s.backend.AppendFile(bytes.NewReader(b2), path)
s.Nil(err)
s.EqualValues(int64(len(b2)), written)
read, err := s.backend.ReadFile(path)
s.Nil(err)
s.EqualValues(len(b)+len(b2), len(read))
s.True(bytes.Equal(append(b, b2...), read))
b3 := make([]byte, 1024)
for i := range b3 {
b3[i] = 'C'
}
written, err = s.backend.AppendFile(bytes.NewReader(b3), path)
s.Nil(err)
s.EqualValues(int64(len(b3)), written)
read, err = s.backend.ReadFile(path)
s.Nil(err)
s.EqualValues(len(b)+len(b2)+len(b3), len(read))
s.True(bytes.Equal(append(append(b, b2...), b3...), read))
})
}
func (s *FileBackendTestSuite) TestFileSize() {
s.Run("nonexistent file", func() {
size, err := s.backend.FileSize("tests/nonexistentfile")
s.NotNil(err)
s.Zero(size)
})
s.Run("valid file", func() {
data := make([]byte, rand.Intn(1024*1024)+1)
path := "tests/" + randomString()
written, err := s.backend.WriteFile(bytes.NewReader(data), path)
s.Nil(err)
s.EqualValues(len(data), written)
defer s.backend.RemoveFile(path)
size, err := s.backend.FileSize(path)
s.Nil(err)
s.Equal(int64(len(data)), size)
})
}
func (s *FileBackendTestSuite) TestFileModTime() {
s.Run("nonexistent file", func() {
modTime, err := s.backend.FileModTime("tests/nonexistentfile")
s.NotNil(err)
s.Empty(modTime)
})
s.Run("valid file", func() {
path := "tests/" + randomString()
data := []byte("some data")
written, err := s.backend.WriteFile(bytes.NewReader(data), path)
s.Nil(err)
s.EqualValues(len(data), written)
defer s.backend.RemoveFile(path)
modTime, err := s.backend.FileModTime(path)
s.Nil(err)
s.NotEmpty(modTime)
// We wait 1 second so that the times will differ enough to be testable.
time.Sleep(1 * time.Second)
path2 := "tests/" + randomString()
written, err = s.backend.WriteFile(bytes.NewReader(data), path2)
s.Nil(err)
s.EqualValues(len(data), written)
defer s.backend.RemoveFile(path2)
modTime2, err := s.backend.FileModTime(path2)
s.Nil(err)
s.NotEmpty(modTime2)
s.True(modTime2.After(modTime))
})
}
func BenchmarkS3WriteFile(b *testing.B) {
settings := FileBackendSettings{
DriverName: driverS3,
AmazonS3AccessKeyId: "minioaccesskey",
AmazonS3SecretAccessKey: "miniosecretkey",
AmazonS3Bucket: "mattermost-test",
AmazonS3Region: "",
AmazonS3Endpoint: "localhost:9000",
AmazonS3PathPrefix: "",
AmazonS3SSL: false,
AmazonS3SSE: false,
AmazonS3RequestTimeoutMilliseconds: 20000,
}
backend, err := NewFileBackend(settings)
require.NoError(b, err)
// This is needed to create the bucket if it doesn't exist.
require.NoError(b, backend.TestConnection())
path := "tests/" + randomString()
size := 1 * 1024 * 1024
data := make([]byte, size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
written, err := backend.WriteFile(bytes.NewReader(data), path)
defer backend.RemoveFile(path)
require.NoError(b, err)
require.Len(b, data, int(written))
}
b.StopTimer()
}

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

@@ -0,0 +1,239 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package filestore
import (
"bytes"
"io"
"os"
"path/filepath"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const (
TestFilePath = "/testfile"
)
type LocalFileBackend struct {
directory string
}
// copyFile will copy a file from src path to dst path.
// Overwrites any existing files at dst.
// Permissions are copied from file at src to the new file at dst.
func copyFile(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil {
return
}
defer in.Close()
if err = os.MkdirAll(filepath.Dir(dst), os.ModePerm); err != nil {
return
}
out, err := os.Create(dst)
if err != nil {
return
}
defer func() {
if e := out.Close(); e != nil {
err = e
}
}()
_, err = io.Copy(out, in)
if err != nil {
return
}
err = out.Sync()
if err != nil {
return
}
stat, err := os.Stat(src)
if err != nil {
return
}
err = os.Chmod(dst, stat.Mode())
if err != nil {
return
}
return
}
func (b *LocalFileBackend) TestConnection() error {
f := bytes.NewReader([]byte("testingwrite"))
if _, err := writeFileLocally(f, filepath.Join(b.directory, TestFilePath)); err != nil {
return errors.Wrap(err, "unable to write to the local filesystem storage")
}
os.Remove(filepath.Join(b.directory, TestFilePath))
mlog.Debug("Able to write files to local storage.")
return nil
}
func (b *LocalFileBackend) Reader(path string) (ReadCloseSeeker, error) {
f, err := os.Open(filepath.Join(b.directory, path))
if err != nil {
return nil, errors.Wrapf(err, "unable to open file %s", path)
}
return f, nil
}
func (b *LocalFileBackend) ReadFile(path string) ([]byte, error) {
f, err := os.ReadFile(filepath.Join(b.directory, path))
if err != nil {
return nil, errors.Wrapf(err, "unable to read file %s", path)
}
return f, nil
}
func (b *LocalFileBackend) FileExists(path string) (bool, error) {
_, err := os.Stat(filepath.Join(b.directory, path))
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, errors.Wrapf(err, "unable to know if file %s exists", path)
}
return true, nil
}
func (b *LocalFileBackend) FileSize(path string) (int64, error) {
info, err := os.Stat(filepath.Join(b.directory, path))
if err != nil {
return 0, errors.Wrapf(err, "unable to get file size for %s", path)
}
return info.Size(), nil
}
func (b *LocalFileBackend) FileModTime(path string) (time.Time, error) {
info, err := os.Stat(filepath.Join(b.directory, path))
if err != nil {
return time.Time{}, errors.Wrapf(err, "unable to get modification time for file %s", path)
}
return info.ModTime(), nil
}
func (b *LocalFileBackend) CopyFile(oldPath, newPath string) error {
if err := copyFile(filepath.Join(b.directory, oldPath), filepath.Join(b.directory, newPath)); err != nil {
return errors.Wrapf(err, "unable to copy file from %s to %s", oldPath, newPath)
}
return nil
}
func (b *LocalFileBackend) MoveFile(oldPath, newPath string) error {
if err := os.MkdirAll(filepath.Dir(filepath.Join(b.directory, newPath)), 0750); err != nil {
return errors.Wrapf(err, "unable to create the new destination directory %s", filepath.Dir(newPath))
}
if err := os.Rename(filepath.Join(b.directory, oldPath), filepath.Join(b.directory, newPath)); err != nil {
return errors.Wrapf(err, "unable to move the file to %s to the destination directory", newPath)
}
return nil
}
func (b *LocalFileBackend) WriteFile(fr io.Reader, path string) (int64, error) {
return writeFileLocally(fr, filepath.Join(b.directory, path))
}
func writeFileLocally(fr io.Reader, path string) (int64, error) {
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
directory, _ := filepath.Abs(filepath.Dir(path))
return 0, errors.Wrapf(err, "unable to create the directory %s for the file %s", directory, path)
}
fw, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return 0, errors.Wrapf(err, "unable to open the file %s to write the data", path)
}
defer fw.Close()
written, err := io.Copy(fw, fr)
if err != nil {
return written, errors.Wrapf(err, "unable write the data in the file %s", path)
}
return written, nil
}
func (b *LocalFileBackend) AppendFile(fr io.Reader, path string) (int64, error) {
fp := filepath.Join(b.directory, path)
if _, err := os.Stat(fp); err != nil {
return 0, errors.Wrapf(err, "unable to find the file %s to append the data", path)
}
fw, err := os.OpenFile(fp, os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return 0, errors.Wrapf(err, "unable to open the file %s to append the data", path)
}
defer fw.Close()
written, err := io.Copy(fw, fr)
if err != nil {
return written, errors.Wrapf(err, "unable append the data in the file %s", path)
}
return written, nil
}
func (b *LocalFileBackend) RemoveFile(path string) error {
if err := os.Remove(filepath.Join(b.directory, path)); err != nil {
return errors.Wrapf(err, "unable to remove the file %s", path)
}
return nil
}
// basePath: path to get to the file but won't be added to the end result
// path: basePath+path current directory we are looking at
// maxDepth: parameter to prevent infinite recursion, once this is reached we won't look any further
func appendRecursively(basePath, path string, maxDepth int) ([]string, error) {
results := []string{}
dirEntries, err := os.ReadDir(filepath.Join(basePath, path))
if err != nil {
if os.IsNotExist(err) {
return results, nil
}
return results, errors.Wrapf(err, "unable to list the directory %s", path)
}
for _, dirEntry := range dirEntries {
entryName := dirEntry.Name()
entryPath := filepath.Join(path, entryName)
if entryName == "." || entryName == ".." || entryPath == path {
continue
}
if dirEntry.IsDir() {
if maxDepth <= 0 {
mlog.Warn("Max Depth reached", mlog.String("path", entryPath))
results = append(results, entryPath)
continue // we'll ignore it if max depth is reached.
}
nestedResults, err := appendRecursively(basePath, entryPath, maxDepth-1)
if err != nil {
return results, err
}
results = append(results, nestedResults...)
} else {
results = append(results, entryPath)
}
}
return results, nil
}
func (b *LocalFileBackend) ListDirectory(path string) ([]string, error) {
return appendRecursively(b.directory, path, 0)
}
func (b *LocalFileBackend) ListDirectoryRecursively(path string) ([]string, error) {
return appendRecursively(b.directory, path, 10)
}
func (b *LocalFileBackend) RemoveDirectory(path string) error {
if err := os.RemoveAll(filepath.Join(b.directory, path)); err != nil {
return errors.Wrapf(err, "unable to remove the directory %s", path)
}
return nil
}

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

@@ -0,0 +1,287 @@
// Code generated by mockery v2.10.4. DO NOT EDIT.
// Regenerate this file using `make filestore-mocks`.
package mocks
import (
io "io"
filestore "github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
mock "github.com/stretchr/testify/mock"
time "time"
)
// FileBackend is an autogenerated mock type for the FileBackend type
type FileBackend struct {
mock.Mock
}
// AppendFile provides a mock function with given fields: fr, path
func (_m *FileBackend) AppendFile(fr io.Reader, path string) (int64, error) {
ret := _m.Called(fr, path)
var r0 int64
if rf, ok := ret.Get(0).(func(io.Reader, string) int64); ok {
r0 = rf(fr, path)
} else {
r0 = ret.Get(0).(int64)
}
var r1 error
if rf, ok := ret.Get(1).(func(io.Reader, string) error); ok {
r1 = rf(fr, path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CopyFile provides a mock function with given fields: oldPath, newPath
func (_m *FileBackend) CopyFile(oldPath string, newPath string) error {
ret := _m.Called(oldPath, newPath)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(oldPath, newPath)
} else {
r0 = ret.Error(0)
}
return r0
}
// FileExists provides a mock function with given fields: path
func (_m *FileBackend) FileExists(path string) (bool, error) {
ret := _m.Called(path)
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(path)
} else {
r0 = ret.Get(0).(bool)
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FileModTime provides a mock function with given fields: path
func (_m *FileBackend) FileModTime(path string) (time.Time, error) {
ret := _m.Called(path)
var r0 time.Time
if rf, ok := ret.Get(0).(func(string) time.Time); ok {
r0 = rf(path)
} else {
r0 = ret.Get(0).(time.Time)
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FileSize provides a mock function with given fields: path
func (_m *FileBackend) FileSize(path string) (int64, error) {
ret := _m.Called(path)
var r0 int64
if rf, ok := ret.Get(0).(func(string) int64); ok {
r0 = rf(path)
} else {
r0 = ret.Get(0).(int64)
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ListDirectory provides a mock function with given fields: path
func (_m *FileBackend) ListDirectory(path string) ([]string, error) {
ret := _m.Called(path)
var r0 []string
if rf, ok := ret.Get(0).(func(string) []string); ok {
r0 = rf(path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ListDirectoryRecursively provides a mock function with given fields: path
func (_m *FileBackend) ListDirectoryRecursively(path string) ([]string, error) {
ret := _m.Called(path)
var r0 []string
if rf, ok := ret.Get(0).(func(string) []string); ok {
r0 = rf(path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MoveFile provides a mock function with given fields: oldPath, newPath
func (_m *FileBackend) MoveFile(oldPath string, newPath string) error {
ret := _m.Called(oldPath, newPath)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(oldPath, newPath)
} else {
r0 = ret.Error(0)
}
return r0
}
// ReadFile provides a mock function with given fields: path
func (_m *FileBackend) ReadFile(path string) ([]byte, error) {
ret := _m.Called(path)
var r0 []byte
if rf, ok := ret.Get(0).(func(string) []byte); ok {
r0 = rf(path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Reader provides a mock function with given fields: path
func (_m *FileBackend) Reader(path string) (filestore.ReadCloseSeeker, error) {
ret := _m.Called(path)
var r0 filestore.ReadCloseSeeker
if rf, ok := ret.Get(0).(func(string) filestore.ReadCloseSeeker); ok {
r0 = rf(path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(filestore.ReadCloseSeeker)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RemoveDirectory provides a mock function with given fields: path
func (_m *FileBackend) RemoveDirectory(path string) error {
ret := _m.Called(path)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(path)
} else {
r0 = ret.Error(0)
}
return r0
}
// RemoveFile provides a mock function with given fields: path
func (_m *FileBackend) RemoveFile(path string) error {
ret := _m.Called(path)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(path)
} else {
r0 = ret.Error(0)
}
return r0
}
// TestConnection provides a mock function with given fields:
func (_m *FileBackend) TestConnection() error {
ret := _m.Called()
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// WriteFile provides a mock function with given fields: fr, path
func (_m *FileBackend) WriteFile(fr io.Reader, path string) (int64, error) {
ret := _m.Called(fr, path)
var r0 int64
if rf, ok := ret.Get(0).(func(io.Reader, string) int64); ok {
r0 = rf(fr, path)
} else {
r0 = ret.Get(0).(int64)
}
var r1 error
if rf, ok := ret.Get(1).(func(io.Reader, string) error); ok {
r1 = rf(fr, path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}

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

@@ -0,0 +1,68 @@
// Code generated by mockery v2.10.4. DO NOT EDIT.
// Regenerate this file using `make filestore-mocks`.
package mocks
import mock "github.com/stretchr/testify/mock"
// ReadCloseSeeker is an autogenerated mock type for the ReadCloseSeeker type
type ReadCloseSeeker struct {
mock.Mock
}
// Close provides a mock function with given fields:
func (_m *ReadCloseSeeker) Close() error {
ret := _m.Called()
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// Read provides a mock function with given fields: p
func (_m *ReadCloseSeeker) Read(p []byte) (int, error) {
ret := _m.Called(p)
var r0 int
if rf, ok := ret.Get(0).(func([]byte) int); ok {
r0 = rf(p)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func([]byte) error); ok {
r1 = rf(p)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Seek provides a mock function with given fields: offset, whence
func (_m *ReadCloseSeeker) Seek(offset int64, whence int) (int64, error) {
ret := _m.Called(offset, whence)
var r0 int64
if rf, ok := ret.Get(0).(func(int64, int) int64); ok {
r0 = rf(offset, whence)
} else {
r0 = ret.Get(0).(int64)
}
var r1 error
if rf, ok := ret.Get(1).(func(int64, int) error); ok {
r1 = rf(offset, whence)
} else {
r1 = ret.Error(1)
}
return r0, r1
}

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

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package filestore
import (
"context"
"net/http"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// customTransport is used to point the request to a different server.
// This is helpful in situations where a different service is handling AWS S3 requests
// from multiple Mattermost applications, and the Mattermost service itself does not
// have any S3 credentials.
type customTransport struct {
host string
scheme string
client http.Client
}
// RoundTrip implements the http.Roundtripper interface.
func (t *customTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Roundtrippers should not modify the original request.
newReq := req.Clone(context.Background())
*newReq.URL = *req.URL
req.URL.Scheme = t.scheme
req.URL.Host = t.host
return t.client.Do(req)
}
// customProvider is a dummy credentials provider for the minio client to work
// without actually providing credentials. This is needed with a custom transport
// in cases where the minio client does not actually have credentials with itself,
// rather needs responses from another entity.
//
// It satisfies the credentials.Provider interface.
type customProvider struct {
isSignV2 bool
}
// Retrieve just returns empty credentials.
func (cp customProvider) Retrieve() (credentials.Value, error) {
sign := credentials.SignatureV4
if cp.isSignV2 {
sign = credentials.SignatureV2
}
return credentials.Value{
SignerType: sign,
}, nil
}
// IsExpired always returns false.
func (cp customProvider) IsExpired() bool { return false }

584
server/platform/shared/filestore/s3store.go Обычный файл
Просмотреть файл

@@ -0,0 +1,584 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package filestore
import (
"bytes"
"context"
"crypto/tls"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
s3 "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/minio/minio-go/v7/pkg/encrypt"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
// S3FileBackend contains all necessary information to communicate with
// an AWS S3 compatible API backend.
type S3FileBackend struct {
endpoint string
accessKey string
secretKey string
secure bool
signV2 bool
region string
bucket string
pathPrefix string
encrypt bool
trace bool
client *s3.Client
skipVerify bool
timeout time.Duration
}
type S3FileBackendAuthError struct {
DetailedError string
}
// S3FileBackendNoBucketError is returned when testing a connection and no S3 bucket is found
type S3FileBackendNoBucketError struct{}
const (
// This is not exported by minio. See: https://github.com/minio/minio-go/issues/1339
bucketNotFound = "NoSuchBucket"
)
var (
imageExtensions = map[string]bool{".jpg": true, ".jpeg": true, ".gif": true, ".bmp": true, ".png": true, ".tiff": true, "tif": true}
imageMimeTypes = map[string]string{".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".bmp": "image/bmp", ".png": "image/png", ".tiff": "image/tiff", ".tif": "image/tif"}
)
var (
// Ensure that the ReaderAt interface is implemented.
_ io.ReaderAt = (*s3WithCancel)(nil)
)
func isFileExtImage(ext string) bool {
ext = strings.ToLower(ext)
return imageExtensions[ext]
}
func getImageMimeType(ext string) string {
ext = strings.ToLower(ext)
if imageMimeTypes[ext] == "" {
return "image"
}
return imageMimeTypes[ext]
}
func (s *S3FileBackendAuthError) Error() string {
return s.DetailedError
}
func (s *S3FileBackendNoBucketError) Error() string {
return "no such bucket"
}
// NewS3FileBackend returns an instance of an S3FileBackend.
func NewS3FileBackend(settings FileBackendSettings) (*S3FileBackend, error) {
timeout := time.Duration(settings.AmazonS3RequestTimeoutMilliseconds) * time.Millisecond
backend := &S3FileBackend{
endpoint: settings.AmazonS3Endpoint,
accessKey: settings.AmazonS3AccessKeyId,
secretKey: settings.AmazonS3SecretAccessKey,
secure: settings.AmazonS3SSL,
signV2: settings.AmazonS3SignV2,
region: settings.AmazonS3Region,
bucket: settings.AmazonS3Bucket,
pathPrefix: settings.AmazonS3PathPrefix,
encrypt: settings.AmazonS3SSE,
trace: settings.AmazonS3Trace,
skipVerify: settings.SkipVerify,
timeout: timeout,
}
cli, err := backend.s3New()
if err != nil {
return nil, err
}
backend.client = cli
return backend, nil
}
// Similar to s3.New() but allows initialization of signature v2 or signature v4 client.
// If signV2 input is false, function always returns signature v4.
//
// Additionally this function also takes a user defined region, if set
// disables automatic region lookup.
func (b *S3FileBackend) s3New() (*s3.Client, error) {
var creds *credentials.Credentials
isCloud := os.Getenv("MM_CLOUD_FILESTORE_BIFROST") != ""
if isCloud {
creds = credentials.New(customProvider{isSignV2: b.signV2})
} else if b.accessKey == "" && b.secretKey == "" {
creds = credentials.NewIAM("")
} else if b.signV2 {
creds = credentials.NewStatic(b.accessKey, b.secretKey, "", credentials.SignatureV2)
} else {
creds = credentials.NewStatic(b.accessKey, b.secretKey, "", credentials.SignatureV4)
}
opts := s3.Options{
Creds: creds,
Secure: b.secure,
Region: b.region,
}
tr, err := s3.DefaultTransport(b.secure)
if err != nil {
return nil, err
}
if b.skipVerify {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
opts.Transport = tr
// If this is a cloud installation, we override the default transport.
if isCloud {
scheme := "http"
if b.secure {
scheme = "https"
}
newTransport := http.DefaultTransport.(*http.Transport).Clone()
newTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: b.skipVerify}
opts.Transport = &customTransport{
host: b.endpoint,
scheme: scheme,
client: http.Client{Transport: newTransport},
}
}
s3Clnt, err := s3.New(b.endpoint, &opts)
if err != nil {
return nil, err
}
if b.trace {
s3Clnt.TraceOn(os.Stdout)
}
return s3Clnt, nil
}
func (b *S3FileBackend) TestConnection() error {
exists := true
var err error
// If a path prefix is present, we attempt to test the bucket by listing objects under the path
// and just checking the first response. This is because the BucketExists call is only at a bucket level
// and sometimes the user might only be allowed access to the specified path prefix.
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
if b.pathPrefix != "" {
obj := <-b.client.ListObjects(ctx, b.bucket, s3.ListObjectsOptions{Prefix: b.pathPrefix})
if obj.Err != nil {
typedErr := s3.ToErrorResponse(obj.Err)
if typedErr.Code != bucketNotFound {
return &S3FileBackendAuthError{DetailedError: "unable to list objects in the S3 bucket"}
}
exists = false
}
} else {
exists, err = b.client.BucketExists(ctx, b.bucket)
if err != nil {
return &S3FileBackendAuthError{DetailedError: "unable to check if the S3 bucket exists"}
}
}
if !exists {
return &S3FileBackendNoBucketError{}
}
mlog.Debug("Connection to S3 or minio is good. Bucket exists.")
return nil
}
func (b *S3FileBackend) MakeBucket() error {
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
err := b.client.MakeBucket(ctx, b.bucket, s3.MakeBucketOptions{Region: b.region})
if err != nil {
return errors.Wrap(err, "unable to create the s3 bucket")
}
return nil
}
// s3WithCancel is a wrapper struct which cancels the context
// when the object is closed.
type s3WithCancel struct {
*s3.Object
timer *time.Timer
cancel context.CancelFunc
}
func (sc *s3WithCancel) Close() error {
sc.timer.Stop()
sc.cancel()
return sc.Object.Close()
}
// CancelTimeout attempts to cancel the timeout for this reader. It allows calling
// code to ignore the timeout in case of longer running operations. The methods returns
// false if the timeout has already fired.
func (sc *s3WithCancel) CancelTimeout() bool {
return sc.timer.Stop()
}
// Caller must close the first return value
func (b *S3FileBackend) Reader(path string) (ReadCloseSeeker, error) {
path = filepath.Join(b.pathPrefix, path)
ctx, cancel := context.WithCancel(context.Background())
minioObject, err := b.client.GetObject(ctx, b.bucket, path, s3.GetObjectOptions{})
if err != nil {
cancel()
return nil, errors.Wrapf(err, "unable to open file %s", path)
}
sc := &s3WithCancel{
Object: minioObject,
timer: time.AfterFunc(b.timeout, cancel),
cancel: cancel,
}
return sc, nil
}
func (b *S3FileBackend) ReadFile(path string) ([]byte, error) {
path = filepath.Join(b.pathPrefix, path)
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
minioObject, err := b.client.GetObject(ctx, b.bucket, path, s3.GetObjectOptions{})
if err != nil {
return nil, errors.Wrapf(err, "unable to open file %s", path)
}
defer minioObject.Close()
f, err := io.ReadAll(minioObject)
if err != nil {
return nil, errors.Wrapf(err, "unable to read file %s", path)
}
return f, nil
}
func (b *S3FileBackend) FileExists(path string) (bool, error) {
path = filepath.Join(b.pathPrefix, path)
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
_, err := b.client.StatObject(ctx, b.bucket, path, s3.StatObjectOptions{})
if err == nil {
return true, nil
}
var s3Err s3.ErrorResponse
if errors.As(err, &s3Err); s3Err.Code == "NoSuchKey" {
return false, nil
}
return false, errors.Wrapf(err, "unable to know if file %s exists", path)
}
func (b *S3FileBackend) FileSize(path string) (int64, error) {
path = filepath.Join(b.pathPrefix, path)
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
info, err := b.client.StatObject(ctx, b.bucket, path, s3.StatObjectOptions{})
if err != nil {
return 0, errors.Wrapf(err, "unable to get file size for %s", path)
}
return info.Size, nil
}
func (b *S3FileBackend) FileModTime(path string) (time.Time, error) {
path = filepath.Join(b.pathPrefix, path)
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
info, err := b.client.StatObject(ctx, b.bucket, path, s3.StatObjectOptions{})
if err != nil {
return time.Time{}, errors.Wrapf(err, "unable to get modification time for file %s", path)
}
return info.LastModified, nil
}
func (b *S3FileBackend) CopyFile(oldPath, newPath string) error {
oldPath = filepath.Join(b.pathPrefix, oldPath)
newPath = filepath.Join(b.pathPrefix, newPath)
srcOpts := s3.CopySrcOptions{
Bucket: b.bucket,
Object: oldPath,
}
if b.encrypt {
srcOpts.Encryption = encrypt.NewSSE()
}
dstOpts := s3.CopyDestOptions{
Bucket: b.bucket,
Object: newPath,
}
if b.encrypt {
dstOpts.Encryption = encrypt.NewSSE()
}
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
if _, err := b.client.CopyObject(ctx, dstOpts, srcOpts); err != nil {
return errors.Wrapf(err, "unable to copy file from %s to %s", oldPath, newPath)
}
return nil
}
func (b *S3FileBackend) MoveFile(oldPath, newPath string) error {
oldPath = filepath.Join(b.pathPrefix, oldPath)
newPath = filepath.Join(b.pathPrefix, newPath)
srcOpts := s3.CopySrcOptions{
Bucket: b.bucket,
Object: oldPath,
}
if b.encrypt {
srcOpts.Encryption = encrypt.NewSSE()
}
dstOpts := s3.CopyDestOptions{
Bucket: b.bucket,
Object: newPath,
}
if b.encrypt {
dstOpts.Encryption = encrypt.NewSSE()
}
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
if _, err := b.client.CopyObject(ctx, dstOpts, srcOpts); err != nil {
return errors.Wrapf(err, "unable to copy the file to %s to the new destination", newPath)
}
ctx2, cancel2 := context.WithTimeout(context.Background(), b.timeout)
defer cancel2()
if err := b.client.RemoveObject(ctx2, b.bucket, oldPath, s3.RemoveObjectOptions{}); err != nil {
return errors.Wrapf(err, "unable to remove the file old file %s", oldPath)
}
return nil
}
func (b *S3FileBackend) WriteFile(fr io.Reader, path string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
return b.WriteFileContext(ctx, fr, path)
}
func (b *S3FileBackend) WriteFileContext(ctx context.Context, fr io.Reader, path string) (int64, error) {
var contentType string
path = filepath.Join(b.pathPrefix, path)
if ext := filepath.Ext(path); isFileExtImage(ext) {
contentType = getImageMimeType(ext)
} else {
contentType = "binary/octet-stream"
}
options := s3PutOptions(b.encrypt, contentType)
objSize := int64(-1)
isCloud := os.Getenv("MM_CLOUD_FILESTORE_BIFROST") != ""
if isCloud {
options.DisableContentSha256 = true
} else {
// We pass an object size only in situations where bifrost is not
// used. Bifrost needs to run in HTTPS, which is not yet deployed.
switch t := fr.(type) {
case *bytes.Buffer:
objSize = int64(t.Len())
case *os.File:
if s, err := t.Stat(); err == nil {
objSize = s.Size()
}
}
}
info, err := b.client.PutObject(ctx, b.bucket, path, fr, objSize, options)
if err != nil {
return info.Size, errors.Wrapf(err, "unable write the data in the file %s", path)
}
return info.Size, nil
}
func (b *S3FileBackend) AppendFile(fr io.Reader, path string) (int64, error) {
fp := filepath.Join(b.pathPrefix, path)
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
if _, err := b.client.StatObject(ctx, b.bucket, fp, s3.StatObjectOptions{}); err != nil {
return 0, errors.Wrapf(err, "unable to find the file %s to append the data", path)
}
var contentType string
if ext := filepath.Ext(fp); isFileExtImage(ext) {
contentType = getImageMimeType(ext)
} else {
contentType = "binary/octet-stream"
}
options := s3PutOptions(b.encrypt, contentType)
sse := options.ServerSideEncryption
partName := fp + ".part"
ctx2, cancel2 := context.WithTimeout(context.Background(), b.timeout)
defer cancel2()
objSize := -1
isCloud := os.Getenv("MM_CLOUD_FILESTORE_BIFROST") != ""
if isCloud {
options.DisableContentSha256 = true
}
// We pass an object size only in situations where bifrost is not
// used. Bifrost needs to run in HTTPS, which is not yet deployed.
if buf, ok := fr.(*bytes.Buffer); ok && !isCloud {
objSize = buf.Len()
}
info, err := b.client.PutObject(ctx2, b.bucket, partName, fr, int64(objSize), options)
if err != nil {
return 0, errors.Wrapf(err, "unable append the data in the file %s", path)
}
defer func() {
ctx4, cancel4 := context.WithTimeout(context.Background(), b.timeout)
defer cancel4()
b.client.RemoveObject(ctx4, b.bucket, partName, s3.RemoveObjectOptions{})
}()
src1Opts := s3.CopySrcOptions{
Bucket: b.bucket,
Object: fp,
}
src2Opts := s3.CopySrcOptions{
Bucket: b.bucket,
Object: partName,
}
dstOpts := s3.CopyDestOptions{
Bucket: b.bucket,
Object: fp,
Encryption: sse,
}
ctx3, cancel3 := context.WithTimeout(context.Background(), b.timeout)
defer cancel3()
_, err = b.client.ComposeObject(ctx3, dstOpts, src1Opts, src2Opts)
if err != nil {
return 0, errors.Wrapf(err, "unable append the data in the file %s", path)
}
return info.Size, nil
}
func (b *S3FileBackend) RemoveFile(path string) error {
path = filepath.Join(b.pathPrefix, path)
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
if err := b.client.RemoveObject(ctx, b.bucket, path, s3.RemoveObjectOptions{}); err != nil {
return errors.Wrapf(err, "unable to remove the file %s", path)
}
return nil
}
func getPathsFromObjectInfos(in <-chan s3.ObjectInfo) <-chan s3.ObjectInfo {
out := make(chan s3.ObjectInfo, 1)
go func() {
defer close(out)
for {
info, done := <-in
if !done {
break
}
out <- info
}
}()
return out
}
func (b *S3FileBackend) listDirectory(path string, recursion bool) ([]string, error) {
path = filepath.Join(b.pathPrefix, path)
if !strings.HasSuffix(path, "/") && path != "" {
// s3Clnt returns only the path itself when "/" is not present
// appending "/" to make it consistent across all filestores
path = path + "/"
}
opts := s3.ListObjectsOptions{
Prefix: path,
Recursive: recursion,
}
var paths []string
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
for object := range b.client.ListObjects(ctx, b.bucket, opts) {
if object.Err != nil {
return nil, errors.Wrapf(object.Err, "unable to list the directory %s", path)
}
// We strip the path prefix that gets applied,
// so that it remains transparent to the application.
object.Key = strings.TrimPrefix(object.Key, b.pathPrefix)
trimmed := strings.Trim(object.Key, "/")
if trimmed != "" {
paths = append(paths, trimmed)
}
}
return paths, nil
}
func (b *S3FileBackend) ListDirectory(path string) ([]string, error) {
return b.listDirectory(path, false)
}
func (b *S3FileBackend) ListDirectoryRecursively(path string) ([]string, error) {
return b.listDirectory(path, true)
}
func (b *S3FileBackend) RemoveDirectory(path string) error {
opts := s3.ListObjectsOptions{
Prefix: filepath.Join(b.pathPrefix, path),
Recursive: true,
}
ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
defer cancel()
list := b.client.ListObjects(ctx, b.bucket, opts)
ctx2, cancel2 := context.WithTimeout(context.Background(), b.timeout)
defer cancel2()
objectsCh := b.client.RemoveObjects(ctx2, b.bucket, getPathsFromObjectInfos(list), s3.RemoveObjectsOptions{})
for err := range objectsCh {
if err.Err != nil {
return errors.Wrapf(err.Err, "unable to remove the directory %s", path)
}
}
return nil
}
func s3PutOptions(encrypted bool, contentType string) s3.PutObjectOptions {
options := s3.PutObjectOptions{}
if encrypted {
options.ServerSideEncryption = encrypt.NewSSE()
}
options.ContentType = contentType
// We set the part size to the minimum allowed value of 5MBs
// to avoid an excessive allocation in minio.PutObject implementation.
options.PartSize = 1024 * 1024 * 5
return options
}

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

@@ -0,0 +1,307 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package filestore
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"strings"
"testing"
"time"
s3 "github.com/minio/minio-go/v7"
"github.com/stretchr/testify/require"
)
// Copied from model/config.go to avoid an import cycle
const (
MinioAccessKey = "minioaccesskey"
MinioSecretKey = "miniosecretkey"
ImageDriverS3 = "amazons3"
)
func TestCheckMandatoryS3Fields(t *testing.T) {
cfg := FileBackendSettings{}
err := cfg.CheckMandatoryS3Fields()
require.Error(t, err)
require.Equal(t, err.Error(), "missing s3 bucket settings", "should've failed with missing s3 bucket")
cfg.AmazonS3Bucket = "test-mm"
err = cfg.CheckMandatoryS3Fields()
require.NoError(t, err)
cfg.AmazonS3Endpoint = ""
err = cfg.CheckMandatoryS3Fields()
require.NoError(t, err)
require.Equal(t, "s3.amazonaws.com", cfg.AmazonS3Endpoint, "should've set the endpoint to the default")
}
func TestMakeBucket(t *testing.T) {
s3Host := os.Getenv("CI_MINIO_HOST")
if s3Host == "" {
s3Host = "localhost"
}
s3Port := os.Getenv("CI_MINIO_PORT")
if s3Port == "" {
s3Port = "9000"
}
s3Endpoint := fmt.Sprintf("%s:%s", s3Host, s3Port)
// Generate a random bucket name
b := make([]byte, 30)
rand.Read(b)
bucketName := base64.StdEncoding.EncodeToString(b)
bucketName = strings.ToLower(bucketName)
bucketName = strings.Replace(bucketName, "+", "", -1)
bucketName = strings.Replace(bucketName, "/", "", -1)
cfg := FileBackendSettings{
DriverName: ImageDriverS3,
AmazonS3AccessKeyId: MinioAccessKey,
AmazonS3SecretAccessKey: MinioSecretKey,
AmazonS3Bucket: bucketName,
AmazonS3Endpoint: s3Endpoint,
AmazonS3Region: "",
AmazonS3PathPrefix: "",
AmazonS3SSL: false,
SkipVerify: false,
AmazonS3RequestTimeoutMilliseconds: 5000,
}
fileBackend, err := NewS3FileBackend(cfg)
require.NoError(t, err)
err = fileBackend.MakeBucket()
require.NoError(t, err)
}
func TestTimeout(t *testing.T) {
s3Host := os.Getenv("CI_MINIO_HOST")
if s3Host == "" {
s3Host = "localhost"
}
s3Port := os.Getenv("CI_MINIO_PORT")
if s3Port == "" {
s3Port = "9000"
}
s3Endpoint := fmt.Sprintf("%s:%s", s3Host, s3Port)
// Generate a random bucket name
b := make([]byte, 30)
rand.Read(b)
bucketName := base64.StdEncoding.EncodeToString(b)
bucketName = strings.ToLower(bucketName)
bucketName = strings.Replace(bucketName, "+", "", -1)
bucketName = strings.Replace(bucketName, "/", "", -1)
cfg := FileBackendSettings{
DriverName: ImageDriverS3,
AmazonS3AccessKeyId: MinioAccessKey,
AmazonS3SecretAccessKey: MinioSecretKey,
AmazonS3Bucket: bucketName,
AmazonS3Endpoint: s3Endpoint,
AmazonS3Region: "",
AmazonS3PathPrefix: "",
AmazonS3SSL: false,
SkipVerify: false,
AmazonS3RequestTimeoutMilliseconds: 0,
}
fileBackend, err := NewS3FileBackend(cfg)
require.NoError(t, err)
err = fileBackend.MakeBucket()
require.True(t, errors.Is(err, context.DeadlineExceeded))
path := "tests/" + randomString() + ".png"
_, err = fileBackend.WriteFile(bytes.NewReader([]byte("testimage")), path)
require.True(t, errors.Is(err, context.DeadlineExceeded))
}
func TestInsecureMakeBucket(t *testing.T) {
s3Host := os.Getenv("CI_MINIO_HOST")
if s3Host == "" {
s3Host = "localhost"
}
s3Port := os.Getenv("CI_MINIO_PORT")
if s3Port == "" {
s3Port = "9000"
}
s3Endpoint := fmt.Sprintf("%s:%s", s3Host, s3Port)
proxySelfSignedHTTPS := newTLSProxyServer(&url.URL{Scheme: "http", Host: s3Endpoint})
defer proxySelfSignedHTTPS.Close()
enableInsecure, secure := true, false
testCases := []struct {
description string
skipVerify bool
expectedAllowed bool
}{
{"allow self-signed HTTPS when insecure enabled", enableInsecure, true},
{"reject self-signed HTTPS when secured", secure, false},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
// Generate a random bucket name
b := make([]byte, 30)
rand.Read(b)
bucketName := base64.StdEncoding.EncodeToString(b)
bucketName = strings.ToLower(bucketName)
bucketName = strings.Replace(bucketName, "+", "", -1)
bucketName = strings.Replace(bucketName, "/", "", -1)
cfg := FileBackendSettings{
DriverName: ImageDriverS3,
AmazonS3AccessKeyId: MinioAccessKey,
AmazonS3SecretAccessKey: MinioSecretKey,
AmazonS3Bucket: bucketName,
AmazonS3Endpoint: proxySelfSignedHTTPS.URL[8:],
AmazonS3Region: "",
AmazonS3PathPrefix: "",
AmazonS3SSL: true,
SkipVerify: testCase.skipVerify,
AmazonS3RequestTimeoutMilliseconds: 5000,
}
fileBackend, err := NewS3FileBackend(cfg)
require.NoError(t, err)
err = fileBackend.MakeBucket()
if testCase.expectedAllowed {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
}
func newTLSProxyServer(backend *url.URL) *httptest.Server {
return httptest.NewTLSServer(httputil.NewSingleHostReverseProxy(backend))
}
func TestS3WithCancel(t *testing.T) {
// Some of these tests use time.Sleep to wait for the timeout to expire.
// They are run in parallel to reduce wait times.
t.Run("zero timeout", func(t *testing.T) {
t.Parallel()
r, ctx := newMockS3WithCancel(0, nil)
time.Sleep(10 * time.Millisecond) // give the context time to cancel
require.False(t, r.CancelTimeout())
require.Error(t, ctx.Err())
})
t.Run("timeout", func(t *testing.T) {
t.Parallel()
r, ctx := newMockS3WithCancel(50*time.Millisecond, nil)
time.Sleep(100 * time.Millisecond) // give the context time to cancel
require.False(t, r.CancelTimeout())
require.Error(t, ctx.Err())
})
t.Run("timeout cancel", func(t *testing.T) {
t.Parallel()
r, ctx := newMockS3WithCancel(50*time.Millisecond, nil)
time.Sleep(10 * time.Millisecond) // give the context time to cancel
require.True(t, r.CancelTimeout())
require.NoError(t, ctx.Err())
time.Sleep(100 * time.Millisecond) // wait for the original (canceled) timeout to expire
require.False(t, r.CancelTimeout())
require.NoError(t, ctx.Err())
require.NoError(t, r.Close())
})
t.Run("timeout closed", func(t *testing.T) {
t.Parallel()
r, ctx := newMockS3WithCancel(50*time.Millisecond, nil)
time.Sleep(10 * time.Millisecond) // give the context time to cancel
require.True(t, r.CancelTimeout())
require.NoError(t, ctx.Err())
require.NoError(t, r.Close())
time.Sleep(100 * time.Millisecond) // wait for the original (canceled) timeout to expire
require.False(t, r.CancelTimeout())
require.Error(t, ctx.Err())
require.NoError(t, r.Close())
})
t.Run("close cancel close", func(t *testing.T) {
t.Parallel()
r, ctx := newMockS3WithCancel(50*time.Millisecond, nil)
time.Sleep(10 * time.Millisecond) // give the context time to cancel
require.True(t, r.CancelTimeout())
require.NoError(t, r.Close())
require.Error(t, ctx.Err())
require.False(t, r.CancelTimeout())
require.Error(t, ctx.Err())
require.NoError(t, r.Close())
})
t.Run("close error", func(t *testing.T) {
t.Parallel()
r, ctx := newMockS3WithCancel(50*time.Millisecond, errors.New("test error"))
time.Sleep(10 * time.Millisecond) // give the context time to cancel
require.NoError(t, ctx.Err())
require.Error(t, r.Close())
require.False(t, r.CancelTimeout())
require.Error(t, ctx.Err())
})
}
func newMockS3WithCancel(timeout time.Duration, closeErr error) (*fauxCloser, context.Context) {
ctx, cancel := context.WithCancel(context.Background())
return &fauxCloser{
s3WithCancel: &s3WithCancel{
Object: &s3.Object{},
timer: time.AfterFunc(timeout, cancel),
cancel: cancel,
},
closeErr: closeErr,
}, ctx
}
type fauxCloser struct {
*s3WithCancel
closeErr error
}
func (fc fauxCloser) Close() error {
fc.s3WithCancel.timer.Stop()
fc.s3WithCancel.cancel()
return fc.closeErr
}

224
server/platform/shared/i18n/i18n.go Обычный файл
Просмотреть файл

@@ -0,0 +1,224 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package i18n
import (
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/mattermost/go-i18n/i18n"
"github.com/mattermost/go-i18n/i18n/bundle"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const defaultLocale = "en"
// TranslateFunc is the type of the translate functions
type TranslateFunc func(translationID string, args ...any) string
// TranslationFuncByLocal is the type of function that takes local as a string and returns the translation function
type TranslationFuncByLocal func(locale string) TranslateFunc
// T is the translate function using the default server language as fallback language
var T TranslateFunc
// TDefault is the translate function using english as fallback language
var TDefault TranslateFunc
var locales map[string]string = make(map[string]string)
var defaultServerLocale string
var defaultClientLocale string
// TranslationsPreInit loads translations from filesystem if they are not
// loaded already and assigns english while loading server config
func TranslationsPreInit(translationsDir string) error {
if T != nil {
return nil
}
// Set T even if we fail to load the translations. Lots of shutdown handling code will
// segfault trying to handle the error, and the untranslated IDs are strictly better.
T = tfuncWithFallback(defaultLocale)
TDefault = tfuncWithFallback(defaultLocale)
return initTranslationsWithDir(translationsDir)
}
// InitTranslations set the defaults configured in the server and initialize
// the T function using the server default as fallback language
func InitTranslations(serverLocale, clientLocale string) error {
defaultServerLocale = serverLocale
defaultClientLocale = clientLocale
var err error
T, err = getTranslationsBySystemLocale()
return err
}
func initTranslationsWithDir(dir string) error {
files, _ := os.ReadDir(dir)
for _, f := range files {
if filepath.Ext(f.Name()) == ".json" {
filename := f.Name()
locales[strings.Split(filename, ".")[0]] = filepath.Join(dir, filename)
if err := i18n.LoadTranslationFile(filepath.Join(dir, filename)); err != nil {
return err
}
}
}
return nil
}
// GetTranslationFuncForDir loads translations from the filesystem into a new instance of the bundle.
// It returns a function to access loaded translations.
func GetTranslationFuncForDir(dir string) (TranslationFuncByLocal, error) {
var availableLocals map[string]string = make(map[string]string)
bundle := bundle.New()
files, _ := os.ReadDir(dir)
for _, f := range files {
if filepath.Ext(f.Name()) != ".json" {
continue
}
filename := f.Name()
availableLocals[strings.Split(filename, ".")[0]] = filepath.Join(dir, filename)
if err := bundle.LoadTranslationFile(filepath.Join(dir, filename)); err != nil {
return nil, err
}
}
return func(locale string) TranslateFunc {
if _, ok := availableLocals[locale]; !ok {
locale = defaultLocale
}
t, _ := bundle.Tfunc(locale)
return func(translationID string, args ...any) string {
if translated := t(translationID, args...); translated != translationID {
return translated
}
t, _ := bundle.Tfunc(defaultLocale)
return t(translationID, args...)
}
}, nil
}
func getTranslationsBySystemLocale() (TranslateFunc, error) {
locale := defaultServerLocale
if _, ok := locales[locale]; !ok {
mlog.Warn("Failed to load system translations for", mlog.String("locale", locale), mlog.String("attempting to fall back to default locale", defaultLocale))
locale = defaultLocale
}
if locales[locale] == "" {
return nil, fmt.Errorf("failed to load system translations for '%v'", defaultLocale)
}
translations := tfuncWithFallback(locale)
if translations == nil {
return nil, fmt.Errorf("failed to load system translations")
}
mlog.Info("Loaded system translations", mlog.String("for locale", locale), mlog.String("from locale", locales[locale]))
return translations, nil
}
// GetUserTranslations get the translation function for an specific locale
func GetUserTranslations(locale string) TranslateFunc {
if _, ok := locales[locale]; !ok {
locale = defaultLocale
}
translations := tfuncWithFallback(locale)
return translations
}
// GetTranslationsAndLocaleFromRequest return the translation function and the
// locale based on a request headers
func GetTranslationsAndLocaleFromRequest(r *http.Request) (TranslateFunc, string) {
// This is for checking against locales like pt_BR or zn_CN
headerLocaleFull := strings.Split(r.Header.Get("Accept-Language"), ",")[0]
// This is for checking against locales like en, es
headerLocale := strings.Split(strings.Split(r.Header.Get("Accept-Language"), ",")[0], "-")[0]
defaultLocale := defaultClientLocale
if locales[headerLocaleFull] != "" {
translations := tfuncWithFallback(headerLocaleFull)
return translations, headerLocaleFull
} else if locales[headerLocale] != "" {
translations := tfuncWithFallback(headerLocale)
return translations, headerLocale
} else if locales[defaultLocale] != "" {
translations := tfuncWithFallback(defaultLocale)
return translations, headerLocale
}
translations := tfuncWithFallback(defaultLocale)
return translations, defaultLocale
}
// GetSupportedLocales return a map of locale code and the file path with the
// translations
func GetSupportedLocales() map[string]string {
return locales
}
func tfuncWithFallback(pref string) TranslateFunc {
t, _ := i18n.Tfunc(pref)
return func(translationID string, args ...any) string {
if translated := t(translationID, args...); translated != translationID {
return translated
}
t, _ := i18n.Tfunc(defaultLocale)
return t(translationID, args...)
}
}
// TranslateAsHTML translates the translationID provided and return a
// template.HTML object
func TranslateAsHTML(t TranslateFunc, translationID string, args map[string]any) template.HTML {
message := t(translationID, escapeForHTML(args))
message = strings.Replace(message, "[[", "<strong>", -1)
message = strings.Replace(message, "]]", "</strong>", -1)
return template.HTML(message)
}
func escapeForHTML(arg any) any {
switch typedArg := arg.(type) {
case string:
return template.HTMLEscapeString(typedArg)
case *string:
return template.HTMLEscapeString(*typedArg)
case map[string]any:
safeArg := make(map[string]any, len(typedArg))
for key, value := range typedArg {
safeArg[key] = escapeForHTML(value)
}
return safeArg
default:
mlog.Warn(
"Unable to escape value for HTML template",
mlog.Any("html_template", arg),
mlog.String("template_type", reflect.ValueOf(arg).Type().String()),
)
return ""
}
}
// IdentityTfunc returns a translation function that don't translate, only
// returns the same id
func IdentityTfunc() TranslateFunc {
return func(translationID string, args ...any) string {
return translationID
}
}

69
server/platform/shared/i18n/i18n_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package i18n
import (
"testing"
"github.com/mattermost/go-i18n/i18n/bundle"
"github.com/mattermost/go-i18n/i18n/language"
"github.com/mattermost/go-i18n/i18n/translation"
"github.com/stretchr/testify/assert"
)
var htmlTestTranslationBundle *bundle.Bundle
func init() {
htmlTestTranslationBundle = bundle.New()
fooBold, _ := translation.NewTranslation(map[string]any{
"id": "foo.bold",
"translation": "<p>[[{{ .Foo }}]]</p>",
})
htmlTestTranslationBundle.AddTranslation(&language.Language{Tag: "en"}, fooBold)
}
func TestTranslateAsHTML(t *testing.T) {
assert.EqualValues(t, "<p><strong>&lt;i&gt;foo&lt;/i&gt;</strong></p>", TranslateAsHTML(TranslateFunc(htmlTestTranslationBundle.MustTfunc("en")), "foo.bold", map[string]any{
"Foo": "<i>foo</i>",
}))
}
func TestEscapeForHTML(t *testing.T) {
stringForPointer := "<b>abc</b>"
for name, tc := range map[string]struct {
In any
Expected any
}{
"NoHTML": {
In: "abc",
Expected: "abc",
},
"String": {
In: "<b>abc</b>",
Expected: "&lt;b&gt;abc&lt;/b&gt;",
},
"StringPointer": {
In: &stringForPointer,
Expected: "&lt;b&gt;abc&lt;/b&gt;",
},
"Map": {
In: map[string]any{
"abc": "abc",
"123": "<b>123</b>",
},
Expected: map[string]any{
"abc": "abc",
"123": "&lt;b&gt;123&lt;/b&gt;",
},
},
"Unsupported": {
In: struct{ string }{"<b>abc</b>"},
Expected: "",
},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.Expected, escapeForHTML(tc.In))
})
}
}

185
server/platform/shared/mail/inbucket.go Обычный файл
Просмотреть файл

@@ -0,0 +1,185 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mail
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
InbucketAPI = "/api/v1/mailbox/"
)
// OutputJSONHeader holds the received Header to test sending emails (inbucket)
type JSONMessageHeaderInbucket []struct {
Mailbox string
ID string `json:"Id"`
From, Subject, Date string
To []string
Size int
}
// OutputJSONMessage holds the received Message fto test sending emails (inbucket)
type JSONMessageInbucket struct {
Mailbox string
ID string `json:"Id"`
From, Subject, Date string
Size int
Header map[string][]string
Body struct {
Text string
HTML string `json:"Html"`
}
Attachments []struct {
Filename string
ContentType string `json:"content-type"`
DownloadLink string `json:"download-link"`
Bytes []byte `json:"-"`
}
}
func ParseEmail(email string) string {
pos := strings.Index(email, "@")
parsedEmail := email[0:pos]
return parsedEmail
}
func GetMailBox(email string) (results JSONMessageHeaderInbucket, err error) {
parsedEmail := ParseEmail(email)
url := fmt.Sprintf("%s%s%s", getInbucketHost(), InbucketAPI, parsedEmail)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.Body == nil {
return nil, fmt.Errorf("no mailbox")
}
var record JSONMessageHeaderInbucket
err = json.NewDecoder(resp.Body).Decode(&record)
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
if len(record) == 0 {
return nil, fmt.Errorf("no mailbox")
}
return record, nil
}
func GetMessageFromMailbox(email, id string) (JSONMessageInbucket, error) {
parsedEmail := ParseEmail(email)
var record JSONMessageInbucket
url := fmt.Sprintf("%s%s%s/%s", getInbucketHost(), InbucketAPI, parsedEmail, id)
emailResponse, err := http.Get(url)
if err != nil {
return record, err
}
defer func() {
io.Copy(io.Discard, emailResponse.Body)
emailResponse.Body.Close()
}()
if err = json.NewDecoder(emailResponse.Body).Decode(&record); err != nil {
return record, err
}
// download attachments
if record.Attachments != nil && len(record.Attachments) > 0 {
for i := range record.Attachments {
var bytes []byte
bytes, err = downloadAttachment(record.Attachments[i].DownloadLink)
if err != nil {
return record, err
}
record.Attachments[i].Bytes = make([]byte, len(bytes))
copy(record.Attachments[i].Bytes, bytes)
}
}
return record, err
}
func downloadAttachment(url string) ([]byte, error) {
attachmentResponse, err := http.Get(url)
if err != nil {
return nil, err
}
defer attachmentResponse.Body.Close()
buf := new(bytes.Buffer)
io.Copy(buf, attachmentResponse.Body)
return buf.Bytes(), nil
}
func DeleteMailBox(email string) (err error) {
parsedEmail := ParseEmail(email)
url := fmt.Sprintf("%s%s%s", getInbucketHost(), InbucketAPI, parsedEmail)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
func RetryInbucket(attempts int, callback func() error) (err error) {
for i := 0; ; i++ {
err = callback()
if err == nil {
return nil
}
if i >= (attempts - 1) {
break
}
time.Sleep(5 * time.Second)
fmt.Println("retrying...")
}
return fmt.Errorf("after %d attempts, last error: %s", attempts, err)
}
func getInbucketHost() (host string) {
inbucket_host := os.Getenv("CI_INBUCKET_HOST")
if inbucket_host == "" {
inbucket_host = "localhost"
}
inbucket_port := os.Getenv("CI_INBUCKET_PORT")
if inbucket_port == "" {
inbucket_port = "9001"
}
return fmt.Sprintf("http://%s:%s", inbucket_host, inbucket_port)
}

380
server/platform/shared/mail/mail.go Обычный файл
Просмотреть файл

@@ -0,0 +1,380 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mail
import (
"context"
"crypto/tls"
"fmt"
"io"
"mime"
"net"
"net/mail"
"net/smtp"
"time"
"github.com/jaytaylor/html2text"
"github.com/pkg/errors"
gomail "gopkg.in/mail.v2"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const (
TLS = "TLS"
StartTLS = "STARTTLS"
)
type SMTPConfig struct {
ConnectionSecurity string
SkipServerCertificateVerification bool
Hostname string
ServerName string
Server string
Port string
ServerTimeout int
Username string
Password string
EnableSMTPAuth bool
SendEmailNotifications bool
FeedbackName string
FeedbackEmail string
ReplyToAddress string
}
type mailData struct {
mimeTo string
smtpTo string
from mail.Address
cc string
replyTo mail.Address
subject string
htmlBody string
embeddedFiles map[string]io.Reader
mimeHeaders map[string]string
messageID string
inReplyTo string
references string
category string
}
// smtpClient is implemented by an smtp.Client. See https://golang.org/pkg/net/smtp/#Client.
type smtpClient interface {
Mail(string) error
Rcpt(string) error
Data() (io.WriteCloser, error)
}
func encodeRFC2047Word(s string) string {
return mime.BEncoding.Encode("utf-8", s)
}
type authChooser struct {
smtp.Auth
config *SMTPConfig
}
func (a *authChooser) Start(server *smtp.ServerInfo) (string, []byte, error) {
smtpAddress := a.config.ServerName + ":" + a.config.Port
a.Auth = LoginAuth(a.config.Username, a.config.Password, smtpAddress)
for _, method := range server.Auth {
if method == "PLAIN" {
a.Auth = smtp.PlainAuth("", a.config.Username, a.config.Password, a.config.ServerName+":"+a.config.Port)
break
}
}
return a.Auth.Start(server)
}
type loginAuth struct {
username, password, host string
}
func LoginAuth(username, password, host string) smtp.Auth {
return &loginAuth{username, password, host}
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if !server.TLS {
return "", nil, errors.New("unencrypted connection")
}
if server.Name != a.host {
return "", nil, errors.New("wrong host name")
}
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("Unknown fromServer")
}
}
return nil, nil
}
func ConnectToSMTPServerAdvanced(config *SMTPConfig) (net.Conn, error) {
var conn net.Conn
var err error
smtpAddress := config.Server + ":" + config.Port
dialer := &net.Dialer{
Timeout: time.Duration(config.ServerTimeout) * time.Second,
}
if config.ConnectionSecurity == TLS {
tlsconfig := &tls.Config{
InsecureSkipVerify: config.SkipServerCertificateVerification,
ServerName: config.ServerName,
}
conn, err = tls.DialWithDialer(dialer, "tcp", smtpAddress, tlsconfig)
if err != nil {
return nil, errors.Wrap(err, "unable to connect to the SMTP server through TLS")
}
} else {
conn, err = dialer.Dial("tcp", smtpAddress)
if err != nil {
return nil, errors.Wrap(err, "unable to connect to the SMTP server")
}
}
return conn, nil
}
func ConnectToSMTPServer(config *SMTPConfig) (net.Conn, error) {
return ConnectToSMTPServerAdvanced(config)
}
func NewSMTPClientAdvanced(ctx context.Context, conn net.Conn, config *SMTPConfig) (*smtp.Client, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var c *smtp.Client
ec := make(chan error)
go func() {
var err error
c, err = smtp.NewClient(conn, config.ServerName+":"+config.Port)
if err != nil {
ec <- err
return
}
cancel()
}()
select {
case <-ctx.Done():
err := ctx.Err()
if err != nil && err.Error() != "context canceled" {
return nil, errors.Wrap(err, "unable to connect to the SMTP server")
}
case err := <-ec:
return nil, errors.Wrap(err, "unable to connect to the SMTP server")
}
if config.Hostname != "" {
err := c.Hello(config.Hostname)
if err != nil {
return nil, errors.Wrap(err, "unable to send hello message")
}
}
if config.ConnectionSecurity == StartTLS {
tlsconfig := &tls.Config{
InsecureSkipVerify: config.SkipServerCertificateVerification,
ServerName: config.ServerName,
}
c.StartTLS(tlsconfig)
}
if config.EnableSMTPAuth {
if err := c.Auth(&authChooser{config: config}); err != nil {
return nil, errors.Wrap(err, "authentication failed")
}
}
return c, nil
}
func NewSMTPClient(ctx context.Context, conn net.Conn, config *SMTPConfig) (*smtp.Client, error) {
return NewSMTPClientAdvanced(
ctx,
conn,
config,
)
}
func TestConnection(config *SMTPConfig) error {
conn, err := ConnectToSMTPServer(config)
if err != nil {
return errors.Wrap(err, "unable to connect")
}
defer conn.Close()
sec := config.ServerTimeout
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, time.Duration(sec)*time.Second)
defer cancel()
c, err := NewSMTPClient(ctx, conn, config)
if err != nil {
return errors.Wrap(err, "unable to connect")
}
c.Close()
c.Quit()
return nil
}
func SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, config *SMTPConfig, enableComplianceFeatures bool, messageID string, inReplyTo string, references string, ccMail string, category string) error {
fromMail := mail.Address{Name: config.FeedbackName, Address: config.FeedbackEmail}
replyTo := mail.Address{Name: config.FeedbackName, Address: config.ReplyToAddress}
mail := mailData{
mimeTo: to,
smtpTo: to,
from: fromMail,
cc: ccMail,
replyTo: replyTo,
subject: subject,
htmlBody: htmlBody,
embeddedFiles: embeddedFiles,
messageID: messageID,
inReplyTo: inReplyTo,
references: references,
category: category,
}
return sendMailUsingConfigAdvanced(mail, config)
}
func SendMailUsingConfig(to, subject, htmlBody string, config *SMTPConfig, enableComplianceFeatures bool, messageID string, inReplyTo string, references string, ccMail, category string) error {
return SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody, nil, config, enableComplianceFeatures, messageID, inReplyTo, references, ccMail, category)
}
// allows for sending an email with differing MIME/SMTP recipients
func sendMailUsingConfigAdvanced(mail mailData, config *SMTPConfig) error {
if config.Server == "" {
return nil
}
conn, err := ConnectToSMTPServer(config)
if err != nil {
return err
}
defer conn.Close()
sec := config.ServerTimeout
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, time.Duration(sec)*time.Second)
defer cancel()
c, err := NewSMTPClient(ctx, conn, config)
if err != nil {
return err
}
defer c.Quit()
defer c.Close()
return sendMail(c, mail, time.Now(), config)
}
const SendGridXSMTPAPIHeader = "X-SMTPAPI"
func sendMail(c smtpClient, mail mailData, date time.Time, config *SMTPConfig) error {
mlog.Debug("sending mail", mlog.String("to", mail.smtpTo), mlog.String("subject", mail.subject))
htmlMessage := mail.htmlBody
txtBody, err := html2text.FromString(mail.htmlBody)
if err != nil {
mlog.Warn("Unable to convert html body to text", mlog.Err(err))
txtBody = ""
}
headers := map[string][]string{
"From": {mail.from.String()},
"To": {mail.mimeTo},
"Subject": {encodeRFC2047Word(mail.subject)},
"Content-Transfer-Encoding": {"8bit"},
"Auto-Submitted": {"auto-generated"},
"Precedence": {"bulk"},
}
if mail.category != "" {
sendgridHeader := fmt.Sprintf(`{"category": %q}`, mail.category)
headers[SendGridXSMTPAPIHeader] = []string{sendgridHeader}
}
if mail.replyTo.Address != "" {
headers["Reply-To"] = []string{mail.replyTo.String()}
}
if mail.cc != "" {
headers["CC"] = []string{mail.cc}
}
if mail.messageID != "" {
headers["Message-ID"] = []string{mail.messageID}
} else {
randomStringLength := 16
msgID := fmt.Sprintf("<%s-%d@%s>", model.NewRandomString(randomStringLength), time.Now().Unix(), config.Hostname)
headers["Message-ID"] = []string{msgID}
}
if mail.inReplyTo != "" {
headers["In-Reply-To"] = []string{mail.inReplyTo}
}
if mail.references != "" {
headers["References"] = []string{mail.references}
}
for k, v := range mail.mimeHeaders {
headers[k] = []string{encodeRFC2047Word(v)}
}
m := gomail.NewMessage(gomail.SetCharset("UTF-8"))
m.SetHeaders(headers)
m.SetDateHeader("Date", date)
m.SetBody("text/plain", txtBody)
m.AddAlternative("text/html", htmlMessage)
for name, reader := range mail.embeddedFiles {
m.EmbedReader(name, reader)
}
if err = c.Mail(mail.from.Address); err != nil {
return errors.Wrap(err, "failed to set the from address")
}
if err = c.Rcpt(mail.smtpTo); err != nil {
return errors.Wrap(err, "failed to set the to address")
}
w, err := c.Data()
if err != nil {
return errors.Wrap(err, "failed to add email message data")
}
_, err = m.WriteTo(w)
if err != nil {
return errors.Wrap(err, "failed to write the email message")
}
err = w.Close()
if err != nil {
return errors.Wrap(err, "failed to close connection to the SMTP server")
}
return nil
}

423
server/platform/shared/mail/mail_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,423 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mail
import (
"bytes"
"context"
"io"
"net"
"net/mail"
"net/smtp"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func getConfig() *SMTPConfig {
server := os.Getenv("MM_EMAILSETTINGS_SMTPSERVER")
if server == "" {
server = "localhost"
}
port := os.Getenv("MM_EMAILSETTINGS_SMTPPORT")
if port == "" {
port = "10025"
}
return &SMTPConfig{
ConnectionSecurity: "",
SkipServerCertificateVerification: false,
Hostname: "localhost",
ServerName: server,
Server: server,
Port: port,
ServerTimeout: 10,
Username: "",
Password: "",
EnableSMTPAuth: false,
SendEmailNotifications: true,
FeedbackName: "",
FeedbackEmail: "test@example.com",
ReplyToAddress: "test@example.com",
}
}
func TestMailConnectionFromConfig(t *testing.T) {
cfg := getConfig()
conn, err := ConnectToSMTPServer(cfg)
require.NoError(t, err, "Should connect to the SMTP Server %v", err)
_, err = NewSMTPClient(context.Background(), conn, cfg)
require.NoError(t, err, "Should get new SMTP client")
cfg.Server = "wrongServer"
cfg.Port = "553"
_, err = ConnectToSMTPServer(cfg)
require.Error(t, err, "Should not connect to the SMTP Server")
}
func TestMailConnectionAdvanced(t *testing.T) {
cfg := getConfig()
conn, err := ConnectToSMTPServerAdvanced(cfg)
require.NoError(t, err, "Should connect to the SMTP Server")
defer conn.Close()
_, err2 := NewSMTPClientAdvanced(context.Background(), conn, cfg)
require.NoError(t, err2, "Should get new SMTP client")
l, err3 := net.Listen("tcp", "localhost:") // emulate nc -l <random-port>
require.NoError(t, err3, "Should've open a network socket and listen")
defer l.Close()
cfg = getConfig()
cfg.Server = strings.Split(l.Addr().String(), ":")[0]
cfg.Port = strings.Split(l.Addr().String(), ":")[1]
cfg.ServerTimeout = 1
conn2, err := ConnectToSMTPServerAdvanced(cfg)
require.NoError(t, err, "Should connect to the SMTP Server")
defer conn2.Close()
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
cfg = getConfig()
cfg.Server = strings.Split(l.Addr().String(), ":")[0]
cfg.Port = strings.Split(l.Addr().String(), ":")[1]
cfg.ServerTimeout = 1
_, err4 := NewSMTPClientAdvanced(
ctx,
conn2,
cfg,
)
require.Error(t, err4, "Should get a timeout get while creating a new SMTP client")
assert.Contains(t, err4.Error(), "unable to connect to the SMTP server")
cfg = getConfig()
cfg.Server = "wrongServer"
cfg.Port = "553"
cfg.ServerTimeout = 1
_, err5 := ConnectToSMTPServerAdvanced(cfg)
require.Error(t, err5, "Should not connect to the SMTP Server")
}
func TestSendMailUsingConfig(t *testing.T) {
cfg := getConfig()
var emailTo = "test@example.com"
var emailSubject = "Testing this email"
var emailBody = "This is a test from autobot"
var emailCC = "test@example.com"
//Delete all the messages before check the sample email
DeleteMailBox(emailTo)
err2 := SendMailUsingConfig(emailTo, emailSubject, emailBody, cfg, true, "", "", "", emailCC, "")
require.NoError(t, err2, "Should connect to the SMTP Server")
//Check if the email was send to the right email address
var resultsMailbox JSONMessageHeaderInbucket
err3 := RetryInbucket(5, func() error {
var err error
resultsMailbox, err = GetMailBox(emailTo)
return err
})
if err3 != nil {
t.Log(err3)
t.Log("No email was received, maybe due load on the server. Skipping this verification")
} else {
if len(resultsMailbox) > 0 {
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, emailBody, resultsEmail.Body.Text, "Wrong received message %s", resultsEmail.Body.Text)
}
}
}
func TestSendMailWithEmbeddedFilesUsingConfig(t *testing.T) {
cfg := getConfig()
var emailTo = "test@example.com"
var emailSubject = "Testing this email"
var emailBody = "This is a test from autobot"
var emailCC = "test@example.com"
//Delete all the messages before check the sample email
DeleteMailBox(emailTo)
embeddedFiles := map[string]io.Reader{
"test1.png": bytes.NewReader([]byte("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")),
"test2.png": bytes.NewReader([]byte("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")),
}
err2 := SendMailWithEmbeddedFilesUsingConfig(emailTo, emailSubject, emailBody, embeddedFiles, cfg, true, "", "", "", emailCC, "")
require.NoError(t, err2, "Should connect to the SMTP Server")
//Check if the email was send to the right email address
var resultsMailbox JSONMessageHeaderInbucket
err3 := RetryInbucket(5, func() error {
var err error
resultsMailbox, err = GetMailBox(emailTo)
return err
})
if err3 != nil {
t.Log(err3)
t.Log("No email was received, maybe due load on the server. Skipping this verification")
} else {
if len(resultsMailbox) > 0 {
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, emailBody, resultsEmail.Body.Text, "Wrong received message %s", resultsEmail.Body.Text)
// Usign the message size because the inbucket API doesn't return embedded attachments through the API
require.Greater(t, resultsEmail.Size, 1500, "the file size should be more because the embedded attachments")
}
}
}
func TestSendMailUsingConfigAdvanced(t *testing.T) {
cfg := getConfig()
//Delete all the messages before check the sample email
DeleteMailBox("test2@example.com")
// create two files with the same name that will both be attached to the email
file1, err := os.CreateTemp("", "*")
require.NoError(t, err)
defer os.Remove(file1.Name())
file1.Write([]byte("hello world"))
file1.Close()
file2, err := os.CreateTemp("", "*")
require.NoError(t, err)
defer os.Remove(file2.Name())
file2.Write([]byte("foo bar"))
file2.Close()
embeddedFiles := map[string]io.Reader{
"test": bytes.NewReader([]byte("test data")),
}
headers := make(map[string]string)
headers["TestHeader"] = "TestValue"
mail := mailData{
mimeTo: "test@example.com",
smtpTo: "test2@example.com",
from: mail.Address{Name: "Nobody", Address: "nobody@mattermost.com"},
replyTo: mail.Address{Name: "ReplyTo", Address: "reply_to@mattermost.com"},
subject: "Testing this email",
htmlBody: "This is a test from autobot",
embeddedFiles: embeddedFiles,
mimeHeaders: headers,
}
err = sendMailUsingConfigAdvanced(mail, cfg)
require.NoError(t, err, "Should connect to the SMTP Server: %v", err)
//Check if the email was send to the right email address
var resultsMailbox JSONMessageHeaderInbucket
err = RetryInbucket(5, func() error {
var mailErr error
resultsMailbox, mailErr = GetMailBox(mail.smtpTo)
return mailErr
})
require.NoError(t, err, "No emails found for address %s. error: %v", mail.smtpTo, err)
require.NotEqual(t, len(resultsMailbox), 0)
require.Contains(t, resultsMailbox[0].To[0], mail.mimeTo, "Wrong To recipient")
resultsEmail, err := GetMessageFromMailbox(mail.smtpTo, resultsMailbox[0].ID)
require.NoError(t, err)
require.Contains(t, mail.htmlBody, resultsEmail.Body.Text, "Wrong received message")
// verify that the To header of the email message is set to the MIME recipient, even though we got it out of the SMTP recipient's email inbox
assert.Equal(t, mail.mimeTo, resultsEmail.Header["To"][0])
// verify that the MIME from address is correct - unfortunately, we can't verify the SMTP from address
assert.Equal(t, mail.from.String(), resultsEmail.Header["From"][0])
// check that the custom mime headers came through - header case seems to get mutated
assert.Equal(t, "TestValue", resultsEmail.Header["Testheader"][0])
}
func TestAuthMethods(t *testing.T) {
auth := &authChooser{
config: &SMTPConfig{
Username: "test",
Password: "fakepass",
ServerName: "fakeserver",
Server: "fakeserver",
Port: "25",
},
}
tests := []struct {
desc string
server *smtp.ServerInfo
err string
}{
{
desc: "auth PLAIN success",
server: &smtp.ServerInfo{Name: "fakeserver:25", Auth: []string{"PLAIN"}, TLS: true},
},
{
desc: "auth PLAIN unencrypted connection fail",
server: &smtp.ServerInfo{Name: "fakeserver:25", Auth: []string{"PLAIN"}, TLS: false},
err: "unencrypted connection",
},
{
desc: "auth PLAIN wrong host name",
server: &smtp.ServerInfo{Name: "wrongServer:999", Auth: []string{"PLAIN"}, TLS: true},
err: "wrong host name",
},
{
desc: "auth LOGIN success",
server: &smtp.ServerInfo{Name: "fakeserver:25", Auth: []string{"LOGIN"}, TLS: true},
},
{
desc: "auth LOGIN unencrypted connection fail",
server: &smtp.ServerInfo{Name: "wrongServer:999", Auth: []string{"LOGIN"}, TLS: true},
err: "wrong host name",
},
{
desc: "auth LOGIN wrong host name",
server: &smtp.ServerInfo{Name: "fakeserver:25", Auth: []string{"LOGIN"}, TLS: false},
err: "unencrypted connection",
},
}
for i, test := range tests {
t.Run(test.desc, func(t *testing.T) {
_, _, err := auth.Start(test.server)
got := ""
if err != nil {
got = err.Error()
}
assert.True(t, got == test.err, "%d. got error = %q; want %q", i, got, test.err)
})
}
}
type mockMailer struct {
data []byte
}
func (m *mockMailer) Mail(string) error { return nil }
func (m *mockMailer) Rcpt(string) error { return nil }
func (m *mockMailer) Data() (io.WriteCloser, error) { return m, nil }
func (m *mockMailer) Write(p []byte) (int, error) {
m.data = append(m.data, p...)
return len(p), nil
}
func (m *mockMailer) Close() error { return nil }
func TestSendMail(t *testing.T) {
dir, err := os.MkdirTemp(".", "mail-test-")
require.NoError(t, err)
defer os.RemoveAll(dir)
mocm := &mockMailer{}
testCases := map[string]struct {
replyTo mail.Address
messageID string
inReplyTo string
references string
contains string
notContains string
}{
"adds reply-to header": {
mail.Address{Address: "foo@test.com"},
"",
"",
"",
"\r\nReply-To: <foo@test.com>\r\n",
"",
},
"doesn't add reply-to header": {
mail.Address{},
"",
"",
"",
"",
"\r\nReply-To:",
},
"adds message-id header": {
mail.Address{},
"<abc123@mattermost.com>",
"",
"",
"\r\nMessage-ID: <abc123@mattermost.com>\r\n",
"",
},
"always adds message-id header": {
mail.Address{},
"",
"",
"",
"\r\nMessage-ID: <",
"",
},
"adds in-reply-to header": {
mail.Address{},
"",
"<defg456@mattermost.com>",
"",
"\r\nIn-Reply-To: <defg456@mattermost.com>\r\n",
"",
},
"doesn't add in-reply-to header": {
mail.Address{},
"",
"",
"",
"",
"\r\nIn-Reply-To:",
},
"adds references header": {
mail.Address{},
"",
"",
"<ghi789@mattermost.com>",
"\r\nReferences: <ghi789@mattermost.com>\r\n",
"",
},
"doesn't add references header": {
mail.Address{},
"",
"",
"",
"",
"\r\nReferences:",
},
}
for testName, tc := range testCases {
t.Run(testName, func(t *testing.T) {
mail := mailData{"", "", mail.Address{}, "", tc.replyTo, "", "", nil, nil, tc.messageID, tc.inReplyTo, tc.references, ""}
cfg := getConfig()
err = sendMail(mocm, mail, time.Now(), cfg)
require.NoError(t, err)
if tc.contains != "" {
require.Contains(t, string(mocm.data), tc.contains)
}
if tc.notContains != "" {
require.NotContains(t, string(mocm.data), tc.notContains)
}
mocm.data = []byte{}
})
}
}

255
server/platform/shared/markdown/autolink.go Обычный файл
Просмотреть файл

@@ -0,0 +1,255 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// Based off of extensions/autolink.c from https://github.com/github/cmark
var (
DefaultURLSchemes = []string{"http", "https", "ftp", "mailto", "tel"}
wwwAutoLinkRegex = regexp.MustCompile(`^www\d{0,3}\.`)
)
// Given a string with a w at the given position, tries to parse and return a range containing a www link.
// if one exists. If the text at the given position isn't a link, returns an empty string. Equivalent to
// www_match from the reference code.
func parseWWWAutolink(data string, position int) (Range, bool) {
// Check that this isn't part of another word
if position > 1 {
prevChar := data[position-1]
if !isWhitespaceByte(prevChar) && !isAllowedBeforeWWWLink(prevChar) {
return Range{}, false
}
}
// Check that this starts with www
if len(data)-position < 4 || !wwwAutoLinkRegex.MatchString(data[position:]) {
return Range{}, false
}
end := checkDomain(data[position:], false)
if end == 0 {
return Range{}, false
}
end += position
// Grab all text until the end of the string or the next whitespace character
for end < len(data) && !isWhitespaceByte(data[end]) {
end += 1
}
// Trim trailing punctuation
end = trimTrailingCharactersFromLink(data, position, end)
if position == end {
return Range{}, false
}
return Range{position, end}, true
}
func isAllowedBeforeWWWLink(c byte) bool {
switch c {
case '*', '_', '~', ')':
return true
}
return false
}
// Given a string with a : at the given position, tried to parse and return a range containing a URL scheme
// if one exists. If the text around the given position isn't a link, returns an empty string. Equivalent to
// url_match from the reference code.
func parseURLAutolink(data string, position int) (Range, bool) {
// Check that a :// exists. This doesn't match the clients that treat the slashes as optional.
if len(data)-position < 4 || data[position+1] != '/' || data[position+2] != '/' {
return Range{}, false
}
start := position - 1
for start > 0 && isAlphanumericByte(data[start-1]) {
start -= 1
}
if start < 0 || position >= len(data) {
return Range{}, false
}
// Ensure that the URL scheme is allowed and that at least one character after the scheme is valid.
scheme := data[start:position]
if !isSchemeAllowed(scheme) || !isValidHostCharacter(data[position+3:]) {
return Range{}, false
}
end := checkDomain(data[position+3:], true)
if end == 0 {
return Range{}, false
}
end += position
// Grab all text until the end of the string or the next whitespace character
for end < len(data) && !isWhitespaceByte(data[end]) {
end += 1
}
// Trim trailing punctuation
end = trimTrailingCharactersFromLink(data, start, end)
if start == end {
return Range{}, false
}
return Range{start, end}, true
}
func isSchemeAllowed(scheme string) bool {
// Note that this doesn't support the custom URL schemes implemented by the client
for _, allowed := range DefaultURLSchemes {
if strings.EqualFold(allowed, scheme) {
return true
}
}
return false
}
// Given a string starting with a URL, returns the number of valid characters that make up the URL's domain.
// Returns 0 if the string doesn't start with a domain name. allowShort determines whether or not the domain
// needs to contain a period to be considered valid. Equivalent to check_domain from the reference code.
func checkDomain(data string, allowShort bool) int {
foundUnderscore := false
foundPeriod := false
i := 1
for ; i < len(data)-1; i++ {
if data[i] == '_' {
foundUnderscore = true
break
} else if data[i] == '.' {
foundPeriod = true
} else if !isValidHostCharacter(data[i:]) && data[i] != '-' {
break
}
}
if foundUnderscore {
return 0
}
if allowShort {
// If allowShort is set, accept any string of valid domain characters
return i
}
// If allowShort isn't set, a valid domain just requires at least a single period. Note that this
// logic isn't entirely necessary because we already know the string starts with "www." when
// this is called from parseWWWAutolink
if foundPeriod {
return i
}
return 0
}
// Returns true if the provided link starts with a valid character for a domain name. Equivalent to
// is_valid_hostchar from the reference code.
func isValidHostCharacter(link string) bool {
c, _ := utf8.DecodeRuneInString(link)
if c == utf8.RuneError {
return false
}
return !unicode.IsSpace(c) && !unicode.IsPunct(c)
}
// Removes any trailing characters such as punctuation or stray brackets that shouldn't be part of the link.
// Returns a new end position for the link. Equivalent to autolink_delim from the reference code.
func trimTrailingCharactersFromLink(markdown string, start int, end int) int {
runes := []rune(markdown[start:end])
linkEnd := len(runes)
// Cut off the link before an open angle bracket if it contains one
for i, c := range runes {
if c == '<' {
linkEnd = i
break
}
}
for linkEnd > 0 {
c := runes[linkEnd-1]
if !canEndAutolink(c) {
// Trim trailing quotes, periods, etc
linkEnd = linkEnd - 1
} else if c == ';' {
// Trim a trailing HTML entity
newEnd := linkEnd - 2
for newEnd > 0 && ((runes[newEnd] >= 'a' && runes[newEnd] <= 'z') || (runes[newEnd] >= 'A' && runes[newEnd] <= 'Z')) {
newEnd -= 1
}
if newEnd < linkEnd-2 && runes[newEnd] == '&' {
linkEnd = newEnd
} else {
// This isn't actually an HTML entity, so just trim the semicolon
linkEnd = linkEnd - 1
}
} else if c == ')' {
// Only allow an autolink ending with a bracket if that bracket is part of a matching pair of brackets.
// If there are more closing brackets than opening ones, remove the extra bracket
numClosing := 0
numOpening := 0
// Examples (input text => output linked portion):
//
// http://www.pokemon.com/Pikachu_(Electric)
// => http://www.pokemon.com/Pikachu_(Electric)
//
// http://www.pokemon.com/Pikachu_((Electric)
// => http://www.pokemon.com/Pikachu_((Electric)
//
// http://www.pokemon.com/Pikachu_(Electric))
// => http://www.pokemon.com/Pikachu_(Electric)
//
// http://www.pokemon.com/Pikachu_((Electric))
// => http://www.pokemon.com/Pikachu_((Electric))
for i := 0; i < linkEnd; i++ {
if runes[i] == '(' {
numOpening += 1
} else if runes[i] == ')' {
numClosing += 1
}
}
if numClosing <= numOpening {
// There's fewer or equal closing brackets, so we've found the end of the link
break
}
linkEnd -= 1
} else {
// There's no special characters at the end of the link, so we're at the end
break
}
}
return start + len(string(runes[:linkEnd]))
}
func canEndAutolink(c rune) bool {
switch c {
case '?', '!', '.', ',', ':', '*', '_', '~', '\'', '"':
return false
}
return true
}

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

@@ -0,0 +1,701 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseURLAutolink(t *testing.T) {
testCases := []struct {
Description string
Input string
Position int
Expected string
}{
{
Description: "no link",
Input: "This is an :emoji:",
Position: 11,
Expected: "",
},
{
Description: "no link 2",
Input: "These are two things: apple and orange",
Position: 20,
Expected: "",
},
{
Description: "link with http",
Input: "http://example.com and some text",
Position: 4,
Expected: "http://example.com",
},
{
Description: "link with https",
Input: "https://example.com and some text",
Position: 5,
Expected: "https://example.com",
},
{
Description: "link with ftp",
Input: "ftp://example.com and some text",
Position: 3,
Expected: "ftp://example.com",
},
{
Description: "link with a path",
Input: "https://example.com/abcd and some text",
Position: 5,
Expected: "https://example.com/abcd",
},
{
Description: "link with parameters",
Input: "ftp://example.com/abcd?foo=bar and some text",
Position: 3,
Expected: "ftp://example.com/abcd?foo=bar",
},
{
Description: "link, not at start",
Input: "This is https://example.com and some text",
Position: 13,
Expected: "https://example.com",
},
{
Description: "link with a path, not at start",
Input: "This is also http://www.example.com/abcd and some text",
Position: 17,
Expected: "http://www.example.com/abcd",
},
{
Description: "link with parameters, not at start",
Input: "These are https://www.example.com/abcd?foo=bar and some text",
Position: 15,
Expected: "https://www.example.com/abcd?foo=bar",
},
{
Description: "link with trailing characters",
Input: "This is ftp://www.example.com??",
Position: 11,
Expected: "ftp://www.example.com",
},
{
Description: "multiple links",
Input: "This is https://example.com/abcd and ftp://www.example.com/1234",
Position: 13,
Expected: "https://example.com/abcd",
},
{
Description: "second of multiple links",
Input: "This is https://example.com/abcd and ftp://www.example.com/1234",
Position: 40,
Expected: "ftp://www.example.com/1234",
},
{
Description: "link with brackets",
Input: "Go to ftp://www.example.com/my/page_(disambiguation) and some text",
Position: 9,
Expected: "ftp://www.example.com/my/page_(disambiguation)",
},
{
Description: "link in brackets",
Input: "(https://www.example.com/foo/bar)",
Position: 6,
Expected: "https://www.example.com/foo/bar",
},
{
Description: "link in underscores",
Input: "_http://www.example.com_",
Position: 5,
Expected: "http://www.example.com",
},
{
Description: "link in asterisks",
Input: "This is **ftp://example.com**",
Position: 13,
Expected: "ftp://example.com",
},
{
Description: "link in strikethrough",
Input: "Those were ~~https://example.com~~",
Position: 18,
Expected: "https://example.com",
},
{
Description: "link with angle brackets",
Input: "<b>We use http://example.com</b>",
Position: 14,
Expected: "http://example.com",
},
{
Description: "bad link protocol",
Input: "://///",
Position: 0,
Expected: "",
},
{
Description: "position greater than input length",
Input: "there is no colon",
Position: 1000,
Expected: "",
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
rawRange, ok := parseURLAutolink(testCase.Input, testCase.Position)
if testCase.Expected == "" {
assert.False(t, ok)
assert.Equal(t, Range{0, 0}, rawRange)
} else {
assert.True(t, ok)
assert.Equal(t, testCase.Expected, testCase.Input[rawRange.Position:rawRange.End])
}
})
}
}
func TestParseWWWAutolink(t *testing.T) {
testCases := []struct {
Description string
Input string
Position int
Expected string
}{
{
Description: "no link",
Input: "This is some text",
Position: 0,
Expected: "",
},
{
Description: "link",
Input: "www.example.com and some text",
Position: 0,
Expected: "www.example.com",
},
{
Description: "link with a path",
Input: "www.example.com/abcd and some text",
Position: 0,
Expected: "www.example.com/abcd",
},
{
Description: "link with parameters",
Input: "www.example.com/abcd?foo=bar and some text",
Position: 0,
Expected: "www.example.com/abcd?foo=bar",
},
{
Description: "link, not at start",
Input: "This is www.example.com and some text",
Position: 8,
Expected: "www.example.com",
},
{
Description: "link with a path, not at start",
Input: "This is also www.example.com/abcd and some text",
Position: 13,
Expected: "www.example.com/abcd",
},
{
Description: "link with parameters, not at start",
Input: "These are www.example.com/abcd?foo=bar and some text",
Position: 10,
Expected: "www.example.com/abcd?foo=bar",
},
{
Description: "link with trailing characters",
Input: "This is www.example.com??",
Position: 8,
Expected: "www.example.com",
},
{
Description: "link after current position",
Input: "This is some text and www.example.com",
Position: 0,
Expected: "",
},
{
Description: "multiple links",
Input: "This is www.example.com/abcd and www.example.com/1234",
Position: 8,
Expected: "www.example.com/abcd",
},
{
Description: "multiple links 2",
Input: "This is www.example.com/abcd and www.example.com/1234",
Position: 33,
Expected: "www.example.com/1234",
},
{
Description: "link with brackets",
Input: "Go to www.example.com/my/page_(disambiguation) and some text",
Position: 6,
Expected: "www.example.com/my/page_(disambiguation)",
},
{
Description: "link following other letters",
Input: "aaawww.example.com and some text",
Position: 3,
Expected: "",
},
{
Description: "link in brackets",
Input: "(www.example.com)",
Position: 1,
Expected: "www.example.com",
},
{
Description: "link in underscores",
Input: "_www.example.com_",
Position: 1,
Expected: "www.example.com",
},
{
Description: "link in asterisks",
Input: "This is **www.example.com**",
Position: 10,
Expected: "www.example.com",
},
{
Description: "link in strikethrough",
Input: "Those were ~~www.example.com~~",
Position: 13,
Expected: "www.example.com",
},
{
Description: "using www1",
Input: "Our backup site is at www1.example.com/foo",
Position: 22,
Expected: "www1.example.com/foo",
},
{
Description: "link with angle brackets",
Input: "<b>We use www2.example.com</b>",
Position: 10,
Expected: "www2.example.com",
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
rawRange, ok := parseWWWAutolink(testCase.Input, testCase.Position)
if testCase.Expected == "" {
assert.False(t, ok)
assert.Equal(t, Range{0, 0}, rawRange)
} else {
assert.True(t, ok)
assert.Equal(t, testCase.Expected, testCase.Input[rawRange.Position:rawRange.End])
}
})
}
}
func TestTrimTrailingCharactersFromLink(t *testing.T) {
testCases := []struct {
Input string
Start int
End int
ExpectedEnd int
}{
{
Input: "http://www.example.com",
ExpectedEnd: 22,
},
{
Input: "http://www.example.com/abcd",
ExpectedEnd: 27,
},
{
Input: "http://www.example.com/abcd/",
ExpectedEnd: 28,
},
{
Input: "http://www.example.com/1234",
ExpectedEnd: 27,
},
{
Input: "http://www.example.com/abcd?foo=bar",
ExpectedEnd: 35,
},
{
Input: "http://www.example.com/abcd#heading",
ExpectedEnd: 35,
},
{
Input: "http://www.example.com.",
ExpectedEnd: 22,
},
{
Input: "http://www.example.com,",
ExpectedEnd: 22,
},
{
Input: "http://www.example.com?",
ExpectedEnd: 22,
},
{
Input: "http://www.example.com)",
ExpectedEnd: 22,
},
{
Input: "http://www.example.com",
ExpectedEnd: 22,
},
{
Input: "https://en.wikipedia.org/wiki/Dolphin_(disambiguation)",
ExpectedEnd: 54,
},
{
Input: "https://en.wikipedia.org/wiki/Dolphin_(disambiguation",
ExpectedEnd: 53,
},
{
Input: "https://en.wikipedia.org/wiki/Dolphin_(disambiguation))",
ExpectedEnd: 54,
},
{
Input: "https://en.wikipedia.org/wiki/Dolphin_(disambiguation)_(disambiguation)",
ExpectedEnd: 71,
},
{
Input: "https://en.wikipedia.org/wiki/Dolphin_(disambiguation_(disambiguation))",
ExpectedEnd: 71,
},
{
Input: "http://www.example.com&quot;",
ExpectedEnd: 22,
},
{
Input: "this is a sentence containing http://www.example.com in it",
Start: 30,
End: 52,
ExpectedEnd: 52,
},
{
Input: "this is a sentence containing http://www.example.com???",
Start: 30,
End: 55,
ExpectedEnd: 52,
},
{
Input: "http://google.com/å",
ExpectedEnd: len("http://google.com/å"),
},
{
Input: "http://google.com/å...",
ExpectedEnd: len("http://google.com/å"),
},
{
Input: "This is http://google.com/å, a link, and http://google.com/å",
Start: 8,
End: len("This is http://google.com/å,"),
ExpectedEnd: len("This is http://google.com/å"),
},
{
Input: "This is http://google.com/å, a link, and http://google.com/å",
Start: 41,
End: len("This is http://google.com/å, a link, and http://google.com/å"),
ExpectedEnd: len("This is http://google.com/å, a link, and http://google.com/å"),
},
{
Input: "This is http://google.com/å, a link, and http://google.com/å.",
Start: 41,
End: len("This is http://google.com/å, a link, and http://google.com/å."),
ExpectedEnd: len("This is http://google.com/å, a link, and http://google.com/å"),
},
{
Input: "http://🍄.ga/ http://x🍄.ga/",
Start: 0,
End: len("http://🍄.ga/"),
ExpectedEnd: len("http://🍄.ga/"),
},
{
Input: "http://🍄.ga/ http://x🍄.ga/",
Start: len("http://🍄.ga/ "),
End: len("http://🍄.ga/ http://x🍄.ga/"),
ExpectedEnd: len("http://🍄.ga/ http://x🍄.ga/"),
},
}
for _, testCase := range testCases {
t.Run(testCase.Input, func(t *testing.T) {
if testCase.End == 0 {
testCase.End = len(testCase.Input) - testCase.Start
}
assert.Equal(t, testCase.ExpectedEnd, trimTrailingCharactersFromLink(testCase.Input, testCase.Start, testCase.End))
})
}
}
func TestAutolinking(t *testing.T) {
// These tests are adapted from https://github.com/mattermost/commonmark.js/test/mattermost.txt.
// It is missing tests for:
// 1. Links surrounded by emphasis (emphasis not implemented on the server)
// 2. IPv6 addresses (not implemented on the server or by GitHub)
// 3. Custom URL schemes (not implemented)
for name, tc := range map[string]struct {
Markdown string
ExpectedHTML string
}{
"valid-link-1": {
Markdown: `http://example.com`,
ExpectedHTML: `<p><a href="http://example.com">http://example.com</a></p>`,
},
"valid-link-2": {
Markdown: `https://example.com`,
ExpectedHTML: `<p><a href="https://example.com">https://example.com</a></p>`,
},
"valid-link-3": {
Markdown: `ftp://example.com`,
ExpectedHTML: `<p><a href="ftp://example.com">ftp://example.com</a></p>`,
},
// "valid-link-4": {
// Markdown: `ts3server://example.com?port=9000`,
// ExpectedHTML: `<p><a href="ts3server://example.com?port=9000">ts3server://example.com?port=9000</a></p>`,
// },
"valid-link-5": {
Markdown: `www.example.com`,
ExpectedHTML: `<p><a href="http://www.example.com">www.example.com</a></p>`,
},
"valid-link-6": {
Markdown: `www.example.com/index`,
ExpectedHTML: `<p><a href="http://www.example.com/index">www.example.com/index</a></p>`,
},
"valid-link-7": {
Markdown: `www.example.com/index.html`,
ExpectedHTML: `<p><a href="http://www.example.com/index.html">www.example.com/index.html</a></p>`,
},
"valid-link-8": {
Markdown: `http://example.com/index/sub`,
ExpectedHTML: `<p><a href="http://example.com/index/sub">http://example.com/index/sub</a></p>`,
},
"valid-link-9": {
Markdown: `www1.example.com`,
ExpectedHTML: `<p><a href="http://www1.example.com">www1.example.com</a></p>`,
},
"valid-link-10": {
Markdown: `https://en.wikipedia.org/wiki/URLs#Syntax`,
ExpectedHTML: `<p><a href="https://en.wikipedia.org/wiki/URLs#Syntax">https://en.wikipedia.org/wiki/URLs#Syntax</a></p>`,
},
"valid-link-11": {
Markdown: `https://groups.google.com/forum/#!msg`,
ExpectedHTML: `<p><a href="https://groups.google.com/forum/#!msg">https://groups.google.com/forum/#!msg</a></p>`,
},
"valid-link-12": {
Markdown: `www.example.com/index?params=1`,
ExpectedHTML: `<p><a href="http://www.example.com/index?params=1">www.example.com/index?params=1</a></p>`,
},
"valid-link-13": {
Markdown: `www.example.com/index?params=1&other=2`,
ExpectedHTML: `<p><a href="http://www.example.com/index?params=1&amp;other=2">www.example.com/index?params=1&amp;other=2</a></p>`,
},
"valid-link-14": {
Markdown: `www.example.com/index?params=1;other=2`,
ExpectedHTML: `<p><a href="http://www.example.com/index?params=1;other=2">www.example.com/index?params=1;other=2</a></p>`,
},
"valid-link-15": {
Markdown: `http://www.example.com/_/page`,
ExpectedHTML: `<p><a href="http://www.example.com/_/page">http://www.example.com/_/page</a></p>`,
},
"valid-link-16": {
Markdown: `https://en.wikipedia.org/wiki/🐬`,
ExpectedHTML: `<p><a href="https://en.wikipedia.org/wiki/%F0%9F%90%AC">https://en.wikipedia.org/wiki/🐬</a></p>`,
},
"valid-link-17": {
Markdown: `http://✪df.ws/1234`,
ExpectedHTML: `<p><a href="http://%E2%9C%AAdf.ws/1234">http://✪df.ws/1234</a></p>`,
},
"valid-link-18": {
Markdown: `https://groups.google.com/forum/#!msg`,
ExpectedHTML: `<p><a href="https://groups.google.com/forum/#!msg">https://groups.google.com/forum/#!msg</a></p>`,
},
"valid-link-19": {
Markdown: `https://пример.срб/пример-26/`,
ExpectedHTML: `<p><a href="https://%D0%BF%D1%80%D0%B8%D0%BC%D0%B5%D1%80.%D1%81%D1%80%D0%B1/%D0%BF%D1%80%D0%B8%D0%BC%D0%B5%D1%80-26/">https://пример.срб/пример-26/</a></p>`,
},
"valid-link-20": {
Markdown: `mailto://test@example.com`,
ExpectedHTML: `<p><a href="mailto://test@example.com">mailto://test@example.com</a></p>`,
},
"valid-link-21": {
Markdown: `tel://555-123-4567`,
ExpectedHTML: `<p><a href="tel://555-123-4567">tel://555-123-4567</a></p>`,
},
"ip-address-1": {
Markdown: `http://127.0.0.1`,
ExpectedHTML: `<p><a href="http://127.0.0.1">http://127.0.0.1</a></p>`,
},
"ip-address-2": {
Markdown: `http://192.168.1.1:4040`,
ExpectedHTML: `<p><a href="http://192.168.1.1:4040">http://192.168.1.1:4040</a></p>`,
},
"ip-address-3": {
Markdown: `http://username:password@127.0.0.1`,
ExpectedHTML: `<p><a href="http://username:password@127.0.0.1">http://username:password@127.0.0.1</a></p>`,
},
"ip-address-4": {
Markdown: `http://username:password@[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80`,
ExpectedHTML: `<p><a href="http://username:password@%5B2001:0:5ef5:79fb:303a:62d5:3312:ff42%5D:80">http://username:password@[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80</a></p>`,
},
"link-with-brackets-1": {
Markdown: `https://en.wikipedia.org/wiki/Rendering_(computer_graphics)`,
ExpectedHTML: `<p><a href="https://en.wikipedia.org/wiki/Rendering_(computer_graphics)">https://en.wikipedia.org/wiki/Rendering_(computer_graphics)</a></p>`,
},
"link-with-brackets-2": {
Markdown: `http://example.com/more_(than)_one_(parens)`,
ExpectedHTML: `<p><a href="http://example.com/more_(than)_one_(parens)">http://example.com/more_(than)_one_(parens)</a></p>`,
},
"link-with-brackets-3": {
Markdown: `http://example.com/(something)?after=parens`,
ExpectedHTML: `<p><a href="http://example.com/(something)?after=parens">http://example.com/(something)?after=parens</a></p>`,
},
"link-with-brackets-4": {
Markdown: `http://foo.com/unicode_(✪)_in_parens`,
ExpectedHTML: `<p><a href="http://foo.com/unicode_(%E2%9C%AA)_in_parens">http://foo.com/unicode_(✪)_in_parens</a></p>`,
},
"inside-another-link-1": {
Markdown: `[www.example.com](https://example.com)`,
ExpectedHTML: `<p><a href="https://example.com">www.example.com</a></p>`,
},
"inside-another-link-2": {
Markdown: `[http://www.example.com](https://example.com)`,
ExpectedHTML: `<p><a href="https://example.com">http://www.example.com</a></p>`,
},
"link-in-sentence-1": {
Markdown: `(http://example.com)`,
ExpectedHTML: `<p>(<a href="http://example.com">http://example.com</a>)</p>`,
},
"link-in-sentence-2": {
Markdown: `(see http://example.com)`,
ExpectedHTML: `<p>(see <a href="http://example.com">http://example.com</a>)</p>`,
},
"link-in-sentence-3": {
Markdown: `(http://example.com watch this)`,
ExpectedHTML: `<p>(<a href="http://example.com">http://example.com</a> watch this)</p>`,
},
"link-in-sentence-4": {
Markdown: `This is a sentence with a http://example.com in it.`,
ExpectedHTML: `<p>This is a sentence with a <a href="http://example.com">http://example.com</a> in it.</p>`,
},
"link-in-sentence-5": {
Markdown: `This is a sentence with a [link](http://example.com) in it.`,
ExpectedHTML: `<p>This is a sentence with a <a href="http://example.com">link</a> in it.</p>`,
},
"link-in-sentence-6": {
Markdown: `This is a sentence with a http://example.com/_/underscore in it.`,
ExpectedHTML: `<p>This is a sentence with a <a href="http://example.com/_/underscore">http://example.com/_/underscore</a> in it.</p>`,
},
"link-in-sentence-7": {
Markdown: `This is a sentence with a link (http://example.com) in it.`,
ExpectedHTML: `<p>This is a sentence with a link (<a href="http://example.com">http://example.com</a>) in it.</p>`,
},
"link-in-sentence-8": {
Markdown: `This is a sentence with a (https://en.wikipedia.org/wiki/Rendering_(computer_graphics)) in it.`,
ExpectedHTML: `<p>This is a sentence with a (<a href="https://en.wikipedia.org/wiki/Rendering_(computer_graphics)">https://en.wikipedia.org/wiki/Rendering_(computer_graphics)</a>) in it.</p>`,
},
"link-in-sentence-9": {
Markdown: `This is a sentence with a http://192.168.1.1:4040 in it.`,
ExpectedHTML: `<p>This is a sentence with a <a href="http://192.168.1.1:4040">http://192.168.1.1:4040</a> in it.</p>`,
},
"link-in-sentence-10": {
Markdown: `This is a link to http://example.com.`,
ExpectedHTML: `<p>This is a link to <a href="http://example.com">http://example.com</a>.</p>`,
},
"link-in-sentence-11": {
Markdown: `This is a link to http://example.com*`,
ExpectedHTML: `<p>This is a link to <a href="http://example.com">http://example.com</a>*</p>`,
},
"link-in-sentence-12": {
Markdown: `This is a link to http://example.com_`,
ExpectedHTML: `<p>This is a link to <a href="http://example.com">http://example.com</a>_</p>`,
},
"link-in-sentence-13": {
Markdown: `This is a link containing http://example.com/something?with,commas,in,url, but not at the end`,
ExpectedHTML: `<p>This is a link containing <a href="http://example.com/something?with,commas,in,url">http://example.com/something?with,commas,in,url</a>, but not at the end</p>`,
},
"link-in-sentence-14": {
Markdown: `This is a question about a link http://example.com?`,
ExpectedHTML: `<p>This is a question about a link <a href="http://example.com">http://example.com</a>?</p>`,
},
"plt-7250-link-with-trailing-periods-1": {
Markdown: `http://example.com.`,
ExpectedHTML: `<p><a href="http://example.com">http://example.com</a>.</p>`,
},
"plt-7250-link-with-trailing-periods-2": {
Markdown: `http://example.com...`,
ExpectedHTML: `<p><a href="http://example.com">http://example.com</a>...</p>`,
},
"plt-7250-link-with-trailing-periods-3": {
Markdown: `http://example.com/foo.`,
ExpectedHTML: `<p><a href="http://example.com/foo">http://example.com/foo</a>.</p>`,
},
"plt-7250-link-with-trailing-periods-4": {
Markdown: `http://example.com/foo...`,
ExpectedHTML: `<p><a href="http://example.com/foo">http://example.com/foo</a>...</p>`,
},
"plt-7250-link-with-trailing-periods-5": {
Markdown: `http://example.com/foo.bar`,
ExpectedHTML: `<p><a href="http://example.com/foo.bar">http://example.com/foo.bar</a></p>`,
},
"plt-7250-link-with-trailing-periods-6": {
Markdown: `http://example.com/foo...bar`,
ExpectedHTML: `<p><a href="http://example.com/foo...bar">http://example.com/foo...bar</a></p>`,
},
"rn-319-www-link-as-part-of-word-1": {
Markdown: `testwww.example.com`,
ExpectedHTML: `<p>testwww.example.com</p>`,
},
"mm-10180-link-containing-period-followed-by-non-letter-1": {
Markdown: `https://example.com/123.+Pagetitle`,
ExpectedHTML: `<p><a href="https://example.com/123.+Pagetitle">https://example.com/123.+Pagetitle</a></p>`,
},
"mm-10180-link-containing-period-followed-by-non-letter-2": {
Markdown: `https://example.com/123.?Pagetitle`,
ExpectedHTML: `<p><a href="https://example.com/123.?Pagetitle">https://example.com/123.?Pagetitle</a></p>`,
},
"mm-10180-link-containing-period-followed-by-non-letter-3": {
Markdown: `https://example.com/123.-Pagetitle`,
ExpectedHTML: `<p><a href="https://example.com/123.-Pagetitle">https://example.com/123.-Pagetitle</a></p>`,
},
"mm-10180-link-containing-period-followed-by-non-letter-4": {
Markdown: `https://example.com/123._Pagetitle`,
ExpectedHTML: `<p><a href="https://example.com/123._Pagetitle">https://example.com/123._Pagetitle</a></p>`,
},
"mm-10180-link-containing-period-followed-by-non-letter-5": {
Markdown: `https://example.com/123.+`,
ExpectedHTML: `<p><a href="https://example.com/123.+">https://example.com/123.+</a></p>`,
},
"mm-10180-link-containing-period-followed-by-non-letter-6": {
Markdown: `https://example.com/123.?`,
ExpectedHTML: `<p><a href="https://example.com/123">https://example.com/123</a>.?</p>`,
},
"mm-10180-link-containing-period-followed-by-non-letter-7": {
Markdown: `https://example.com/123.-`,
ExpectedHTML: `<p><a href="https://example.com/123.-">https://example.com/123.-</a></p>`,
},
"mm-10180-link-containing-period-followed-by-non-letter-8": {
Markdown: `https://example.com/123._`,
ExpectedHTML: `<p><a href="https://example.com/123">https://example.com/123</a>._</p>`,
},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.ExpectedHTML, RenderHTML(tc.Markdown))
})
}
}

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

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
type BlockQuote struct {
blockBase
markdown string
Children []Block
}
func (b *BlockQuote) Continuation(indentation int, r Range) *continuation {
if indentation > 3 {
return nil
}
s := b.markdown[r.Position:r.End]
if s == "" || s[0] != '>' {
return nil
}
remaining := Range{r.Position + 1, r.End}
indentation, indentationBytes := countIndentation(b.markdown, remaining)
if indentation > 0 {
indentation--
}
return &continuation{
Indentation: indentation,
Remaining: Range{remaining.Position + indentationBytes, remaining.End},
}
}
func (b *BlockQuote) AddChild(openBlocks []Block) []Block {
b.Children = append(b.Children, openBlocks[0])
return openBlocks
}
func blockQuoteStart(markdown string, indent int, r Range) []Block {
if indent > 3 {
return nil
}
s := markdown[r.Position:r.End]
if s == "" || s[0] != '>' {
return nil
}
block := &BlockQuote{
markdown: markdown,
}
r.Position++
if len(s) > 1 && s[1] == ' ' {
r.Position++
}
indent, bytes := countIndentation(markdown, r)
ret := []Block{block}
if descendants := blockStartOrParagraph(markdown, indent, Range{r.Position + bytes, r.End}, nil, nil); descendants != nil {
block.Children = append(block.Children, descendants[0])
ret = append(ret, descendants...)
}
return ret
}

154
server/platform/shared/markdown/blocks.go Обычный файл
Просмотреть файл

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
)
type continuation struct {
Indentation int
Remaining Range
}
type Block interface {
Continuation(indentation int, r Range) *continuation
AddLine(indentation int, r Range) bool
Close()
AllowsBlockStarts() bool
HasTrailingBlankLine() bool
}
type blockBase struct{}
func (*blockBase) AddLine(indentation int, r Range) bool { return false }
func (*blockBase) Close() {}
func (*blockBase) AllowsBlockStarts() bool { return true }
func (*blockBase) HasTrailingBlankLine() bool { return false }
type ContainerBlock interface {
Block
AddChild(openBlocks []Block) []Block
}
type Range struct {
Position int
End int
}
func closeBlocks(blocks []Block, referenceDefinitions []*ReferenceDefinition) []*ReferenceDefinition {
for _, block := range blocks {
block.Close()
if p, ok := block.(*Paragraph); ok && len(p.ReferenceDefinitions) > 0 {
referenceDefinitions = append(referenceDefinitions, p.ReferenceDefinitions...)
}
}
return referenceDefinitions
}
func ParseBlocks(markdown string, lines []Line) (*Document, []*ReferenceDefinition) {
document := &Document{}
var referenceDefinitions []*ReferenceDefinition
openBlocks := []Block{document}
for _, line := range lines {
r := line.Range
lastMatchIndex := 0
indentation, indentationBytes := countIndentation(markdown, r)
r = Range{r.Position + indentationBytes, r.End}
for i, block := range openBlocks {
if continuation := block.Continuation(indentation, r); continuation != nil {
indentation = continuation.Indentation
r = continuation.Remaining
additionalIndentation, additionalIndentationBytes := countIndentation(markdown, r)
r = Range{r.Position + additionalIndentationBytes, r.End}
indentation += additionalIndentation
lastMatchIndex = i
} else {
break
}
}
if openBlocks[lastMatchIndex].AllowsBlockStarts() {
if newBlocks := blockStart(markdown, indentation, r, openBlocks[:lastMatchIndex+1], openBlocks[lastMatchIndex+1:]); newBlocks != nil {
didAdd := false
for i := lastMatchIndex; i >= 0; i-- {
if container, ok := openBlocks[i].(ContainerBlock); ok {
if addedBlocks := container.AddChild(newBlocks); addedBlocks != nil {
referenceDefinitions = closeBlocks(openBlocks[i+1:], referenceDefinitions)
openBlocks = openBlocks[:i+1]
openBlocks = append(openBlocks, addedBlocks...)
didAdd = true
break
}
}
}
if didAdd {
continue
}
}
}
isBlank := strings.TrimSpace(markdown[r.Position:r.End]) == ""
if paragraph, ok := openBlocks[len(openBlocks)-1].(*Paragraph); ok && !isBlank {
paragraph.Text = append(paragraph.Text, r)
continue
}
referenceDefinitions = closeBlocks(openBlocks[lastMatchIndex+1:], referenceDefinitions)
openBlocks = openBlocks[:lastMatchIndex+1]
if openBlocks[lastMatchIndex].AddLine(indentation, r) {
continue
}
if paragraph := newParagraph(markdown, r); paragraph != nil {
for i := lastMatchIndex; i >= 0; i-- {
if container, ok := openBlocks[i].(ContainerBlock); ok {
if newBlocks := container.AddChild([]Block{paragraph}); newBlocks != nil {
referenceDefinitions = closeBlocks(openBlocks[i+1:], referenceDefinitions)
openBlocks = openBlocks[:i+1]
openBlocks = append(openBlocks, newBlocks...)
break
}
}
}
}
}
referenceDefinitions = closeBlocks(openBlocks, referenceDefinitions)
return document, referenceDefinitions
}
func blockStart(markdown string, indentation int, r Range, matchedBlocks, unmatchedBlocks []Block) []Block {
if r.Position >= r.End {
return nil
}
if start := blockQuoteStart(markdown, indentation, r); start != nil {
return start
} else if start := listStart(markdown, indentation, r, matchedBlocks, unmatchedBlocks); start != nil {
return start
} else if start := indentedCodeStart(markdown, indentation, r, matchedBlocks, unmatchedBlocks); start != nil {
return start
} else if start := fencedCodeStart(markdown, indentation, r); start != nil {
return start
}
return nil
}
func blockStartOrParagraph(markdown string, indentation int, r Range, matchedBlocks, unmatchedBlocks []Block) []Block {
if start := blockStart(markdown, indentation, r, matchedBlocks, unmatchedBlocks); start != nil {
return start
}
if paragraph := newParagraph(markdown, r); paragraph != nil {
return []Block{paragraph}
}
return nil
}

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

22
server/platform/shared/markdown/document.go Обычный файл
Просмотреть файл

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
type Document struct {
blockBase
Children []Block
}
func (b *Document) Continuation(indentation int, r Range) *continuation {
return &continuation{
Indentation: indentation,
Remaining: r,
}
}
func (b *Document) AddChild(openBlocks []Block) []Block {
b.Children = append(b.Children, openBlocks[0])
return openBlocks
}

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

@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
)
type FencedCodeLine struct {
Indentation int
Range Range
}
type FencedCode struct {
blockBase
markdown string
didSeeClosingFence bool
Indentation int
OpeningFence Range
RawInfo Range
RawCode []FencedCodeLine
}
func (b *FencedCode) Code() (result string) {
for _, code := range b.RawCode {
result += strings.Repeat(" ", code.Indentation) + b.markdown[code.Range.Position:code.Range.End]
}
return
}
func (b *FencedCode) Info() string {
return Unescape(b.markdown[b.RawInfo.Position:b.RawInfo.End])
}
func (b *FencedCode) Continuation(indentation int, r Range) *continuation {
if b.didSeeClosingFence {
return nil
}
return &continuation{
Indentation: indentation,
Remaining: r,
}
}
func (b *FencedCode) AddLine(indentation int, r Range) bool {
s := b.markdown[r.Position:r.End]
if indentation <= 3 && strings.HasPrefix(s, b.markdown[b.OpeningFence.Position:b.OpeningFence.End]) {
suffix := strings.TrimSpace(s[b.OpeningFence.End-b.OpeningFence.Position:])
isClosingFence := true
for _, c := range suffix {
if c != rune(s[0]) {
isClosingFence = false
break
}
}
if isClosingFence {
b.didSeeClosingFence = true
return true
}
}
if indentation >= b.Indentation {
indentation -= b.Indentation
} else {
indentation = 0
}
b.RawCode = append(b.RawCode, FencedCodeLine{
Indentation: indentation,
Range: r,
})
return true
}
func (b *FencedCode) AllowsBlockStarts() bool {
return false
}
func fencedCodeStart(markdown string, indentation int, r Range) []Block {
s := markdown[r.Position:r.End]
if !strings.HasPrefix(s, "```") && !strings.HasPrefix(s, "~~~") {
return nil
}
fenceCharacter := rune(s[0])
fenceLength := 3
for _, c := range s[3:] {
if c == fenceCharacter {
fenceLength++
} else {
break
}
}
for i := r.Position + fenceLength; i < r.End; i++ {
if markdown[i] == '`' {
return nil
}
}
return []Block{
&FencedCode{
markdown: markdown,
Indentation: indentation,
RawInfo: trimRightSpace(markdown, Range{r.Position + fenceLength, r.End}),
OpeningFence: Range{r.Position, r.Position + fenceLength},
},
}
}

192
server/platform/shared/markdown/html.go Обычный файл
Просмотреть файл

@@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"fmt"
"strings"
)
var htmlEscaper = strings.NewReplacer(
`&`, "&amp;",
`<`, "&lt;",
`>`, "&gt;",
`"`, "&quot;",
)
// RenderHTML produces HTML with the same behavior as the example renderer used in the CommonMark
// reference materials except for one slight difference: for brevity, no unnecessary whitespace is
// inserted between elements. The output is not defined by the CommonMark spec, and it exists
// primarily as an aid in testing.
func RenderHTML(markdown string) string {
return RenderBlockHTML(Parse(markdown))
}
func RenderBlockHTML(block Block, referenceDefinitions []*ReferenceDefinition) (result string) {
return renderBlockHTML(block, referenceDefinitions, false)
}
func renderBlockHTML(block Block, referenceDefinitions []*ReferenceDefinition, isTightList bool) (result string) {
switch v := block.(type) {
case *Document:
for _, block := range v.Children {
result += RenderBlockHTML(block, referenceDefinitions)
}
case *Paragraph:
if len(v.Text) == 0 {
return
}
if !isTightList {
result += "<p>"
}
for _, inline := range v.ParseInlines(referenceDefinitions) {
result += RenderInlineHTML(inline)
}
if !isTightList {
result += "</p>"
}
case *List:
if v.IsOrdered {
if v.OrderedStart != 1 {
result += fmt.Sprintf(`<ol start="%v">`, v.OrderedStart)
} else {
result += "<ol>"
}
} else {
result += "<ul>"
}
for _, block := range v.Children {
result += renderBlockHTML(block, referenceDefinitions, !v.IsLoose)
}
if v.IsOrdered {
result += "</ol>"
} else {
result += "</ul>"
}
case *ListItem:
result += "<li>"
for _, block := range v.Children {
result += renderBlockHTML(block, referenceDefinitions, isTightList)
}
result += "</li>"
case *BlockQuote:
result += "<blockquote>"
for _, block := range v.Children {
result += RenderBlockHTML(block, referenceDefinitions)
}
result += "</blockquote>"
case *FencedCode:
if info := v.Info(); info != "" {
language := strings.Fields(info)[0]
result += `<pre><code class="language-` + htmlEscaper.Replace(language) + `">`
} else {
result += "<pre><code>"
}
result += htmlEscaper.Replace(v.Code()) + "</code></pre>"
case *IndentedCode:
result += "<pre><code>" + htmlEscaper.Replace(v.Code()) + "</code></pre>"
default:
panic(fmt.Sprintf("missing case for type %T", v))
}
return
}
func escapeURL(url string) (result string) {
for i := 0; i < len(url); {
switch b := url[i]; b {
case ';', '/', '?', ':', '@', '&', '=', '+', '$', ',', '-', '_', '.', '!', '~', '*', '\'', '(', ')', '#':
result += string(b)
i++
default:
if b == '%' && i+2 < len(url) && isHexByte(url[i+1]) && isHexByte(url[i+2]) {
result += url[i : i+3]
i += 3
} else if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') {
result += string(b)
i++
} else {
result += fmt.Sprintf("%%%0X", b)
i++
}
}
}
return
}
func RenderInlineHTML(inline Inline) (result string) {
switch v := inline.(type) {
case *Text:
return htmlEscaper.Replace(v.Text)
case *HardLineBreak:
return "<br />"
case *SoftLineBreak:
return "\n"
case *CodeSpan:
return "<code>" + htmlEscaper.Replace(v.Code) + "</code>"
case *InlineImage:
result += `<img src="` + htmlEscaper.Replace(escapeURL(v.Destination())) + `" alt="` + htmlEscaper.Replace(renderImageAltText(v.Children)) + `"`
if title := v.Title(); title != "" {
result += ` title="` + htmlEscaper.Replace(title) + `"`
}
result += ` />`
case *ReferenceImage:
result += `<img src="` + htmlEscaper.Replace(escapeURL(v.Destination())) + `" alt="` + htmlEscaper.Replace(renderImageAltText(v.Children)) + `"`
if title := v.Title(); title != "" {
result += ` title="` + htmlEscaper.Replace(title) + `"`
}
result += ` />`
case *InlineLink:
result += `<a href="` + htmlEscaper.Replace(escapeURL(v.Destination())) + `"`
if title := v.Title(); title != "" {
result += ` title="` + htmlEscaper.Replace(title) + `"`
}
result += `>`
for _, inline := range v.Children {
result += RenderInlineHTML(inline)
}
result += "</a>"
case *ReferenceLink:
result += `<a href="` + htmlEscaper.Replace(escapeURL(v.Destination())) + `"`
if title := v.Title(); title != "" {
result += ` title="` + htmlEscaper.Replace(title) + `"`
}
result += `>`
for _, inline := range v.Children {
result += RenderInlineHTML(inline)
}
result += "</a>"
case *Autolink:
result += `<a href="` + htmlEscaper.Replace(escapeURL(v.Destination())) + `">`
for _, inline := range v.Children {
result += RenderInlineHTML(inline)
}
result += "</a>"
default:
panic(fmt.Sprintf("missing case for type %T", v))
}
return
}
func renderImageAltText(children []Inline) (result string) {
for _, inline := range children {
result += renderImageChildAltText(inline)
}
return
}
func renderImageChildAltText(inline Inline) (result string) {
switch v := inline.(type) {
case *Text:
return v.Text
case *InlineImage:
for _, inline := range v.Children {
result += renderImageChildAltText(inline)
}
case *InlineLink:
for _, inline := range v.Children {
result += renderImageChildAltText(inline)
}
}
return
}

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

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

@@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
)
type IndentedCodeLine struct {
Indentation int
Range Range
}
type IndentedCode struct {
blockBase
markdown string
RawCode []IndentedCodeLine
}
func (b *IndentedCode) Code() (result string) {
for _, code := range b.RawCode {
result += strings.Repeat(" ", code.Indentation) + b.markdown[code.Range.Position:code.Range.End]
}
return
}
func (b *IndentedCode) Continuation(indentation int, r Range) *continuation {
if indentation >= 4 {
return &continuation{
Indentation: indentation - 4,
Remaining: r,
}
}
s := b.markdown[r.Position:r.End]
if strings.TrimSpace(s) == "" {
return &continuation{
Remaining: r,
}
}
return nil
}
func (b *IndentedCode) AddLine(indentation int, r Range) bool {
b.RawCode = append(b.RawCode, IndentedCodeLine{
Indentation: indentation,
Range: r,
})
return true
}
func (b *IndentedCode) Close() {
for {
last := b.RawCode[len(b.RawCode)-1]
s := b.markdown[last.Range.Position:last.Range.End]
if strings.TrimRight(s, "\r\n") == "" {
b.RawCode = b.RawCode[:len(b.RawCode)-1]
} else {
break
}
}
}
func (b *IndentedCode) AllowsBlockStarts() bool {
return false
}
func indentedCodeStart(markdown string, indentation int, r Range, matchedBlocks, unmatchedBlocks []Block) []Block {
if len(unmatchedBlocks) > 0 {
if _, ok := unmatchedBlocks[len(unmatchedBlocks)-1].(*Paragraph); ok {
return nil
}
} else if len(matchedBlocks) > 0 {
if _, ok := matchedBlocks[len(matchedBlocks)-1].(*Paragraph); ok {
return nil
}
}
if indentation < 4 {
return nil
}
s := markdown[r.Position:r.End]
if strings.TrimSpace(s) == "" {
return nil
}
return []Block{
&IndentedCode{
markdown: markdown,
RawCode: []IndentedCodeLine{{
Indentation: indentation - 4,
Range: r,
}},
},
}
}

663
server/platform/shared/markdown/inlines.go Обычный файл
Просмотреть файл

@@ -0,0 +1,663 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"container/list"
"strings"
"unicode"
"unicode/utf8"
)
type Inline interface {
IsInline() bool
}
type inlineBase struct{}
func (inlineBase) IsInline() bool { return true }
type Text struct {
inlineBase
Text string
Range Range
}
type CodeSpan struct {
inlineBase
Code string
}
type HardLineBreak struct {
inlineBase
}
type SoftLineBreak struct {
inlineBase
}
type InlineLinkOrImage struct {
inlineBase
Children []Inline
RawDestination Range
markdown string
rawTitle string
}
func (i *InlineLinkOrImage) Destination() string {
return Unescape(i.markdown[i.RawDestination.Position:i.RawDestination.End])
}
func (i *InlineLinkOrImage) Title() string {
return Unescape(i.rawTitle)
}
type InlineLink struct {
InlineLinkOrImage
}
type InlineImage struct {
InlineLinkOrImage
}
type ReferenceLinkOrImage struct {
inlineBase
*ReferenceDefinition
Children []Inline
}
type ReferenceLink struct {
ReferenceLinkOrImage
}
type ReferenceImage struct {
ReferenceLinkOrImage
}
type Autolink struct {
inlineBase
Children []Inline
RawDestination Range
markdown string
}
func (i *Autolink) Destination() string {
destination := Unescape(i.markdown[i.RawDestination.Position:i.RawDestination.End])
if strings.HasPrefix(destination, "www") {
destination = "http://" + destination
}
return destination
}
type delimiterType int
const (
linkOpeningDelimiter delimiterType = iota
imageOpeningDelimiter
)
type delimiter struct {
Type delimiterType
IsInactive bool
TextNode int
Range Range
}
type inlineParser struct {
markdown string
ranges []Range
referenceDefinitions []*ReferenceDefinition
raw string
position int
inlines []Inline
delimiterStack *list.List
}
func newInlineParser(markdown string, ranges []Range, referenceDefinitions []*ReferenceDefinition) *inlineParser {
return &inlineParser{
markdown: markdown,
ranges: ranges,
referenceDefinitions: referenceDefinitions,
delimiterStack: list.New(),
}
}
func (p *inlineParser) parseBackticks() {
count := 1
for i := p.position + 1; i < len(p.raw) && p.raw[i] == '`'; i++ {
count++
}
opening := p.raw[p.position : p.position+count]
search := p.position + count
for search < len(p.raw) {
end := strings.Index(p.raw[search:], opening)
if end == -1 {
break
}
if search+end+count < len(p.raw) && p.raw[search+end+count] == '`' {
search += end + count
for search < len(p.raw) && p.raw[search] == '`' {
search++
}
continue
}
code := strings.Join(strings.Fields(p.raw[p.position+count:search+end]), " ")
p.position = search + end + count
p.inlines = append(p.inlines, &CodeSpan{
Code: code,
})
return
}
p.position += len(opening)
absPos := relativeToAbsolutePosition(p.ranges, p.position-len(opening))
p.inlines = append(p.inlines, &Text{
Text: opening,
Range: Range{absPos, absPos + len(opening)},
})
}
func (p *inlineParser) parseLineEnding() {
if p.position >= 1 && p.raw[p.position-1] == '\t' {
p.inlines = append(p.inlines, &HardLineBreak{})
} else if p.position >= 2 && p.raw[p.position-1] == ' ' && (p.raw[p.position-2] == '\t' || p.raw[p.position-1] == ' ') {
p.inlines = append(p.inlines, &HardLineBreak{})
} else {
p.inlines = append(p.inlines, &SoftLineBreak{})
}
p.position++
if p.position < len(p.raw) && p.raw[p.position] == '\n' {
p.position++
}
}
func (p *inlineParser) parseEscapeCharacter() {
if p.position+1 < len(p.raw) && isEscapableByte(p.raw[p.position+1]) {
absPos := relativeToAbsolutePosition(p.ranges, p.position+1)
p.inlines = append(p.inlines, &Text{
Text: string(p.raw[p.position+1]),
Range: Range{absPos, absPos + len(string(p.raw[p.position+1]))},
})
p.position += 2
} else {
absPos := relativeToAbsolutePosition(p.ranges, p.position)
p.inlines = append(p.inlines, &Text{
Text: `\`,
Range: Range{absPos, absPos + 1},
})
p.position++
}
}
func (p *inlineParser) parseText() {
if next := strings.IndexAny(p.raw[p.position:], "\r\n\\`&![]wW:"); next == -1 {
absPos := relativeToAbsolutePosition(p.ranges, p.position)
p.inlines = append(p.inlines, &Text{
Text: strings.TrimRightFunc(p.raw[p.position:], isWhitespace),
Range: Range{absPos, absPos + len(p.raw[p.position:])},
})
p.position = len(p.raw)
} else {
absPos := relativeToAbsolutePosition(p.ranges, p.position)
if p.raw[p.position+next] == '\r' || p.raw[p.position+next] == '\n' {
s := strings.TrimRightFunc(p.raw[p.position:p.position+next], isWhitespace)
p.inlines = append(p.inlines, &Text{
Text: s,
Range: Range{absPos, absPos + len(s)},
})
} else {
if next == 0 {
// Always read at least one character since 'w', 'W', and ':' may not actually match another
// type of node
next = 1
}
p.inlines = append(p.inlines, &Text{
Text: p.raw[p.position : p.position+next],
Range: Range{absPos, absPos + next},
})
}
p.position += next
}
}
func (p *inlineParser) parseLinkOrImageDelimiter() {
absPos := relativeToAbsolutePosition(p.ranges, p.position)
if p.raw[p.position] == '[' {
p.inlines = append(p.inlines, &Text{
Text: "[",
Range: Range{absPos, absPos + 1},
})
p.delimiterStack.PushBack(&delimiter{
Type: linkOpeningDelimiter,
TextNode: len(p.inlines) - 1,
Range: Range{p.position, p.position + 1},
})
p.position++
} else if p.raw[p.position] == '!' && p.position+1 < len(p.raw) && p.raw[p.position+1] == '[' {
p.inlines = append(p.inlines, &Text{
Text: "![",
Range: Range{absPos, absPos + 2},
})
p.delimiterStack.PushBack(&delimiter{
Type: imageOpeningDelimiter,
TextNode: len(p.inlines) - 1,
Range: Range{p.position, p.position + 2},
})
p.position += 2
} else {
p.inlines = append(p.inlines, &Text{
Text: "!",
Range: Range{absPos, absPos + 1},
})
p.position++
}
}
func (p *inlineParser) peekAtInlineLinkDestinationAndTitle(position int, isImage bool) (destination, title Range, end int, ok bool) {
if position >= len(p.raw) || p.raw[position] != '(' {
return
}
position++
destinationStart := nextNonWhitespace(p.raw, position)
if destinationStart >= len(p.raw) {
return
} else if p.raw[destinationStart] == ')' {
return Range{destinationStart, destinationStart}, Range{destinationStart, destinationStart}, destinationStart + 1, true
}
destination, end, ok = parseLinkDestination(p.raw, destinationStart)
if !ok {
return
}
position = end
if isImage && position < len(p.raw) && isWhitespaceByte(p.raw[position]) {
dimensionsStart := nextNonWhitespace(p.raw, position)
if dimensionsStart >= len(p.raw) {
return
}
if p.raw[dimensionsStart] == '=' {
// Read optional image dimensions even if we don't use them
_, end, ok = parseImageDimensions(p.raw, dimensionsStart)
if !ok {
return
}
position = end
}
}
if position < len(p.raw) && isWhitespaceByte(p.raw[position]) {
titleStart := nextNonWhitespace(p.raw, position)
if titleStart >= len(p.raw) {
return
} else if p.raw[titleStart] == ')' {
return destination, Range{titleStart, titleStart}, titleStart + 1, true
}
if p.raw[titleStart] == '"' || p.raw[titleStart] == '\'' || p.raw[titleStart] == '(' {
title, end, ok = parseLinkTitle(p.raw, titleStart)
if !ok {
return
}
position = end
}
}
closingPosition := nextNonWhitespace(p.raw, position)
if closingPosition >= len(p.raw) || p.raw[closingPosition] != ')' {
return Range{}, Range{}, 0, false
}
return destination, title, closingPosition + 1, true
}
func (p *inlineParser) referenceDefinition(label string) *ReferenceDefinition {
clean := strings.Join(strings.Fields(label), " ")
for _, d := range p.referenceDefinitions {
if strings.EqualFold(clean, strings.Join(strings.Fields(d.Label()), " ")) {
return d
}
}
return nil
}
func (p *inlineParser) lookForLinkOrImage() {
for element := p.delimiterStack.Back(); element != nil; element = element.Prev() {
d := element.Value.(*delimiter)
if d.Type != imageOpeningDelimiter && d.Type != linkOpeningDelimiter {
continue
}
if d.IsInactive {
p.delimiterStack.Remove(element)
break
}
isImage := d.Type == imageOpeningDelimiter
var inline Inline
if destination, title, next, ok := p.peekAtInlineLinkDestinationAndTitle(p.position+1, isImage); ok {
destinationMarkdownPosition := relativeToAbsolutePosition(p.ranges, destination.Position)
linkOrImage := InlineLinkOrImage{
Children: append([]Inline(nil), p.inlines[d.TextNode+1:]...),
RawDestination: Range{destinationMarkdownPosition, destinationMarkdownPosition + destination.End - destination.Position},
markdown: p.markdown,
rawTitle: p.raw[title.Position:title.End],
}
if d.Type == imageOpeningDelimiter {
inline = &InlineImage{linkOrImage}
} else {
inline = &InlineLink{linkOrImage}
}
p.position = next
} else {
referenceLabel := ""
label, next, hasLinkLabel := parseLinkLabel(p.raw, p.position+1)
if hasLinkLabel && label.End > label.Position {
referenceLabel = p.raw[label.Position:label.End]
} else {
referenceLabel = p.raw[d.Range.End:p.position]
if !hasLinkLabel {
next = p.position + 1
}
}
if referenceLabel != "" {
if reference := p.referenceDefinition(referenceLabel); reference != nil {
linkOrImage := ReferenceLinkOrImage{
ReferenceDefinition: reference,
Children: append([]Inline(nil), p.inlines[d.TextNode+1:]...),
}
if d.Type == imageOpeningDelimiter {
inline = &ReferenceImage{linkOrImage}
} else {
inline = &ReferenceLink{linkOrImage}
}
p.position = next
}
}
}
if inline != nil {
if d.Type == imageOpeningDelimiter {
p.inlines = append(p.inlines[:d.TextNode], inline)
} else {
p.inlines = append(p.inlines[:d.TextNode], inline)
for inlineElement := element.Prev(); inlineElement != nil; inlineElement = inlineElement.Prev() {
if d := inlineElement.Value.(*delimiter); d.Type == linkOpeningDelimiter {
d.IsInactive = true
}
}
}
p.delimiterStack.Remove(element)
return
}
p.delimiterStack.Remove(element)
break
}
absPos := relativeToAbsolutePosition(p.ranges, p.position)
p.inlines = append(p.inlines, &Text{
Text: "]",
Range: Range{absPos, absPos + 1},
})
p.position++
}
func CharacterReference(ref string) string {
if ref == "" {
return ""
}
if ref[0] == '#' {
if len(ref) < 2 {
return ""
}
n := 0
if ref[1] == 'X' || ref[1] == 'x' {
if len(ref) < 3 {
return ""
}
for i := 2; i < len(ref); i++ {
if i > 9 {
return ""
}
d := ref[i]
switch {
case d >= '0' && d <= '9':
n = n*16 + int(d-'0')
case d >= 'a' && d <= 'f':
n = n*16 + 10 + int(d-'a')
case d >= 'A' && d <= 'F':
n = n*16 + 10 + int(d-'A')
default:
return ""
}
}
} else {
for i := 1; i < len(ref); i++ {
if i > 8 || ref[i] < '0' || ref[i] > '9' {
return ""
}
n = n*10 + int(ref[i]-'0')
}
}
c := rune(n)
if c == '\u0000' || !utf8.ValidRune(c) {
return string(unicode.ReplacementChar)
}
return string(c)
}
if entity, ok := htmlEntities[ref]; ok {
return entity
}
return ""
}
func (p *inlineParser) parseCharacterReference() {
absPos := relativeToAbsolutePosition(p.ranges, p.position)
p.position++
if semicolon := strings.IndexByte(p.raw[p.position:], ';'); semicolon == -1 {
p.inlines = append(p.inlines, &Text{
Text: "&",
Range: Range{absPos, absPos + 1},
})
} else if s := CharacterReference(p.raw[p.position : p.position+semicolon]); s != "" {
p.position += semicolon + 1
p.inlines = append(p.inlines, &Text{
Text: s,
Range: Range{absPos, absPos + len(s)},
})
} else {
p.inlines = append(p.inlines, &Text{
Text: "&",
Range: Range{absPos, absPos + 1},
})
}
}
func (p *inlineParser) parseAutolink(c rune) bool {
for element := p.delimiterStack.Back(); element != nil; element = element.Prev() {
d := element.Value.(*delimiter)
if !d.IsInactive {
return false
}
}
var link Range
if c == ':' {
var ok bool
link, ok = parseURLAutolink(p.raw, p.position)
if !ok {
return false
}
// Since the current position is at the colon, we have to rewind the parsing slightly so that
// we don't duplicate the URL scheme
rewind := strings.Index(p.raw[link.Position:link.End], ":")
if rewind != -1 {
lastInline := p.inlines[len(p.inlines)-1]
lastText, ok := lastInline.(*Text)
if !ok {
// This should never occur since parseURLAutolink will only return a non-empty value
// when the previous text ends in a valid URL protocol which would mean that the previous
// node is a Text node
return false
}
p.inlines = p.inlines[0 : len(p.inlines)-1]
p.inlines = append(p.inlines, &Text{
Text: lastText.Text[:len(lastText.Text)-rewind],
Range: Range{lastText.Range.Position, lastText.Range.End - rewind},
})
p.position -= rewind
}
} else if c == 'w' || c == 'W' {
var ok bool
link, ok = parseWWWAutolink(p.raw, p.position)
if !ok {
return false
}
}
linkMarkdownPosition := relativeToAbsolutePosition(p.ranges, link.Position)
linkRange := Range{linkMarkdownPosition, linkMarkdownPosition + link.End - link.Position}
p.inlines = append(p.inlines, &Autolink{
Children: []Inline{
&Text{
Text: p.raw[link.Position:link.End],
Range: linkRange,
},
},
RawDestination: linkRange,
markdown: p.markdown,
})
p.position += (link.End - link.Position)
return true
}
func (p *inlineParser) Parse() []Inline {
for _, r := range p.ranges {
p.raw += p.markdown[r.Position:r.End]
}
for p.position < len(p.raw) {
c, _ := utf8.DecodeRuneInString(p.raw[p.position:])
switch c {
case '\r', '\n':
p.parseLineEnding()
case '\\':
p.parseEscapeCharacter()
case '`':
p.parseBackticks()
case '&':
p.parseCharacterReference()
case '!', '[':
p.parseLinkOrImageDelimiter()
case ']':
p.lookForLinkOrImage()
case 'w', 'W', ':':
matched := p.parseAutolink(c)
if !matched {
p.parseText()
}
default:
p.parseText()
}
}
return p.inlines
}
func ParseInlines(markdown string, ranges []Range, referenceDefinitions []*ReferenceDefinition) (inlines []Inline) {
return newInlineParser(markdown, ranges, referenceDefinitions).Parse()
}
func MergeInlineText(inlines []Inline) []Inline {
ret := inlines[:0]
for i, v := range inlines {
// always add first node
if i == 0 {
ret = append(ret, v)
continue
}
// not a text node? nothing to merge
text, ok := v.(*Text)
if !ok {
ret = append(ret, v)
continue
}
// previous node is not a text node? nothing to merge
prevText, ok := ret[len(ret)-1].(*Text)
if !ok {
ret = append(ret, v)
continue
}
// previous node is not right before this one
if prevText.Range.End != text.Range.Position {
ret = append(ret, v)
continue
}
// we have two consecutive text nodes
ret[len(ret)-1] = &Text{
Text: prevText.Text + text.Text,
Range: Range{prevText.Range.Position, text.Range.End},
}
}
return ret
}
func Unescape(markdown string) string {
ret := ""
position := 0
for position < len(markdown) {
c, cSize := utf8.DecodeRuneInString(markdown[position:])
switch c {
case '\\':
if position+1 < len(markdown) && isEscapableByte(markdown[position+1]) {
ret += string(markdown[position+1])
position += 2
} else {
ret += `\`
position++
}
case '&':
position++
if semicolon := strings.IndexByte(markdown[position:], ';'); semicolon == -1 {
ret += "&"
} else if s := CharacterReference(markdown[position : position+semicolon]); s != "" {
position += semicolon + 1
ret += s
} else {
ret += "&"
}
default:
ret += string(c)
position += cSize
}
}
return ret
}

78
server/platform/shared/markdown/inspect.go Обычный файл
Просмотреть файл

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
// Inspect traverses the markdown tree in depth-first order. If f returns true, Inspect invokes f
// recursively for each child of the block or inline, followed by a call of f(nil).
func Inspect(markdown string, f func(any) bool) {
document, referenceDefinitions := Parse(markdown)
InspectBlock(document, func(block Block) bool {
if !f(block) {
return false
}
switch v := block.(type) {
case *Paragraph:
for _, inline := range MergeInlineText(v.ParseInlines(referenceDefinitions)) {
InspectInline(inline, func(inline Inline) bool {
return f(inline)
})
}
}
return true
})
}
// InspectBlock traverses the blocks in depth-first order, starting with block. If f returns true,
// InspectBlock invokes f recursively for each child of the block, followed by a call of f(nil).
func InspectBlock(block Block, f func(Block) bool) {
if !f(block) {
return
}
switch v := block.(type) {
case *Document:
for _, child := range v.Children {
InspectBlock(child, f)
}
case *List:
for _, child := range v.Children {
InspectBlock(child, f)
}
case *ListItem:
for _, child := range v.Children {
InspectBlock(child, f)
}
case *BlockQuote:
for _, child := range v.Children {
InspectBlock(child, f)
}
}
f(nil)
}
// InspectInline traverses the blocks in depth-first order, starting with block. If f returns true,
// InspectInline invokes f recursively for each child of the block, followed by a call of f(nil).
func InspectInline(inline Inline, f func(Inline) bool) {
if !f(inline) {
return
}
switch v := inline.(type) {
case *InlineImage:
for _, child := range v.Children {
InspectInline(child, f)
}
case *InlineLink:
for _, child := range v.Children {
InspectInline(child, f)
}
case *ReferenceImage:
for _, child := range v.Children {
InspectInline(child, f)
}
case *ReferenceLink:
for _, child := range v.Children {
InspectInline(child, f)
}
}
f(nil)
}

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

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestInspect(t *testing.T) {
markdown := `
[foo]: bar
- a
> [![]()]()
> [![foo]][foo]
- d
`
visited := []string{}
level := 0
Inspect(markdown, func(blockOrInline any) bool {
if blockOrInline == nil {
level--
} else {
visited = append(visited, strings.Repeat(" ", level*4)+strings.TrimPrefix(fmt.Sprintf("%T", blockOrInline), "*markdown."))
level++
}
return true
})
assert.Equal(t, []string{
"Document",
" Paragraph",
" List",
" ListItem",
" Paragraph",
" Text",
" BlockQuote",
" Paragraph",
" InlineLink",
" InlineImage",
" SoftLineBreak",
" ReferenceLink",
" ReferenceImage",
" Text",
" ListItem",
" Paragraph",
" Text",
}, visited)
}
var counterSink int
func BenchmarkInspect(b *testing.B) {
text := `Some standard piece of text.
Has a link [post](https://github.com) and also has a blockquote.
> This is a famous quote.
Some bold text **Text for markdown?** to go with it.
At the end, some more lines`
for i := 0; i < b.N; i++ {
Inspect(text, func(_ any) bool {
counterSink++
return true
})
}
}

32
server/platform/shared/markdown/lines.go Обычный файл
Просмотреть файл

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
)
type Line struct {
Range
}
func ParseLines(markdown string) []Line {
lineStartPosition := 0
isAfterCarriageReturn := false
lines := make([]Line, 0, strings.Count(markdown, "\n"))
for position, r := range markdown {
if r == '\n' {
lines = append(lines, Line{Range{lineStartPosition, position + 1}})
lineStartPosition = position + 1
} else if isAfterCarriageReturn {
lines = append(lines, Line{Range{lineStartPosition, position}})
lineStartPosition = position
}
isAfterCarriageReturn = r == '\r'
}
if lineStartPosition < len(markdown) {
lines = append(lines, Line{Range{lineStartPosition, len(markdown)}})
}
return lines
}

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

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseLines(t *testing.T) {
assert.Equal(t, []Line{
{Range{0, 4}}, {Range{4, 7}},
}, ParseLines("foo\nbar"))
assert.Equal(t, []Line{
{Range{0, 5}}, {Range{5, 8}},
}, ParseLines("foo\r\nbar"))
assert.Equal(t, []Line{
{Range{0, 4}}, {Range{4, 6}}, {Range{6, 9}},
}, ParseLines("foo\r\r\nbar"))
assert.Equal(t, []Line{
{Range{0, 4}},
}, ParseLines("foo\n"))
assert.Equal(t, []Line{
{Range{0, 4}},
}, ParseLines("foo\r"))
assert.Equal(t, []Line{
{Range{0, 5}},
}, ParseLines("foo\r\n"))
}

184
server/platform/shared/markdown/links.go Обычный файл
Просмотреть файл

@@ -0,0 +1,184 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"unicode/utf8"
)
func parseLinkDestination(markdown string, position int) (raw Range, next int, ok bool) {
if position >= len(markdown) {
return
}
if markdown[position] == '<' {
isEscaped := false
for offset, c := range []byte(markdown[position+1:]) {
if isEscaped {
isEscaped = false
if isEscapableByte(c) {
continue
}
}
if c == '\\' {
isEscaped = true
} else if c == '<' {
break
} else if c == '>' {
return Range{position + 1, position + 1 + offset}, position + 1 + offset + 1, true
} else if isWhitespaceByte(c) {
break
}
}
}
openCount := 0
isEscaped := false
for offset, c := range []byte(markdown[position:]) {
if isEscaped {
isEscaped = false
if isEscapableByte(c) {
continue
}
}
switch c {
case '\\':
isEscaped = true
case '(':
openCount++
case ')':
if openCount < 1 {
return Range{position, position + offset}, position + offset, true
}
openCount--
default:
if isWhitespaceByte(c) {
return Range{position, position + offset}, position + offset, true
}
}
}
return Range{position, len(markdown)}, len(markdown), true
}
func parseLinkTitle(markdown string, position int) (raw Range, next int, ok bool) {
if position >= len(markdown) {
return
}
originalPosition := position
var closer byte
switch markdown[position] {
case '"', '\'':
closer = markdown[position]
case '(':
closer = ')'
default:
return
}
position++
for position < len(markdown) {
switch markdown[position] {
case '\\':
position++
if position < len(markdown) && isEscapableByte(markdown[position]) {
position++
}
case closer:
return Range{originalPosition + 1, position}, position + 1, true
default:
position++
}
}
return
}
func parseLinkLabel(markdown string, position int) (raw Range, next int, ok bool) {
if position >= len(markdown) || markdown[position] != '[' {
return
}
originalPosition := position
position++
for position < len(markdown) {
switch markdown[position] {
case '\\':
position++
if position < len(markdown) && isEscapableByte(markdown[position]) {
position++
}
case '[':
return
case ']':
if position-originalPosition >= 1000 && utf8.RuneCountInString(markdown[originalPosition:position]) >= 1000 {
return
}
return Range{originalPosition + 1, position}, position + 1, true
default:
position++
}
}
return
}
// As a non-standard feature, we allow image links to specify dimensions of the image by adding "=WIDTHxHEIGHT"
// after the image destination but before the image title like ![alt](http://example.com/image.png =100x200 "title").
// Both width and height are optional, but at least one of them must be specified.
func parseImageDimensions(markdown string, position int) (raw Range, next int, ok bool) {
if position >= len(markdown) {
return
}
originalPosition := position
// Read =
position += 1
if position >= len(markdown) {
return
}
// Read width
hasWidth := false
for position < len(markdown)-1 && isNumericByte(markdown[position]) {
hasWidth = true
position += 1
}
// Look for early end of dimensions
if isWhitespaceByte(markdown[position]) || markdown[position] == ')' {
return Range{originalPosition, position - 1}, position, true
}
// Read the x
if (markdown[position] != 'x' && markdown[position] != 'X') || position == len(markdown)-1 {
return
}
position += 1
// Read height
hasHeight := false
for position < len(markdown)-1 && isNumericByte(markdown[position]) {
hasHeight = true
position += 1
}
// Make sure the there's no trailing characters
if !isWhitespaceByte(markdown[position]) && markdown[position] != ')' {
return
}
if !hasWidth && !hasHeight {
// At least one of width or height is required
return
}
return Range{originalPosition, position - 1}, position, true
}

244
server/platform/shared/markdown/links_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,244 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseImageDimensions(t *testing.T) {
for name, tc := range map[string]struct {
Input string
Position int
ExpectedRange Range
ExpectedNext int
ExpectedOk bool
}{
"no dimensions, no title": {
Input: `![alt](https://example.com)`,
Position: 26,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"no dimensions, title": {
Input: `![alt](https://example.com "title")`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"only width, no title": {
Input: `![alt](https://example.com =100)`,
Position: 27,
ExpectedRange: Range{27, 30},
ExpectedNext: 31,
ExpectedOk: true,
},
"only width, title": {
Input: `![alt](https://example.com =100 "title")`,
Position: 27,
ExpectedRange: Range{27, 30},
ExpectedNext: 31,
ExpectedOk: true,
},
"only height, no title": {
Input: `![alt](https://example.com =x100)`,
Position: 27,
ExpectedRange: Range{27, 31},
ExpectedNext: 32,
ExpectedOk: true,
},
"only height, title": {
Input: `![alt](https://example.com =x100 "title")`,
Position: 27,
ExpectedRange: Range{27, 31},
ExpectedNext: 32,
ExpectedOk: true,
},
"dimensions, no title": {
Input: `![alt](https://example.com =100x200)`,
Position: 27,
ExpectedRange: Range{27, 34},
ExpectedNext: 35,
ExpectedOk: true,
},
"dimensions, title": {
Input: `![alt](https://example.com =100x200 "title")`,
Position: 27,
ExpectedRange: Range{27, 34},
ExpectedNext: 35,
ExpectedOk: true,
},
"no dimensions, no title, trailing whitespace": {
Input: `![alt](https://example.com )`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"only width, no title, trailing whitespace": {
Input: `![alt](https://example.com =100 )`,
Position: 28,
ExpectedRange: Range{28, 31},
ExpectedNext: 32,
ExpectedOk: true,
},
"only height, no title, trailing whitespace": {
Input: `![alt](https://example.com =x100 )`,
Position: 29,
ExpectedRange: Range{29, 33},
ExpectedNext: 34,
ExpectedOk: true,
},
"dimensions, no title, trailing whitespace": {
Input: `![alt](https://example.com =100x200 )`,
Position: 30,
ExpectedRange: Range{30, 37},
ExpectedNext: 38,
ExpectedOk: true,
},
"no width or height": {
Input: `![alt](https://example.com =x)`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"garbage 1": {
Input: `![alt](https://example.com =aaa)`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"garbage 2": {
Input: `![alt](https://example.com ====)`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"garbage 3": {
Input: `![alt](https://example.com =100xx200)`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"garbage 4": {
Input: `![alt](https://example.com =100x200x300x400)`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"garbage 5": {
Input: `![alt](https://example.com =100x200`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"garbage 6": {
Input: `![alt](https://example.com =100x`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
"garbage 7": {
Input: `![alt](https://example.com =x200`,
Position: 27,
ExpectedRange: Range{0, 0},
ExpectedNext: 0,
ExpectedOk: false,
},
} {
t.Run(name, func(t *testing.T) {
raw, next, ok := parseImageDimensions(tc.Input, tc.Position)
assert.Equal(t, tc.ExpectedOk, ok)
assert.Equal(t, tc.ExpectedNext, next)
assert.Equal(t, tc.ExpectedRange, raw)
})
}
}
func TestImageLinksWithDimensions(t *testing.T) {
for name, tc := range map[string]struct {
Markdown string
ExpectedHTML string
}{
"regular link": {
Markdown: `[link](https://example.com)`,
ExpectedHTML: `<p><a href="https://example.com">link</a></p>`,
},
"image link": {
Markdown: `![image](https://example.com/image.png)`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" /></p>`,
},
"image link with title": {
Markdown: `![image](https://example.com/image.png "title")`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
},
"image link with bracketed title": {
Markdown: `![image](https://example.com/image.png (title))`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
},
"image link with width": {
Markdown: `![image](https://example.com/image.png =500)`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" /></p>`,
},
"image link with width and title": {
Markdown: `![image](https://example.com/image.png =500 "title")`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
},
"image link with width and bracketed title": {
Markdown: `![image](https://example.com/image.png =500 (title))`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
},
"image link with height": {
Markdown: `![image](https://example.com/image.png =x500)`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" /></p>`,
},
"image link with height and title": {
Markdown: `![image](https://example.com/image.png =x500 "title")`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
},
"image link with height and bracketed title": {
Markdown: `![image](https://example.com/image.png =x500 (title))`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
},
"image link with dimensions": {
Markdown: `![image](https://example.com/image.png =500x400)`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" /></p>`,
},
"image link with dimensions and title": {
Markdown: `![image](https://example.com/image.png =500x400 "title")`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
},
"image link with dimensions and bracketed title": {
Markdown: `![image](https://example.com/image.png =500x400 (title))`,
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
},
"no image link 1": {
Markdown: `![image]()`,
ExpectedHTML: `<p><img src="" alt="image" /></p>`,
},
"no image link 2": {
Markdown: `![image]( )`,
ExpectedHTML: `<p><img src="" alt="image" /></p>`,
},
"no image link with dimensions": {
Markdown: `![image]( =500x400)`,
ExpectedHTML: `<p><img src="=500x400" alt="image" /></p>`,
},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.ExpectedHTML, RenderHTML(tc.Markdown))
})
}
}

220
server/platform/shared/markdown/list.go Обычный файл
Просмотреть файл

@@ -0,0 +1,220 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
)
type ListItem struct {
blockBase
markdown string
hasTrailingBlankLine bool
hasBlankLineBetweenChildren bool
Indentation int
Children []Block
}
func (b *ListItem) Continuation(indentation int, r Range) *continuation {
s := b.markdown[r.Position:r.End]
if strings.TrimSpace(s) == "" {
if b.Children == nil {
return nil
}
return &continuation{
Remaining: r,
}
}
if indentation < b.Indentation {
return nil
}
return &continuation{
Indentation: indentation - b.Indentation,
Remaining: r,
}
}
func (b *ListItem) AddChild(openBlocks []Block) []Block {
b.Children = append(b.Children, openBlocks[0])
if b.hasTrailingBlankLine {
b.hasBlankLineBetweenChildren = true
}
b.hasTrailingBlankLine = false
return openBlocks
}
func (b *ListItem) AddLine(indentation int, r Range) bool {
isBlank := strings.TrimSpace(b.markdown[r.Position:r.End]) == ""
if isBlank {
b.hasTrailingBlankLine = true
}
return false
}
func (b *ListItem) HasTrailingBlankLine() bool {
return b.hasTrailingBlankLine || (len(b.Children) > 0 && b.Children[len(b.Children)-1].HasTrailingBlankLine())
}
func (b *ListItem) isLoose() bool {
if b.hasBlankLineBetweenChildren {
return true
}
for i, child := range b.Children {
if i < len(b.Children)-1 && child.HasTrailingBlankLine() {
return true
}
}
return false
}
type List struct {
blockBase
markdown string
hasTrailingBlankLine bool
hasBlankLineBetweenChildren bool
IsLoose bool
IsOrdered bool
OrderedStart int
BulletOrDelimiter byte
Children []*ListItem
}
func (b *List) Continuation(indentation int, r Range) *continuation {
s := b.markdown[r.Position:r.End]
if strings.TrimSpace(s) == "" {
return &continuation{
Remaining: r,
}
}
return &continuation{
Indentation: indentation,
Remaining: r,
}
}
func (b *List) AddChild(openBlocks []Block) []Block {
if item, ok := openBlocks[0].(*ListItem); ok {
b.Children = append(b.Children, item)
if b.hasTrailingBlankLine {
b.hasBlankLineBetweenChildren = true
}
b.hasTrailingBlankLine = false
return openBlocks
} else if list, ok := openBlocks[0].(*List); ok {
if len(list.Children) == 1 && list.IsOrdered == b.IsOrdered && list.BulletOrDelimiter == b.BulletOrDelimiter {
return b.AddChild(openBlocks[1:])
}
}
return nil
}
func (b *List) AddLine(indentation int, r Range) bool {
isBlank := strings.TrimSpace(b.markdown[r.Position:r.End]) == ""
if isBlank {
b.hasTrailingBlankLine = true
}
return false
}
func (b *List) HasTrailingBlankLine() bool {
return b.hasTrailingBlankLine || (len(b.Children) > 0 && b.Children[len(b.Children)-1].HasTrailingBlankLine())
}
func (b *List) isLoose() bool {
if b.hasBlankLineBetweenChildren {
return true
}
for i, child := range b.Children {
if child.isLoose() || (i < len(b.Children)-1 && child.HasTrailingBlankLine()) {
return true
}
}
return false
}
func (b *List) Close() {
b.IsLoose = b.isLoose()
}
func parseListMarker(markdown string, r Range) (success, isOrdered bool, orderedStart int, bulletOrDelimiter byte, markerWidth int, remaining Range) {
digits := 0
n := 0
for i := r.Position; i < r.End && markdown[i] >= '0' && markdown[i] <= '9'; i++ {
digits++
n = n*10 + int(markdown[i]-'0')
}
if digits > 0 {
if digits > 9 || r.Position+digits >= r.End {
return
}
next := markdown[r.Position+digits]
if next != '.' && next != ')' {
return
}
return true, true, n, next, digits + 1, Range{r.Position + digits + 1, r.End}
}
if r.Position >= r.End {
return
}
next := markdown[r.Position]
if next != '-' && next != '+' && next != '*' {
return
}
return true, false, 0, next, 1, Range{r.Position + 1, r.End}
}
func listStart(markdown string, indent int, r Range, matchedBlocks, unmatchedBlocks []Block) []Block {
afterList := false
if len(matchedBlocks) > 0 {
_, afterList = matchedBlocks[len(matchedBlocks)-1].(*List)
}
if !afterList && indent > 3 {
return nil
}
success, isOrdered, orderedStart, bulletOrDelimiter, markerWidth, remaining := parseListMarker(markdown, r)
if !success {
return nil
}
isBlank := strings.TrimSpace(markdown[remaining.Position:remaining.End]) == ""
if len(matchedBlocks) > 0 && len(unmatchedBlocks) == 0 {
if _, ok := matchedBlocks[len(matchedBlocks)-1].(*Paragraph); ok {
if isBlank || (isOrdered && orderedStart != 1) {
return nil
}
}
}
indentAfterMarker, indentBytesAfterMarker := countIndentation(markdown, remaining)
if !isBlank && indentAfterMarker < 1 {
return nil
}
remaining = Range{remaining.Position + indentBytesAfterMarker, remaining.End}
consumedIndentAfterMarker := indentAfterMarker
if isBlank || indentAfterMarker >= 5 {
consumedIndentAfterMarker = 1
}
listItem := &ListItem{
markdown: markdown,
Indentation: indent + markerWidth + consumedIndentAfterMarker,
}
list := &List{
markdown: markdown,
IsOrdered: isOrdered,
OrderedStart: orderedStart,
BulletOrDelimiter: bulletOrDelimiter,
Children: []*ListItem{listItem},
}
ret := []Block{list, listItem}
if descendants := blockStartOrParagraph(markdown, indentAfterMarker-consumedIndentAfterMarker, remaining, nil, nil); descendants != nil {
listItem.Children = append(listItem.Children, descendants[0])
ret = append(ret, descendants...)
}
return ret
}

147
server/platform/shared/markdown/markdown.go Обычный файл
Просмотреть файл

@@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This package implements a parser for the subset of the CommonMark spec necessary for us to do
// server-side processing. It is not a full implementation and lacks many features. But it is
// complete enough to efficiently and accurately allow us to do what we need to like rewrite image
// URLs for proxying.
package markdown
import (
"strings"
)
func isEscapable(c rune) bool {
return c > ' ' && (c < '0' || (c > '9' && (c < 'A' || (c > 'Z' && (c < 'a' || (c > 'z' && c <= '~'))))))
}
func isEscapableByte(c byte) bool {
return isEscapable(rune(c))
}
func isWhitespace(c rune) bool {
switch c {
case ' ', '\t', '\n', '\u000b', '\u000c', '\r':
return true
}
return false
}
func isWhitespaceByte(c byte) bool {
return isWhitespace(rune(c))
}
func isNumeric(c rune) bool {
return c >= '0' && c <= '9'
}
func isNumericByte(c byte) bool {
return isNumeric(rune(c))
}
func isHex(c rune) bool {
return isNumeric(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func isHexByte(c byte) bool {
return isHex(rune(c))
}
func isAlphanumeric(c rune) bool {
return isNumeric(c) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
func isAlphanumericByte(c byte) bool {
return isAlphanumeric(rune(c))
}
func nextNonWhitespace(markdown string, position int) int {
for offset, c := range []byte(markdown[position:]) {
if !isWhitespaceByte(c) {
return position + offset
}
}
return len(markdown)
}
func nextLine(markdown string, position int) (linePosition int, skippedNonWhitespace bool) {
for i := position; i < len(markdown); i++ {
c := markdown[i]
if c == '\r' {
if i+1 < len(markdown) && markdown[i+1] == '\n' {
return i + 2, skippedNonWhitespace
}
return i + 1, skippedNonWhitespace
} else if c == '\n' {
return i + 1, skippedNonWhitespace
} else if !isWhitespaceByte(c) {
skippedNonWhitespace = true
}
}
return len(markdown), skippedNonWhitespace
}
func countIndentation(markdown string, r Range) (spaces, bytes int) {
for i := r.Position; i < r.End; i++ {
if markdown[i] == ' ' {
spaces++
bytes++
} else if markdown[i] == '\t' {
spaces += 4
bytes++
} else {
break
}
}
return
}
func trimLeftSpace(markdown string, r Range) Range {
s := markdown[r.Position:r.End]
trimmed := strings.TrimLeftFunc(s, isWhitespace)
return Range{r.Position, r.End - (len(s) - len(trimmed))}
}
func trimRightSpace(markdown string, r Range) Range {
s := markdown[r.Position:r.End]
trimmed := strings.TrimRightFunc(s, isWhitespace)
return Range{r.Position, r.End - (len(s) - len(trimmed))}
}
func relativeToAbsolutePosition(ranges []Range, position int) int {
rem := position
for _, r := range ranges {
l := r.End - r.Position
if rem < l {
return r.Position + rem
}
rem -= l
}
if len(ranges) == 0 {
return 0
}
return ranges[len(ranges)-1].End
}
func trimBytesFromRanges(ranges []Range, bytes int) (result []Range) {
rem := bytes
for _, r := range ranges {
if rem == 0 {
result = append(result, r)
continue
}
l := r.End - r.Position
if rem < l {
result = append(result, Range{r.Position + rem, r.End})
rem = 0
continue
}
rem -= l
}
return
}
func Parse(markdown string) (*Document, []*ReferenceDefinition) {
lines := ParseLines(markdown)
return ParseBlocks(markdown, lines)
}

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

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
)
type Paragraph struct {
blockBase
markdown string
Text []Range
ReferenceDefinitions []*ReferenceDefinition
}
func (b *Paragraph) ParseInlines(referenceDefinitions []*ReferenceDefinition) []Inline {
return ParseInlines(b.markdown, b.Text, referenceDefinitions)
}
func (b *Paragraph) Continuation(indentation int, r Range) *continuation {
s := b.markdown[r.Position:r.End]
if strings.TrimSpace(s) == "" {
return nil
}
return &continuation{
Indentation: indentation,
Remaining: r,
}
}
func (b *Paragraph) Close() {
for {
for i := 0; i < len(b.Text); i++ {
b.Text[i] = trimLeftSpace(b.markdown, b.Text[i])
if b.Text[i].Position < b.Text[i].End {
break
}
}
if len(b.Text) == 0 || b.Text[0].Position < b.Text[0].End && b.markdown[b.Text[0].Position] != '[' {
break
}
definition, remaining := parseReferenceDefinition(b.markdown, b.Text)
if definition == nil {
break
}
b.ReferenceDefinitions = append(b.ReferenceDefinitions, definition)
b.Text = remaining
}
for i := len(b.Text) - 1; i >= 0; i-- {
b.Text[i] = trimRightSpace(b.markdown, b.Text[i])
if b.Text[i].Position < b.Text[i].End {
break
}
}
}
func newParagraph(markdown string, r Range) *Paragraph {
s := markdown[r.Position:r.End]
if strings.TrimSpace(s) == "" {
return nil
}
return &Paragraph{
markdown: markdown,
Text: []Range{r},
}
}

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

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
type ReferenceDefinition struct {
RawDestination Range
markdown string
rawLabel string
rawTitle string
}
func (d *ReferenceDefinition) Destination() string {
return Unescape(d.markdown[d.RawDestination.Position:d.RawDestination.End])
}
func (d *ReferenceDefinition) Label() string {
return d.rawLabel
}
func (d *ReferenceDefinition) Title() string {
return Unescape(d.rawTitle)
}
func parseReferenceDefinition(markdown string, ranges []Range) (*ReferenceDefinition, []Range) {
raw := ""
for _, r := range ranges {
raw += markdown[r.Position:r.End]
}
label, next, ok := parseLinkLabel(raw, 0)
if !ok {
return nil, nil
}
position := next
if position >= len(raw) || raw[position] != ':' {
return nil, nil
}
position++
destination, next, ok := parseLinkDestination(raw, nextNonWhitespace(raw, position))
if !ok {
return nil, nil
}
position = next
absoluteDestination := relativeToAbsolutePosition(ranges, destination.Position)
ret := &ReferenceDefinition{
RawDestination: Range{absoluteDestination, absoluteDestination + destination.End - destination.Position},
markdown: markdown,
rawLabel: raw[label.Position:label.End],
}
if position < len(raw) && isWhitespaceByte(raw[position]) {
title, next, ok := parseLinkTitle(raw, nextNonWhitespace(raw, position))
if !ok {
if nextLine, skippedNonWhitespace := nextLine(raw, position); !skippedNonWhitespace {
return ret, trimBytesFromRanges(ranges, nextLine)
}
return nil, nil
}
if nextLine, skippedNonWhitespace := nextLine(raw, next); !skippedNonWhitespace {
ret.rawTitle = raw[title.Position:title.End]
return ret, trimBytesFromRanges(ranges, nextLine)
}
}
if nextLine, skippedNonWhitespace := nextLine(raw, position); !skippedNonWhitespace {
return ret, trimBytesFromRanges(ranges, nextLine)
}
return nil, nil
}

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

@@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTextRanges(t *testing.T) {
for name, tc := range map[string]struct {
Markdown string
ExpectedRanges []Range
ExpectedValues []string
}{
"simple": {
Markdown: "hello",
ExpectedRanges: []Range{{0, 5}},
ExpectedValues: []string{"hello"},
},
"simple2": {
Markdown: "hello!",
ExpectedRanges: []Range{{0, 6}},
ExpectedValues: []string{"hello!"},
},
"multiline": {
Markdown: "hello world\nfoobar",
ExpectedRanges: []Range{{0, 11}, {12, 18}},
ExpectedValues: []string{"hello world", "foobar"},
},
"code": {
Markdown: "hello `code` world",
ExpectedRanges: []Range{{0, 6}, {12, 18}},
ExpectedValues: []string{"hello ", " world"},
},
"notcode": {
Markdown: "hello ` world",
ExpectedRanges: []Range{{0, 13}},
ExpectedValues: []string{"hello ` world"},
},
"escape": {
Markdown: "\\*hello\\*",
ExpectedRanges: []Range{{1, 7}, {8, 9}},
ExpectedValues: []string{"*hello", "*"},
},
"escapeescape": {
Markdown: "\\\\",
ExpectedRanges: []Range{{1, 2}},
ExpectedValues: []string{"\\"},
},
"notescape": {
Markdown: "foo\\x",
ExpectedRanges: []Range{{0, 5}},
ExpectedValues: []string{"foo\\x"},
},
"notlink": {
Markdown: "[foo",
ExpectedRanges: []Range{{0, 4}},
ExpectedValues: []string{"[foo"},
},
"notlinkend": {
Markdown: "[foo]",
ExpectedRanges: []Range{{0, 5}},
ExpectedValues: []string{"[foo]"},
},
"notimage": {
Markdown: "![foo",
ExpectedRanges: []Range{{0, 5}},
ExpectedValues: []string{"![foo"},
},
"notimage2": {
Markdown: "!foo",
ExpectedRanges: []Range{{0, 4}},
ExpectedValues: []string{"!foo"},
},
"charref": {
Markdown: "&quot;test",
ExpectedRanges: []Range{{0, 1}, {6, 10}},
ExpectedValues: []string{"\"", "test"},
},
"notcharref": {
Markdown: "&amp test",
ExpectedRanges: []Range{{0, 9}},
ExpectedValues: []string{"&amp test"},
},
"notcharref2": {
Markdown: "this is &mattermost;",
ExpectedRanges: []Range{{0, 20}},
ExpectedValues: []string{"this is &mattermost;"},
},
"standalone-ampersand": {
Markdown: "Hello & World",
ExpectedRanges: []Range{{0, 13}},
ExpectedValues: []string{"Hello & World"},
},
} {
t.Run(name, func(t *testing.T) {
var ranges []Range
var values []string
Inspect(tc.Markdown, func(node any) bool {
if textNode, ok := node.(*Text); ok {
ranges = append(ranges, textNode.Range)
values = append(values, textNode.Text)
}
return true
})
assert.Equal(t, tc.ExpectedRanges, ranges)
assert.Equal(t, tc.ExpectedValues, values)
})
}
}

137
server/platform/shared/mfa/mfa.go Обычный файл
Просмотреть файл

@@ -0,0 +1,137 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mfa
import (
"crypto/rand"
"encoding/base32"
"fmt"
"net/url"
"strings"
"github.com/dgryski/dgoogauth"
"github.com/mattermost/rsc/qr"
"github.com/pkg/errors"
)
// InvalidToken indicates the case where the token validation has failed.
var InvalidToken = errors.New("invalid mfa token")
const (
// This will result in 160 bits of entropy (base32 encoded), as recommended by rfc4226.
mfaSecretSize = 20
)
type Store interface {
UpdateMfaActive(userId string, active bool) error
UpdateMfaSecret(userId, secret string) error
}
type MFA struct {
store Store
}
func New(store Store) *MFA {
return &MFA{store}
}
// newRandomBase32String returns a base32 encoded string of a random slice
// of bytes of the given size. The resulting entropy will be (8 * size) bits.
func newRandomBase32String(size int) string {
data := make([]byte, size)
rand.Read(data)
return base32.StdEncoding.EncodeToString(data)
}
func getIssuerFromURL(uri string) string {
issuer := "Mattermost"
siteURL := strings.TrimSpace(uri)
if siteURL != "" {
siteURL = strings.TrimPrefix(siteURL, "https://")
siteURL = strings.TrimPrefix(siteURL, "http://")
issuer = strings.TrimPrefix(siteURL, "www.")
}
return url.QueryEscape(issuer)
}
// GenerateSecret generates a new user mfa secret and store it with the StoreSecret function provided
func (m *MFA) GenerateSecret(siteURL, userEmail, userID string) (string, []byte, error) {
issuer := getIssuerFromURL(siteURL)
secret := newRandomBase32String(mfaSecretSize)
authLink := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", issuer, userEmail, secret, issuer)
code, err := qr.Encode(authLink, qr.H)
if err != nil {
return "", nil, errors.Wrap(err, "unable to generate qr code")
}
img := code.PNG()
if err := m.store.UpdateMfaSecret(userID, secret); err != nil {
return "", nil, errors.Wrap(err, "unable to store mfa secret")
}
return secret, img, nil
}
// Activate set the mfa as active and store it with the StoreActive function provided
func (m *MFA) Activate(userMfaSecret, userID string, token string) error {
otpConfig := &dgoogauth.OTPConfig{
Secret: userMfaSecret,
WindowSize: 3,
HotpCounter: 0,
}
trimmedToken := strings.TrimSpace(token)
ok, err := otpConfig.Authenticate(trimmedToken)
if err != nil {
return errors.Wrap(err, "unable to parse the token")
}
if !ok {
return InvalidToken
}
if err := m.store.UpdateMfaActive(userID, true); err != nil {
return errors.Wrap(err, "unable to store mfa active")
}
return nil
}
// Deactivate set the mfa as deactivated, remove the mfa secret, store it with the StoreActive and StoreSecret functions provided
func (m *MFA) Deactivate(userId string) error {
if err := m.store.UpdateMfaActive(userId, false); err != nil {
return errors.Wrap(err, "unable to store mfa active")
}
if err := m.store.UpdateMfaSecret(userId, ""); err != nil {
return errors.Wrap(err, "unable to store mfa secret")
}
return nil
}
// Validate the provide token using the secret provided
func (m *MFA) ValidateToken(secret, token string) (bool, error) {
otpConfig := &dgoogauth.OTPConfig{
Secret: secret,
WindowSize: 3,
HotpCounter: 0,
}
trimmedToken := strings.TrimSpace(token)
ok, err := otpConfig.Authenticate(trimmedToken)
if err != nil {
return false, errors.Wrap(err, "unable to parse the token")
}
return ok, nil
}

172
server/platform/shared/mfa/mfa_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,172 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mfa
import (
"encoding/base32"
"errors"
"fmt"
"net/url"
"testing"
"time"
"github.com/dgryski/dgoogauth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
"github.com/mattermost/mattermost-server/v6/server/channels/store/storetest/mocks"
)
func TestGenerateSecret(t *testing.T) {
userID := "user-id"
userEmail := "sample@sample.com"
siteURL := "http://localhost:8065"
t.Run("fail on store action fail", func(t *testing.T) {
storeMock := mocks.UserStore{}
storeMock.On("UpdateMfaSecret", userID, mock.AnythingOfType("string")).Return(func(userId string, secret string) error {
return errors.New("failed to update mfa secret")
})
_, _, err := New(&storeMock).GenerateSecret(siteURL, userEmail, userID)
require.Error(t, err)
require.Contains(t, err.Error(), "unable to store mfa secret")
})
t.Run("Successful generate secret", func(t *testing.T) {
storeMock := mocks.UserStore{}
storeMock.On("UpdateMfaSecret", userID, mock.AnythingOfType("string")).Return(func(userId string, secret string) error {
return nil
})
secret, img, err := New(&storeMock).GenerateSecret(siteURL, userEmail, userID)
require.NoError(t, err)
assert.Len(t, secret, 32)
require.NotEmpty(t, img, "no image set")
})
}
func TestGetIssuerFromURL(t *testing.T) {
cases := []struct {
Input string
Expected string
}{
{"http://somewebsite.com", url.QueryEscape("somewebsite.com")},
{"https://somewebsite.com", url.QueryEscape("somewebsite.com")},
{"https://some.website.com", url.QueryEscape("some.website.com")},
{" https://www.somewebsite.com", url.QueryEscape("somewebsite.com")},
{"http://somewebsite.com/chat", url.QueryEscape("somewebsite.com/chat")},
{"somewebsite.com ", url.QueryEscape("somewebsite.com")},
{"http://localhost:8065", url.QueryEscape("localhost:8065")},
{"", "Mattermost"},
{" ", "Mattermost"},
}
for _, c := range cases {
assert.Equal(t, c.Expected, getIssuerFromURL(c.Input))
}
}
func TestActivate(t *testing.T) {
userID := "user-id"
userMfaSecret := newRandomBase32String(mfaSecretSize)
token := dgoogauth.ComputeCode(userMfaSecret, time.Now().UTC().Unix()/30)
t.Run("fail on wrongly formatted token", func(t *testing.T) {
err := New(nil).Activate(userMfaSecret, userID, "invalid-token")
require.Error(t, err)
require.Contains(t, err.Error(), "unable to parse the token")
})
t.Run("fail on invalid token", func(t *testing.T) {
err := New(nil).Activate(userMfaSecret, userID, "000000")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid mfa token")
})
t.Run("fail on store action fail", func(t *testing.T) {
storeMock := mocks.UserStore{}
storeMock.On("UpdateMfaActive", userID, true).Return(func(userId string, active bool) error {
return errors.New("failed to update mfa active")
})
err := New(&storeMock).Activate(userMfaSecret, userID, fmt.Sprintf("%06d", token))
require.Error(t, err)
require.Contains(t, err.Error(), "unable to store mfa active")
})
t.Run("Successful activate", func(t *testing.T) {
storeMock := mocks.UserStore{}
storeMock.On("UpdateMfaActive", userID, true).Return(func(userId string, active bool) error {
return nil
})
err := New(&storeMock).Activate(userMfaSecret, userID, fmt.Sprintf("%06d", token))
require.NoError(t, err)
})
}
func TestDeactivate(t *testing.T) {
userID := "user-id"
t.Run("fail on store UpdateMfaActive action fail", func(t *testing.T) {
storeMock := mocks.UserStore{}
storeMock.On("UpdateMfaActive", userID, false).Return(func(userId string, active bool) error {
return errors.New("failed to update mfa active")
})
storeMock.On("UpdateMfaSecret", userID, "").Return(func(userId string, secret string) error {
return errors.New("failed to update mfa secret")
})
err := New(&storeMock).Deactivate(userID)
require.Error(t, err)
require.Contains(t, err.Error(), "unable to store mfa active")
})
t.Run("fail on store UpdateMfaSecret action fail", func(t *testing.T) {
storeMock := mocks.UserStore{}
storeMock.On("UpdateMfaActive", userID, false).Return(func(userId string, active bool) error {
return nil
})
storeMock.On("UpdateMfaSecret", userID, "").Return(func(userId string, secret string) error {
return errors.New("failed to update mfa secret")
})
err := New(&storeMock).Deactivate(userID)
require.Error(t, err)
require.Contains(t, err.Error(), "unable to store mfa secret")
})
t.Run("Successful deactivate", func(t *testing.T) {
storeMock := mocks.UserStore{}
storeMock.On("UpdateMfaActive", userID, false).Return(func(userId string, active bool) error {
return nil
})
storeMock.On("UpdateMfaSecret", userID, "").Return(func(userId string, secret string) error {
return nil
})
err := New(&storeMock).Deactivate(userID)
require.NoError(t, err)
})
}
func TestValidateToken(t *testing.T) {
t.Run("fail on wrongly formatted token", func(t *testing.T) {
secret := newRandomBase32String(mfaSecretSize)
ok, err := New(nil).ValidateToken(secret, "invalid-token")
require.Error(t, err)
require.False(t, ok)
require.Contains(t, err.Error(), "unable to parse the token")
})
}
func TestRandomBase32String(t *testing.T) {
for i := 0; i < 1000; i++ {
str := newRandomBase32String(i)
require.Len(t, str, base32.StdEncoding.EncodedLen(i))
}
}

63
server/platform/shared/mlog/default.go Обычный файл
Просмотреть файл

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import (
"bytes"
"encoding/json"
"fmt"
"os"
)
// defaultLog manually encodes the log to STDERR, providing a basic, default logging implementation
// before mlog is fully configured.
func defaultLog(level Level, msg string, fields ...Field) {
mFields := make(map[string]string)
buf := &bytes.Buffer{}
for _, fld := range fields {
buf.Reset()
fld.ValueString(buf, shouldQuote)
mFields[fld.Key] = buf.String()
}
log := struct {
Level string `json:"level"`
Message string `json:"msg"`
Fields map[string]string `json:"fields,omitempty"`
}{
level.Name,
msg,
mFields,
}
if b, err := json.Marshal(log); err != nil {
fmt.Fprintf(os.Stderr, `{"level":"error","msg":"failed to encode log message"}%s`, "\n")
} else {
fmt.Fprintf(os.Stderr, "%s\n", b)
}
}
func defaultIsLevelEnabled(level Level) bool {
return true
}
func defaultCustomMultiLog(lvl []Level, msg string, fields ...Field) {
for _, level := range lvl {
defaultLog(level, msg, fields...)
}
}
// shouldQuote returns true if val contains any characters that require quotations.
func shouldQuote(val string) bool {
for _, c := range val {
if !((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
c == '-' || c == '.' || c == '_' || c == '/' || c == '@' || c == '^' || c == '+') {
return true
}
}
return false
}

129
server/platform/shared/mlog/global.go Обычный файл
Просмотреть файл

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import (
"sync"
)
var (
globalLogger *Logger
muxGlobalLogger sync.RWMutex
)
func InitGlobalLogger(logger *Logger) {
muxGlobalLogger.Lock()
defer muxGlobalLogger.Unlock()
globalLogger = logger
}
func getGlobalLogger() *Logger {
muxGlobalLogger.RLock()
defer muxGlobalLogger.RUnlock()
return globalLogger
}
// IsLevelEnabled returns true only if at least one log target is
// configured to emit the specified log level. Use this check when
// gathering the log info may be expensive.
//
// Note, transformations and serializations done via fields are already
// lazily evaluated and don't require this check beforehand.
func IsLevelEnabled(level Level) bool {
logger := getGlobalLogger()
if logger == nil {
return defaultIsLevelEnabled(level)
}
return logger.IsLevelEnabled(level)
}
// Log emits the log record for any targets configured for the specified level.
func Log(level Level, msg string, fields ...Field) {
logger := getGlobalLogger()
if logger == nil {
defaultLog(level, msg, fields...)
return
}
logger.Log(level, msg, fields...)
}
// LogM emits the log record for any targets configured for the specified levels.
// Equivalent to calling `Log` once for each level.
func LogM(levels []Level, msg string, fields ...Field) {
logger := getGlobalLogger()
if logger == nil {
defaultCustomMultiLog(levels, msg, fields...)
return
}
logger.LogM(levels, msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Trace` level.
func Trace(msg string, fields ...Field) {
logger := getGlobalLogger()
if logger == nil {
defaultLog(LvlTrace, msg, fields...)
return
}
logger.Trace(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Debug` level.
func Debug(msg string, fields ...Field) {
logger := getGlobalLogger()
if logger == nil {
defaultLog(LvlDebug, msg, fields...)
return
}
logger.Debug(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Info` level.
func Info(msg string, fields ...Field) {
logger := getGlobalLogger()
if logger == nil {
defaultLog(LvlInfo, msg, fields...)
return
}
logger.Info(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Warn` level.
func Warn(msg string, fields ...Field) {
logger := getGlobalLogger()
if logger == nil {
defaultLog(LvlWarn, msg, fields...)
return
}
logger.Warn(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Error` level.
func Error(msg string, fields ...Field) {
logger := getGlobalLogger()
if logger == nil {
defaultLog(LvlError, msg, fields...)
return
}
logger.Error(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Critical` level.
// DEPRECATED: Either use Error or Fatal.
// Critical level isn't added in mlog/levels.go:StdAll so calling this doesn't
// really work. For now we just call Fatal to atleast print something.
func Critical(msg string, fields ...Field) {
Fatal(msg, fields...)
}
func Fatal(msg string, fields ...Field) {
logger := getGlobalLogger()
if logger == nil {
defaultLog(LvlFatal, msg, fields...)
return
}
logger.Fatal(msg, fields...)
}

138
server/platform/shared/mlog/global_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog_test
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func TestLoggingBeforeInitialized(t *testing.T) {
require.NotPanics(t, func() {
// None of these should segfault before mlog is globally configured
mlog.Info("info log")
mlog.Debug("debug log")
mlog.Warn("warning log")
mlog.Error("error log")
})
}
func TestLoggingAfterInitialized(t *testing.T) {
testCases := []struct {
description string
cfg mlog.TargetCfg
expectedLogs []string
}{
{
"file logging, json, debug",
mlog.TargetCfg{
Type: "file",
Format: "json",
FormatOptions: json.RawMessage(`{"enable_caller":true}`),
Levels: []mlog.Level{mlog.LvlError, mlog.LvlWarn, mlog.LvlInfo, mlog.LvlDebug},
},
[]string{
`{"timestamp":0,"level":"debug","msg":"real debug log","caller":"mlog/global_test.go:0"}`,
`{"timestamp":0,"level":"info","msg":"real info log","caller":"mlog/global_test.go:0"}`,
`{"timestamp":0,"level":"warn","msg":"real warning log","caller":"mlog/global_test.go:0"}`,
`{"timestamp":0,"level":"error","msg":"real error log","caller":"mlog/global_test.go:0"}`,
},
},
{
"file logging, json, error",
mlog.TargetCfg{
Type: "file",
Format: "json",
FormatOptions: json.RawMessage(`{"enable_caller":true}`),
Levels: []mlog.Level{mlog.LvlError},
},
[]string{
`{"timestamp":0,"level":"error","msg":"real error log","caller":"mlog/global_test.go:0"}`,
},
},
{
"file logging, non-json, debug",
mlog.TargetCfg{
Type: "file",
Format: "plain",
FormatOptions: json.RawMessage(`{"delim":" | ", "enable_caller":true}`),
Levels: []mlog.Level{mlog.LvlError, mlog.LvlWarn, mlog.LvlInfo, mlog.LvlDebug},
},
[]string{
`debug | TIME | real debug log | caller="mlog/global_test.go:0"`,
`info | TIME | real info log | caller="mlog/global_test.go:0"`,
`warn | TIME | real warning log | caller="mlog/global_test.go:0"`,
`error | TIME | real error log | caller="mlog/global_test.go:0"`,
},
},
{
"file logging, non-json, error",
mlog.TargetCfg{
Type: "file",
Format: "plain",
FormatOptions: json.RawMessage(`{"delim":" | ", "enable_caller":true}`),
Levels: []mlog.Level{mlog.LvlError},
},
[]string{
`error | TIME | real error log | caller="mlog/global_test.go:0"`,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
var filePath string
if testCase.cfg.Type == "file" {
tempDir, err := os.MkdirTemp(os.TempDir(), "TestLoggingAfterInitialized")
require.NoError(t, err)
defer os.Remove(tempDir)
filePath = filepath.Join(tempDir, "file.log")
testCase.cfg.Options = json.RawMessage(fmt.Sprintf(`{"filename": "%s"}`, filePath))
}
logger, _ := mlog.NewLogger()
err := logger.ConfigureTargets(map[string]mlog.TargetCfg{testCase.description: testCase.cfg}, nil)
require.NoError(t, err)
mlog.InitGlobalLogger(logger)
mlog.Debug("real debug log")
mlog.Info("real info log")
mlog.Warn("real warning log")
mlog.Error("real error log")
logger.Shutdown()
if testCase.cfg.Type == "file" {
logs, err := os.ReadFile(filePath)
require.NoError(t, err)
actual := strings.TrimSpace(string(logs))
if testCase.cfg.Format == "json" {
reTs := regexp.MustCompile(`"timestamp":"[0-9\.\-\+\:\sZ]+"`)
reCaller := regexp.MustCompile(`"caller":"([^"]+):[0-9\.]+"`)
actual = reTs.ReplaceAllString(actual, `"timestamp":0`)
actual = reCaller.ReplaceAllString(actual, `"caller":"$1:0"`)
} else {
reTs := regexp.MustCompile(`\[\d\d\d\d-\d\d-\d\d\s[0-9\:\.\s\-\+Z]+\]`)
reCaller := regexp.MustCompile(`caller="([^"]+):[0-9\.]+"`)
actual = reTs.ReplaceAllString(actual, "TIME")
actual = reCaller.ReplaceAllString(actual, `caller="$1:0"`)
}
require.ElementsMatch(t, testCase.expectedLogs, strings.Split(actual, "\n"))
}
})
}
}

23
server/platform/shared/mlog/graphql.go Обычный файл
Просмотреть файл

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import (
"context"
)
// GraphQLLogger is used to log panics that occur during query execution.
type GraphQLLogger struct {
logger *Logger
}
func NewGraphQLLogger(logger *Logger) *GraphQLLogger {
return &GraphQLLogger{logger: logger}
}
// LogPanic satisfies the graphql/log.Logger interface.
// It converts the panic into an error.
func (l *GraphQLLogger) LogPanic(_ context.Context, value any) {
l.logger.Error("Error while executing GraphQL query", Any("error", value))
}

58
server/platform/shared/mlog/levels.go Обычный файл
Просмотреть файл

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import "github.com/mattermost/logr/v2"
// Standard levels.
var (
LvlPanic = logr.Panic // ID = 0
LvlFatal = logr.Fatal // ID = 1
LvlError = logr.Error // ID = 2
LvlWarn = logr.Warn // ID = 3
LvlInfo = logr.Info // ID = 4
LvlDebug = logr.Debug // ID = 5
LvlTrace = logr.Trace // ID = 6
StdAll = []Level{LvlPanic, LvlFatal, LvlError, LvlWarn, LvlInfo, LvlDebug, LvlTrace, LvlStdLog}
// non-standard "critical" level
LvlCritical = Level{ID: 7, Name: "critical"}
// used by redirected standard logger
LvlStdLog = Level{ID: 10, Name: "stdlog"}
// used only by the logger
LvlLogError = Level{ID: 11, Name: "logerror", Stacktrace: true}
)
// Register custom (discrete) levels here.
// !!!!! Custom ID's must be between 20 and 32,768 !!!!!!
var (
// used by the audit system
LvlAuditAPI = Level{ID: 100, Name: "audit-api"}
LvlAuditContent = Level{ID: 101, Name: "audit-content"}
LvlAuditPerms = Level{ID: 102, Name: "audit-permissions"}
LvlAuditCLI = Level{ID: 103, Name: "audit-cli"}
// used by the TCP log target
LvlTCPLogTarget = Level{ID: 120, Name: "TcpLogTarget"}
// used by Remote Cluster Service
LvlRemoteClusterServiceDebug = Level{ID: 130, Name: "RemoteClusterServiceDebug"}
LvlRemoteClusterServiceError = Level{ID: 131, Name: "RemoteClusterServiceError"}
LvlRemoteClusterServiceWarn = Level{ID: 132, Name: "RemoteClusterServiceWarn"}
// used by Shared Channel Sync Service
LvlSharedChannelServiceDebug = Level{ID: 200, Name: "SharedChannelServiceDebug"}
LvlSharedChannelServiceError = Level{ID: 201, Name: "SharedChannelServiceError"}
LvlSharedChannelServiceWarn = Level{ID: 202, Name: "SharedChannelServiceWarn"}
LvlSharedChannelServiceMessagesInbound = Level{ID: 203, Name: "SharedChannelServiceMsgInbound"}
LvlSharedChannelServiceMessagesOutbound = Level{ID: 204, Name: "SharedChannelServiceMsgOutbound"}
// Focalboard
LvlFBTelemetry = Level{ID: 9000, Name: "telemetry"}
LvlFBMetrics = Level{ID: 9001, Name: "metrics"}
)
// Combinations for LogM (log multi).
var (
MLvlAuditAll = []Level{LvlAuditAPI, LvlAuditContent, LvlAuditPerms, LvlAuditCLI}
)

449
server/platform/shared/mlog/mlog.go Обычный файл
Просмотреть файл

@@ -0,0 +1,449 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Package mlog provides a simple wrapper around Logr.
package mlog
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"sync/atomic"
"time"
"github.com/mattermost/logr/v2"
logrcfg "github.com/mattermost/logr/v2/config"
)
const (
ShutdownTimeout = time.Second * 15
FlushTimeout = time.Second * 15
DefaultMaxQueueSize = 1000
DefaultMetricsUpdateFreqMillis = 15000
)
type LoggerIFace interface {
IsLevelEnabled(Level) bool
Trace(string, ...Field)
Debug(string, ...Field)
Info(string, ...Field)
Warn(string, ...Field)
Error(string, ...Field)
Critical(string, ...Field)
Fatal(string, ...Field)
Log(Level, string, ...Field)
LogM([]Level, string, ...Field)
With(fields ...Field) *Logger
Flush() error
StdLogger(level Level) *log.Logger
}
// Type and function aliases from Logr to limit the spread of dependencies.
type Field = logr.Field
type Level = logr.Level
type Option = logr.Option
type Target = logr.Target
type TargetInfo = logr.TargetInfo
type LogRec = logr.LogRec
type LogCloner = logr.LogCloner
type MetricsCollector = logr.MetricsCollector
type TargetCfg = logrcfg.TargetCfg
type TargetFactory = logrcfg.TargetFactory
type FormatterFactory = logrcfg.FormatterFactory
type Factories = logrcfg.Factories
type Sugar = logr.Sugar
// LoggerConfiguration is a map of LogTarget configurations.
type LoggerConfiguration map[string]TargetCfg
func (lc LoggerConfiguration) Append(cfg LoggerConfiguration) {
for k, v := range cfg {
lc[k] = v
}
}
func (lc LoggerConfiguration) toTargetCfg() map[string]logrcfg.TargetCfg {
tcfg := make(map[string]logrcfg.TargetCfg)
for k, v := range lc {
tcfg[k] = v
}
return tcfg
}
// Any picks the best supported field type based on type of val.
// For best performance when passing a struct (or struct pointer),
// implement `logr.LogWriter` on the struct, otherwise reflection
// will be used to generate a string representation.
var Any = logr.Any
// Int64 constructs a field containing a key and Int64 value.
var Int64 = logr.Int64
// Int32 constructs a field containing a key and Int32 value.
var Int32 = logr.Int32
// Int constructs a field containing a key and Int value.
var Int = logr.Int
// Uint64 constructs a field containing a key and Uint64 value.
var Uint64 = logr.Uint64
// Uint32 constructs a field containing a key and Uint32 value.
var Uint32 = logr.Uint32
// Uint constructs a field containing a key and Uint value.
var Uint = logr.Uint
// Float64 constructs a field containing a key and Float64 value.
var Float64 = logr.Float64
// Float32 constructs a field containing a key and Float32 value.
var Float32 = logr.Float32
// String constructs a field containing a key and String value.
var String = logr.String
// Stringer constructs a field containing a key and a fmt.Stringer value.
// The fmt.Stringer's `String` method is called lazily.
var Stringer = func(key string, s fmt.Stringer) logr.Field {
if s == nil {
return Field{Key: key, Type: logr.StringType, String: ""}
}
return Field{Key: key, Type: logr.StringType, String: s.String()}
}
// Err constructs a field containing a default key ("error") and error value.
var Err = func(err error) logr.Field {
return NamedErr("error", err)
}
// NamedErr constructs a field containing a key and error value.
var NamedErr = func(key string, err error) logr.Field {
if err == nil {
return Field{Key: key, Type: logr.StringType, String: ""}
}
return Field{Key: key, Type: logr.StringType, String: err.Error()}
}
// Bool constructs a field containing a key and bool value.
var Bool = logr.Bool
// Time constructs a field containing a key and time.Time value.
var Time = logr.Time
// Duration constructs a field containing a key and time.Duration value.
var Duration = logr.Duration
// Millis constructs a field containing a key and timestamp value.
// The timestamp is expected to be milliseconds since Jan 1, 1970 UTC.
var Millis = logr.Millis
// Array constructs a field containing a key and array value.
var Array = logr.Array
// Map constructs a field containing a key and map value.
var Map = logr.Map
// Logger provides a thin wrapper around a Logr instance. This is a struct instead of an interface
// so that there are no allocations on the heap each interface method invocation. Normally not
// something to be concerned about, but logging calls for disabled levels should have as little CPU
// and memory impact as possible. Most of these wrapper calls will be inlined as well.
type Logger struct {
log *logr.Logger
lockConfig *int32
}
// NewLogger creates a new Logger instance which can be configured via `(*Logger).Configure`.
// Some options with invalid values can cause an error to be returned, however `NewLogger()`
// using just defaults never errors.
func NewLogger(options ...Option) (*Logger, error) {
options = append(options, logr.StackFilter(logr.GetPackageName("NewLogger")))
lgr, err := logr.New(options...)
if err != nil {
return nil, err
}
log := lgr.NewLogger()
var lockConfig int32
return &Logger{
log: &log,
lockConfig: &lockConfig,
}, nil
}
// Configure provides a new configuration for this logger.
// 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
//
// An optional set of factories can be provided which will be called to create any target
// types or formatters not built-in.
func (l *Logger) Configure(cfgFile string, cfgEscaped string, factories *Factories) error {
if atomic.LoadInt32(l.lockConfig) != 0 {
return ErrConfigurationLock
}
cfgMap := make(LoggerConfiguration)
// Add config from file
if cfgFile != "" {
b, err := os.ReadFile(cfgFile)
if err != nil {
return fmt.Errorf("error reading logger config file %s: %w", cfgFile, err)
}
var mapCfgFile LoggerConfiguration
if err := json.Unmarshal(b, &mapCfgFile); err != nil {
return fmt.Errorf("error decoding logger config file %s: %w", cfgFile, err)
}
cfgMap.Append(mapCfgFile)
}
// Add config from escaped json string
if cfgEscaped != "" {
var mapCfgEscaped LoggerConfiguration
if err := json.Unmarshal([]byte(cfgEscaped), &mapCfgEscaped); err != nil {
return fmt.Errorf("error decoding logger config as escaped json: %w", err)
}
cfgMap.Append(mapCfgEscaped)
}
if len(cfgMap) == 0 {
return nil
}
return logrcfg.ConfigureTargets(l.log.Logr(), cfgMap.toTargetCfg(), factories)
}
// ConfigureTargets provides a new configuration for this logger via a `LoggerConfig` map.
// Typically `mlog.Configure` is used instead which accepts JSON formatted configuration.
// An optional set of factories can be provided which will be called to create any target
// types or formatters not built-in.
func (l *Logger) ConfigureTargets(cfg LoggerConfiguration, factories *Factories) error {
if atomic.LoadInt32(l.lockConfig) != 0 {
return ErrConfigurationLock
}
return logrcfg.ConfigureTargets(l.log.Logr(), cfg.toTargetCfg(), factories)
}
// LockConfiguration disallows further configuration changes until `UnlockConfiguration`
// is called. The previous locked stated is returned.
func (l *Logger) LockConfiguration() bool {
old := atomic.SwapInt32(l.lockConfig, 1)
return old != 0
}
// UnlockConfiguration allows configuration changes. The previous locked stated is returned.
func (l *Logger) UnlockConfiguration() bool {
old := atomic.SwapInt32(l.lockConfig, 0)
return old != 0
}
// IsConfigurationLocked returns the current state of the configuration lock.
func (l *Logger) IsConfigurationLocked() bool {
return atomic.LoadInt32(l.lockConfig) != 0
}
// With creates a new Logger with the specified fields. This is a light-weight
// operation and can be called on demand.
func (l *Logger) With(fields ...Field) *Logger {
logWith := l.log.With(fields...)
return &Logger{
log: &logWith,
lockConfig: l.lockConfig,
}
}
// IsLevelEnabled returns true only if at least one log target is
// configured to emit the specified log level. Use this check when
// gathering the log info may be expensive.
//
// Note, transformations and serializations done via fields are already
// lazily evaluated and don't require this check beforehand.
func (l *Logger) IsLevelEnabled(level Level) bool {
return l.log.IsLevelEnabled(level)
}
// Log emits the log record for any targets configured for the specified level.
func (l *Logger) Log(level Level, msg string, fields ...Field) {
l.log.Log(level, msg, fields...)
}
// LogM emits the log record for any targets configured for the specified levels.
// Equivalent to calling `Log` once for each level.
func (l *Logger) LogM(levels []Level, msg string, fields ...Field) {
l.log.LogM(levels, msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Trace` level.
func (l *Logger) Trace(msg string, fields ...Field) {
l.log.Trace(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Debug` level.
func (l *Logger) Debug(msg string, fields ...Field) {
l.log.Debug(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Info` level.
func (l *Logger) Info(msg string, fields ...Field) {
l.log.Info(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Warn` level.
func (l *Logger) Warn(msg string, fields ...Field) {
l.log.Warn(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Error` level.
func (l *Logger) Error(msg string, fields ...Field) {
l.log.Error(msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Critical` level.
func (l *Logger) Critical(msg string, fields ...Field) {
l.log.Log(LvlCritical, msg, fields...)
}
// Convenience method equivalent to calling `Log` with the `Fatal` level,
// followed by `os.Exit(1)`.
func (l *Logger) Fatal(msg string, fields ...Field) {
l.log.Log(logr.Fatal, msg, fields...)
_ = l.Shutdown()
os.Exit(1)
}
// HasTargets returns true if at least one log target has been added.
func (l *Logger) HasTargets() bool {
return l.log.Logr().HasTargets()
}
// StdLogger creates a standard logger backed by this logger.
// All log records are output with the specified level.
func (l *Logger) StdLogger(level Level) *log.Logger {
return l.log.StdLogger(level)
}
// StdLogWriter returns a writer that can be hooked up to the output of a golang standard logger
// anything written will be interpreted as log entries and passed to this logger.
func (l *Logger) StdLogWriter() io.Writer {
return &logWriter{
logger: l,
}
}
// RedirectStdLog redirects output from the standard library's package-global logger
// to this logger at the specified level and with zero or more Field's. Since this logger already
// handles caller annotations, timestamps, etc., it automatically disables the standard
// library's annotations and prefixing.
// A function is returned that restores the original prefix and flags and resets the standard
// library's output to os.Stdout.
func (l *Logger) RedirectStdLog(level Level, fields ...Field) func() {
return l.log.Logr().RedirectStdLog(level, fields...)
}
// RemoveTargets safely removes one or more targets based on the filtering method.
// `f` should return true to delete the target, false to keep it.
// When removing a target, best effort is made to write any queued log records before
// closing, with ctx determining how much time can be spent in total.
// Note, keep the timeout short since this method blocks certain logging operations.
func (l *Logger) RemoveTargets(ctx context.Context, f func(ti TargetInfo) bool) error {
return l.log.Logr().RemoveTargets(ctx, f)
}
// SetMetricsCollector sets (or resets) the metrics collector to be used for gathering
// metrics for all targets. Only targets added after this call will use the collector.
//
// To ensure all targets use a collector, use the `SetMetricsCollector` option when
// creating the Logger instead, or configure/reconfigure the Logger after calling this method.
func (l *Logger) SetMetricsCollector(collector MetricsCollector, updateFrequencyMillis int64) {
l.log.Logr().SetMetricsCollector(collector, updateFrequencyMillis)
}
// Sugar creates a new `Logger` with a less structured API. Any fields are preserved.
func (l *Logger) Sugar(fields ...Field) Sugar {
return l.log.Sugar(fields...)
}
// Flush forces all targets to write out any queued log records with a default timeout.
func (l *Logger) Flush() error {
ctx, cancel := context.WithTimeout(context.Background(), FlushTimeout)
defer cancel()
return l.log.Logr().FlushWithTimeout(ctx)
}
// Flush forces all targets to write out any queued log records with the specified timeout.
func (l *Logger) FlushWithTimeout(ctx context.Context) error {
return l.log.Logr().FlushWithTimeout(ctx)
}
// Shutdown shuts down the logger after making best efforts to flush any
// remaining records.
func (l *Logger) Shutdown() error {
ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
defer cancel()
return l.log.Logr().ShutdownWithTimeout(ctx)
}
// Shutdown shuts down the logger after making best efforts to flush any
// remaining records.
func (l *Logger) ShutdownWithTimeout(ctx context.Context) error {
return l.log.Logr().ShutdownWithTimeout(ctx)
}
// GetPackageName reduces a fully qualified function name to the package name
// By sirupsen: https://github.com/sirupsen/logrus/blob/master/entry.go
func GetPackageName(f string) string {
for {
lastPeriod := strings.LastIndex(f, ".")
lastSlash := strings.LastIndex(f, "/")
if lastPeriod > lastSlash {
f = f[:lastPeriod]
} else {
break
}
}
return f
}
// ShouldQuote returns true if val contains any characters that might be unsafe
// when injecting log output into an aggregator, viewer or report.
// Returning true means that val should be surrounded by quotation marks before being
// output into logs.
func ShouldQuote(val string) bool {
for _, c := range val {
if !((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
c == '-' || c == '.' || c == '_' || c == '/' || c == '@' || c == '^' || c == '+') {
return true
}
}
return false
}
type logWriter struct {
logger *Logger
}
func (lw *logWriter) Write(p []byte) (int, error) {
lw.logger.Info(string(p))
return len(p), nil
}
// ErrConfigurationLock is returned when one of a logger's configuration APIs is called
// while the configuration is locked.
var ErrConfigurationLock = errors.New("configuration is locked")

55
server/platform/shared/mlog/options.go Обычный файл
Просмотреть файл

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import "github.com/mattermost/logr/v2"
// MaxQueueSize is the maximum number of log records that can be queued.
// If exceeded, `OnQueueFull` is called which determines if the log
// record will be dropped or block until add is successful.
// Defaults to DefaultMaxQueueSize.
func MaxQueueSize(size int) Option {
return logr.MaxQueueSize(size)
}
// OnLoggerError, when not nil, is called any time an internal
// logging error occurs. For example, this can happen when a
// target cannot connect to its data sink.
func OnLoggerError(f func(error)) Option {
return logr.OnLoggerError(f)
}
// OnQueueFull, when not nil, is called on an attempt to add
// a log record to a full Logr queue.
// `MaxQueueSize` can be used to modify the maximum queue size.
// This function should return quickly, with a bool indicating whether
// the log record should be dropped (true) or block until the log record
// is successfully added (false). If nil then blocking (false) is assumed.
func OnQueueFull(f func(rec *LogRec, maxQueueSize int) bool) Option {
return logr.OnQueueFull(f)
}
// OnTargetQueueFull, when not nil, is called on an attempt to add
// a log record to a full target queue provided the target supports reporting
// this condition.
// This function should return quickly, with a bool indicating whether
// the log record should be dropped (true) or block until the log record
// is successfully added (false). If nil then blocking (false) is assumed.
func OnTargetQueueFull(f func(target Target, rec *LogRec, maxQueueSize int) bool) Option {
return logr.OnTargetQueueFull(f)
}
// SetMetricsCollector enables metrics collection by supplying a MetricsCollector.
// The MetricsCollector provides counters and gauges that are updated by log targets.
// `updateFreqMillis` determines how often polled metrics are updated. Defaults to 15000 (15 seconds)
// and must be at least 250 so we don't peg the CPU.
func SetMetricsCollector(collector MetricsCollector, updateFreqMillis int64) Option {
return logr.SetMetricsCollector(collector, updateFreqMillis)
}
// StackFilter provides a list of package names to exclude from the top of
// stack traces. The Logr packages are automatically filtered.
func StackFilter(pkg ...string) Option {
return logr.StackFilter(pkg...)
}

79
server/platform/shared/mlog/tlog.go Обычный файл
Просмотреть файл

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import (
"bytes"
"io"
"os"
"sync"
"github.com/mattermost/logr/v2"
"github.com/mattermost/logr/v2/formatters"
"github.com/mattermost/logr/v2/targets"
)
// AddWriterTarget adds a simple io.Writer target to an existing Logger.
// The `io.Writer` can be a buffer which is useful for testing.
// When adding a buffer to collect logs make sure to use `mlog.Buffer` which is
// a thread safe version of `bytes.Buffer`.
func AddWriterTarget(logger *Logger, w io.Writer, useJSON bool, levels ...Level) error {
filter := logr.NewCustomFilter(levels...)
var formatter logr.Formatter
if useJSON {
formatter = &formatters.JSON{EnableCaller: true}
} else {
formatter = &formatters.Plain{EnableCaller: true}
}
target := targets.NewWriterTarget(w)
return logger.log.Logr().AddTarget(target, "_testWriter", filter, formatter, 1000)
}
// CreateConsoleTestLogger creates a logger for unit tests. Log records are output to `os.Stdout`.
// Logs can also be mirrored to the optional `io.Writer`.
func CreateConsoleTestLogger(useJSON bool, level Level) *Logger {
logger, _ := NewLogger()
filter := logr.StdFilter{
Lvl: level,
Stacktrace: LvlPanic,
}
var formatter logr.Formatter
if useJSON {
formatter = &formatters.JSON{EnableCaller: true}
} else {
formatter = &formatters.Plain{EnableCaller: true}
}
target := targets.NewWriterTarget(os.Stdout)
if err := logger.log.Logr().AddTarget(target, "_testcon", filter, formatter, 1000); err != nil {
panic(err)
}
return logger
}
// Buffer provides a thread-safe buffer useful for logging to memory in unit tests.
type Buffer struct {
buf bytes.Buffer
mux sync.Mutex
}
func (b *Buffer) Read(p []byte) (n int, err error) {
b.mux.Lock()
defer b.mux.Unlock()
return b.buf.Read(p)
}
func (b *Buffer) Write(p []byte) (n int, err error) {
b.mux.Lock()
defer b.mux.Unlock()
return b.buf.Write(p)
}
func (b *Buffer) String() string {
b.mux.Lock()
defer b.mux.Unlock()
return b.buf.String()
}

155
server/platform/shared/templates/templates.go Обычный файл
Просмотреть файл

@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package templates
import (
"bytes"
"html/template"
"io"
"os"
"path/filepath"
"sync"
"github.com/fsnotify/fsnotify"
"github.com/mattermost/mattermost-server/v6/server/channels/utils/fileutils"
)
// Container represents a set of templates that can be render
type Container struct {
templates *template.Template
mutex sync.RWMutex
stop chan struct{}
stopped chan struct{}
watch bool
}
// Data contains the data used to populate the template variables, it has Props
// that can be of any type and HTML that only can be `template.HTML` types.
type Data struct {
Props map[string]any
HTML map[string]template.HTML
}
func GetTemplateDirectory() (string, bool) {
templatesDir := "templates"
if mattermostPath := os.Getenv("MM_SERVER_PATH"); mattermostPath != "" {
templatesDir = filepath.Join(mattermostPath, templatesDir)
}
return fileutils.FindDir(templatesDir)
}
// NewFromTemplates creates a new templates container using a
// `template.Template` object
func NewFromTemplate(templates *template.Template) *Container {
return &Container{templates: templates}
}
// New creates a new templates container scanning a directory.
func New(directory string) (*Container, error) {
c := &Container{}
htmlTemplates, err := template.ParseGlob(filepath.Join(directory, "*.html"))
if err != nil {
return nil, err
}
c.templates = htmlTemplates
return c, nil
}
// NewWithWatcher creates a new templates container scanning a directory and
// watch the directory filesystem changes to apply them to the loaded
// templates. This function returns the container and an errors channel to pass
// all errors that can happen during the watch process, or an regular error if
// we fail to create the templates or the watcher. The caller must consume the
// returned errors channel to ensure not blocking the watch process.
func NewWithWatcher(directory string) (*Container, <-chan error, error) {
htmlTemplates, err := template.ParseGlob(filepath.Join(directory, "*.html"))
if err != nil {
return nil, nil, err
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, nil, err
}
err = watcher.Add(directory)
if err != nil {
watcher.Close()
return nil, nil, err
}
c := &Container{
templates: htmlTemplates,
watch: true,
stop: make(chan struct{}),
stopped: make(chan struct{}),
}
errors := make(chan error)
go func() {
defer close(errors)
defer close(c.stopped)
defer watcher.Close()
for {
select {
case <-c.stop:
return
case event := <-watcher.Events:
if event.Op&fsnotify.Write == fsnotify.Write {
if htmlTemplates, err := template.ParseGlob(filepath.Join(directory, "*.html")); err != nil {
errors <- err
} else {
c.mutex.Lock()
c.templates = htmlTemplates
c.mutex.Unlock()
}
}
case err := <-watcher.Errors:
errors <- err
}
}
}()
return c, errors, nil
}
// Close stops the templates watcher of the container in case you have created
// it with watch parameter set to true
func (c *Container) Close() {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.watch {
close(c.stop)
<-c.stopped
}
}
// RenderToString renders the template referenced with the template name using
// the data provided and return a string with the result
func (c *Container) RenderToString(templateName string, data Data) (string, error) {
var text bytes.Buffer
if err := c.Render(&text, templateName, data); err != nil {
return "", err
}
return text.String(), nil
}
// RenderToString renders the template referenced with the template name using
// the data provided and write it to the writer provided
func (c *Container) Render(w io.Writer, templateName string, data Data) error {
c.mutex.RLock()
htmlTemplates := c.templates
c.mutex.RUnlock()
if err := htmlTemplates.ExecuteTemplate(w, templateName, data); err != nil {
return err
}
return nil
}

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

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package templates
import (
"bytes"
"html/template"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTMLTemplateWatcher(t *testing.T) {
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
require.NoError(t, os.Mkdir(filepath.Join(dir, "templates"), 0700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "templates", "foo.html"), []byte(`{{ define "foo" }}foo{{ end }}`), 0600))
prevDir, err := os.Getwd()
require.NoError(t, err)
defer os.Chdir(prevDir)
os.Chdir(dir)
watcher, errChan, err := NewWithWatcher("templates")
require.NoError(t, err)
require.NotNil(t, watcher)
select {
case msg := <-errChan:
err = msg
default:
err = nil
}
require.NoError(t, err)
defer watcher.Close()
text, err := watcher.RenderToString("foo", Data{})
require.NoError(t, err)
assert.Equal(t, "foo", text)
require.NoError(t, os.WriteFile(filepath.Join(dir, "templates", "foo.html"), []byte(`{{ define "foo" }}bar{{ end }}`), 0600))
require.Eventually(t, func() bool {
text, err := watcher.RenderToString("foo", Data{})
return text == "bar" && err == nil
}, time.Millisecond*1000, time.Millisecond*50)
}
func TestNewWithWatcher_BadDirectory(t *testing.T) {
watcher, errChan, err := NewWithWatcher("notarealdirectory")
require.Error(t, err)
assert.Nil(t, watcher)
assert.Nil(t, errChan)
}
func TestNew_BadDirectory(t *testing.T) {
watcher, err := New("notarealdirectory")
assert.Nil(t, watcher)
assert.Error(t, err)
}
func TestRender(t *testing.T) {
tpl := template.New("test")
_, err := tpl.Parse(`{{ define "foo" }}foo{{ .Props.Bar }}{{ end }}`)
require.NoError(t, err)
mt := NewFromTemplate(tpl)
data := Data{
Props: map[string]any{
"Bar": "bar",
},
}
text, err := mt.RenderToString("foo", data)
require.NoError(t, err)
assert.Equal(t, "foobar", text)
buf := &bytes.Buffer{}
require.NoError(t, mt.Render(buf, "foo", data))
assert.Equal(t, "foobar", buf.String())
}
func TestRenderError(t *testing.T) {
tpl := template.New("test")
_, err := tpl.Parse(`{{ define "foo" }}foo{{ .Foo.Bar }}bar{{ end }}`)
require.NoError(t, err)
mt := NewFromTemplate(tpl)
text, err := mt.RenderToString("foo", Data{})
require.Error(t, err)
assert.Equal(t, "", text)
buf := &bytes.Buffer{}
assert.Error(t, mt.Render(buf, "foo", Data{}))
assert.Equal(t, "foo", buf.String())
}
func TestRenderUnknownTemplate(t *testing.T) {
tpl := template.New("")
mt := NewFromTemplate(tpl)
text, err := mt.RenderToString("foo", Data{})
require.Error(t, err)
assert.Equal(t, "", text)
buf := &bytes.Buffer{}
assert.Error(t, mt.Render(buf, "foo", Data{}))
assert.Equal(t, "", buf.String())
}

92
server/platform/shared/web/files.go Обычный файл
Просмотреть файл

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
var UnsafeContentTypes = [...]string{
"application/javascript",
"application/ecmascript",
"text/javascript",
"text/ecmascript",
"application/x-javascript",
"text/html",
}
var MediaContentTypes = [...]string{
"image/jpeg",
"image/png",
"image/bmp",
"image/gif",
"image/tiff",
"video/avi",
"video/mpeg",
"video/mp4",
"audio/mpeg",
"audio/wav",
}
func WriteFileResponse(filename string, contentType string, contentSize int64, lastModification time.Time, webserverMode string, fileReader io.ReadSeeker, forceDownload bool, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "private, no-cache")
w.Header().Set("X-Content-Type-Options", "nosniff")
if contentSize > 0 {
contentSizeStr := strconv.Itoa(int(contentSize))
if webserverMode == "gzip" {
w.Header().Set("X-Uncompressed-Content-Length", contentSizeStr)
} else {
w.Header().Set("Content-Length", contentSizeStr)
}
}
if contentType == "" {
contentType = "application/octet-stream"
} else {
for _, unsafeContentType := range UnsafeContentTypes {
if strings.HasPrefix(contentType, unsafeContentType) {
contentType = "text/plain"
break
}
}
}
w.Header().Set("Content-Type", contentType)
var toDownload bool
if forceDownload {
toDownload = true
} else {
isMediaType := false
for _, mediaContentType := range MediaContentTypes {
if strings.HasPrefix(contentType, mediaContentType) {
isMediaType = true
break
}
}
toDownload = !isMediaType
}
filename = url.PathEscape(filename)
if toDownload {
w.Header().Set("Content-Disposition", "attachment;filename=\""+filename+"\"; filename*=UTF-8''"+filename)
} else {
w.Header().Set("Content-Disposition", "inline;filename=\""+filename+"\"; filename*=UTF-8''"+filename)
}
// prevent file links from being embedded in iframes
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "Frame-ancestors 'none'")
http.ServeContent(w, r, filename, lastModification, fileReader)
}