Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
117
server/platform/shared/driver/conn.go
Обычный файл
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
Обычный файл
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
Обычный файл
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)
|
||||
}
|
||||
102
server/platform/shared/filestore/filesstore.go
Обычный файл
102
server/platform/shared/filestore/filesstore.go
Обычный файл
@@ -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)
|
||||
}
|
||||
623
server/platform/shared/filestore/filesstore_test.go
Обычный файл
623
server/platform/shared/filestore/filesstore_test.go
Обычный файл
@@ -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()
|
||||
}
|
||||
239
server/platform/shared/filestore/localstore.go
Обычный файл
239
server/platform/shared/filestore/localstore.go
Обычный файл
@@ -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
|
||||
}
|
||||
287
server/platform/shared/filestore/mocks/FileBackend.go
Обычный файл
287
server/platform/shared/filestore/mocks/FileBackend.go
Обычный файл
@@ -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
|
||||
}
|
||||
68
server/platform/shared/filestore/mocks/ReadCloseSeeker.go
Обычный файл
68
server/platform/shared/filestore/mocks/ReadCloseSeeker.go
Обычный файл
@@ -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
|
||||
}
|
||||
55
server/platform/shared/filestore/s3_overrides.go
Обычный файл
55
server/platform/shared/filestore/s3_overrides.go
Обычный файл
@@ -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
Обычный файл
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
|
||||
}
|
||||
307
server/platform/shared/filestore/s3store_test.go
Обычный файл
307
server/platform/shared/filestore/s3store_test.go
Обычный файл
@@ -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
Обычный файл
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
Обычный файл
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><i>foo</i></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: "<b>abc</b>",
|
||||
},
|
||||
"StringPointer": {
|
||||
In: &stringForPointer,
|
||||
Expected: "<b>abc</b>",
|
||||
},
|
||||
"Map": {
|
||||
In: map[string]any{
|
||||
"abc": "abc",
|
||||
"123": "<b>123</b>",
|
||||
},
|
||||
Expected: map[string]any{
|
||||
"abc": "abc",
|
||||
"123": "<b>123</b>",
|
||||
},
|
||||
},
|
||||
"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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
}
|
||||
701
server/platform/shared/markdown/autolink_test.go
Обычный файл
701
server/platform/shared/markdown/autolink_test.go
Обычный файл
@@ -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"",
|
||||
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&other=2">www.example.com/index?params=1&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))
|
||||
})
|
||||
}
|
||||
}
|
||||
62
server/platform/shared/markdown/block_quote.go
Обычный файл
62
server/platform/shared/markdown/block_quote.go
Обычный файл
@@ -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
Обычный файл
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
|
||||
}
|
||||
1044
server/platform/shared/markdown/commonmark_test.go
Обычный файл
1044
server/platform/shared/markdown/commonmark_test.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
22
server/platform/shared/markdown/document.go
Обычный файл
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
|
||||
}
|
||||
112
server/platform/shared/markdown/fenced_code.go
Обычный файл
112
server/platform/shared/markdown/fenced_code.go
Обычный файл
@@ -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
Обычный файл
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(
|
||||
`&`, "&",
|
||||
`<`, "<",
|
||||
`>`, ">",
|
||||
`"`, """,
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
2132
server/platform/shared/markdown/html_entities.go
Обычный файл
2132
server/platform/shared/markdown/html_entities.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
98
server/platform/shared/markdown/indented_code.go
Обычный файл
98
server/platform/shared/markdown/indented_code.go
Обычный файл
@@ -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
Обычный файл
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
Обычный файл
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)
|
||||
}
|
||||
75
server/platform/shared/markdown/inspect_test.go
Обычный файл
75
server/platform/shared/markdown/inspect_test.go
Обычный файл
@@ -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
Обычный файл
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
|
||||
}
|
||||
36
server/platform/shared/markdown/lines_test.go
Обычный файл
36
server/platform/shared/markdown/lines_test.go
Обычный файл
@@ -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
Обычный файл
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 .
|
||||
// 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
Обычный файл
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: ``,
|
||||
Position: 26,
|
||||
ExpectedRange: Range{0, 0},
|
||||
ExpectedNext: 0,
|
||||
ExpectedOk: false,
|
||||
},
|
||||
"no dimensions, title": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{0, 0},
|
||||
ExpectedNext: 0,
|
||||
ExpectedOk: false,
|
||||
},
|
||||
"only width, no title": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{27, 30},
|
||||
ExpectedNext: 31,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"only width, title": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{27, 30},
|
||||
ExpectedNext: 31,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"only height, no title": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{27, 31},
|
||||
ExpectedNext: 32,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"only height, title": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{27, 31},
|
||||
ExpectedNext: 32,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"dimensions, no title": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{27, 34},
|
||||
ExpectedNext: 35,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"dimensions, title": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{27, 34},
|
||||
ExpectedNext: 35,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"no dimensions, no title, trailing whitespace": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{0, 0},
|
||||
ExpectedNext: 0,
|
||||
ExpectedOk: false,
|
||||
},
|
||||
"only width, no title, trailing whitespace": {
|
||||
Input: ``,
|
||||
Position: 28,
|
||||
ExpectedRange: Range{28, 31},
|
||||
ExpectedNext: 32,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"only height, no title, trailing whitespace": {
|
||||
Input: ``,
|
||||
Position: 29,
|
||||
ExpectedRange: Range{29, 33},
|
||||
ExpectedNext: 34,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"dimensions, no title, trailing whitespace": {
|
||||
Input: ``,
|
||||
Position: 30,
|
||||
ExpectedRange: Range{30, 37},
|
||||
ExpectedNext: 38,
|
||||
ExpectedOk: true,
|
||||
},
|
||||
"no width or height": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{0, 0},
|
||||
ExpectedNext: 0,
|
||||
ExpectedOk: false,
|
||||
},
|
||||
"garbage 1": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{0, 0},
|
||||
ExpectedNext: 0,
|
||||
ExpectedOk: false,
|
||||
},
|
||||
"garbage 2": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{0, 0},
|
||||
ExpectedNext: 0,
|
||||
ExpectedOk: false,
|
||||
},
|
||||
"garbage 3": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{0, 0},
|
||||
ExpectedNext: 0,
|
||||
ExpectedOk: false,
|
||||
},
|
||||
"garbage 4": {
|
||||
Input: ``,
|
||||
Position: 27,
|
||||
ExpectedRange: Range{0, 0},
|
||||
ExpectedNext: 0,
|
||||
ExpectedOk: false,
|
||||
},
|
||||
"garbage 5": {
|
||||
Input: ` {
|
||||
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: ``,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" /></p>`,
|
||||
},
|
||||
"image link with title": {
|
||||
Markdown: ``,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
|
||||
},
|
||||
"image link with bracketed title": {
|
||||
Markdown: `)`,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
|
||||
},
|
||||
"image link with width": {
|
||||
Markdown: ``,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" /></p>`,
|
||||
},
|
||||
"image link with width and title": {
|
||||
Markdown: ``,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
|
||||
},
|
||||
"image link with width and bracketed title": {
|
||||
Markdown: `)`,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
|
||||
},
|
||||
"image link with height": {
|
||||
Markdown: ``,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" /></p>`,
|
||||
},
|
||||
"image link with height and title": {
|
||||
Markdown: ``,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
|
||||
},
|
||||
"image link with height and bracketed title": {
|
||||
Markdown: `)`,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
|
||||
},
|
||||
"image link with dimensions": {
|
||||
Markdown: ``,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" /></p>`,
|
||||
},
|
||||
"image link with dimensions and title": {
|
||||
Markdown: ``,
|
||||
ExpectedHTML: `<p><img src="https://example.com/image.png" alt="image" title="title" /></p>`,
|
||||
},
|
||||
"image link with dimensions and bracketed title": {
|
||||
Markdown: `)`,
|
||||
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: ``,
|
||||
ExpectedHTML: `<p><img src="" alt="image" /></p>`,
|
||||
},
|
||||
"no image link with dimensions": {
|
||||
Markdown: ``,
|
||||
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
Обычный файл
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
Обычный файл
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)
|
||||
}
|
||||
71
server/platform/shared/markdown/paragraph.go
Обычный файл
71
server/platform/shared/markdown/paragraph.go
Обычный файл
@@ -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},
|
||||
}
|
||||
}
|
||||
75
server/platform/shared/markdown/reference_definition.go
Обычный файл
75
server/platform/shared/markdown/reference_definition.go
Обычный файл
@@ -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
|
||||
}
|
||||
115
server/platform/shared/markdown/text_range_test.go
Обычный файл
115
server/platform/shared/markdown/text_range_test.go
Обычный файл
@@ -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: ""test",
|
||||
ExpectedRanges: []Range{{0, 1}, {6, 10}},
|
||||
ExpectedValues: []string{"\"", "test"},
|
||||
},
|
||||
"notcharref": {
|
||||
Markdown: "& test",
|
||||
ExpectedRanges: []Range{{0, 9}},
|
||||
ExpectedValues: []string{"& 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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
}
|
||||
114
server/platform/shared/templates/templates_test.go
Обычный файл
114
server/platform/shared/templates/templates_test.go
Обычный файл
@@ -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
Обычный файл
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)
|
||||
}
|
||||
Ссылка в новой задаче
Block a user