snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/v8/channels/utils:
config:
dir: "channels/utils/mocks"
filename: "{{.InterfaceName}}.go"
mockname: "{{.InterfaceName}}"
outpkg: mocks
note: "Regenerate this file using `make misc-mocks`."
interfaces:
LicenseValidatorIface:

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"crypto"
"crypto/rand"
"encoding/base64"
"fmt"
"html/template"
"net/http"
"net/url"
"path"
"slices"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func CheckOrigin(r *http.Request, allowedOrigins string) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
if allowedOrigins == "*" {
return true
}
return slices.Contains(strings.Split(allowedOrigins, " "), origin)
}
func OriginChecker(allowedOrigins string) func(*http.Request) bool {
return func(r *http.Request) bool {
return CheckOrigin(r, allowedOrigins)
}
}
// CheckEmbeddedCookie returns true if the MMEMBED cookie is set to 1.
// MMEMBED is set via any plugin that facilitates Mattermost embedded in an iframe (e.g. mattermost-plugin-msteams-sync).
func CheckEmbeddedCookie(r *http.Request) bool {
cookie, err := r.Cookie("MMEMBED")
if err != nil {
return false
}
return cookie.Value == "1"
}
func RenderWebAppError(config *model.Config, w http.ResponseWriter, r *http.Request, err *model.AppError, s crypto.Signer) {
RenderWebError(config, w, r, err.StatusCode, url.Values{
"message": []string{err.Message},
}, s)
}
func RenderWebError(config *model.Config, w http.ResponseWriter, r *http.Request, status int, params url.Values, s crypto.Signer) {
queryString := params.Encode()
subpath, _ := GetSubpathFromConfig(config)
h := crypto.SHA256
sum := h.New()
sum.Write([]byte(path.Join(subpath, "error") + "?" + queryString))
signature, err := s.Sign(rand.Reader, sum.Sum(nil), h)
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return
}
destination := path.Join(subpath, "error") + "?" + queryString + "&s=" + base64.URLEncoding.EncodeToString(signature)
if status >= 300 && status < 400 {
http.Redirect(w, r, destination, status)
return
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(status)
fmt.Fprintln(w, `<!DOCTYPE html><html><head></head>`)
fmt.Fprintln(w, `<body onload="window.location = '`+template.HTMLEscapeString(template.JSEscapeString(destination))+`'">`)
fmt.Fprintln(w, `<noscript><meta http-equiv="refresh" content="0; url=`+template.HTMLEscapeString(destination)+`"></noscript>`)
fmt.Fprintln(w, `<!-- web error message -->`)
fmt.Fprintln(w, `<a href="`+template.HTMLEscapeString(destination)+`" style="color: #c0c0c0;">...</a>`)
fmt.Fprintln(w, `</body></html>`)
}
func RenderMobileAuthComplete(w http.ResponseWriter, redirectURL string) {
var link = template.HTMLEscapeString(redirectURL)
RenderMobileMessage(w, `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="width: 64px; height: 64px; fill: #3c763d">
<!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->
<path stroke="green" d="M504 256c0 136.967-111.033 248-248 248S8 392.967 8 256 119.033 8 256 8s248 111.033 248 248zM227.314 387.314l184-184c6.248-6.248 6.248-16.379 0-22.627l-22.627-22.627c-6.248-6.249-16.379-6.249-22.628 0L216 308.118l-70.059-70.059c-6.248-6.248-16.379-6.248-22.628 0l-22.627 22.627c-6.248 6.248-6.248 16.379 0 22.627l104 104c6.249 6.249 16.379 6.249 22.628.001z"/>
</svg>
<h2> `+i18n.T("api.oauth.auth_complete")+` </h2>
<p id="redirecting-message"> `+i18n.T("api.oauth.redirecting_back")+` </p>
<p id="close-tab-message" style="display: none"> `+i18n.T("api.oauth.close_browser")+` </p>
<p> `+string(i18n.TranslateAsHTML(i18n.T, "api.oauth.click_redirect", map[string]any{"Link": redirectURL}))+` </p>
<meta http-equiv="refresh" content="2; url=`+link+`">
<script>
window.onload = function() {
setTimeout(function() {
document.getElementById('redirecting-message').style.display = 'none';
document.getElementById('close-tab-message').style.display = 'block';
}, 2000);
}
</script>
`)
}
func RenderMobileError(config *model.Config, w http.ResponseWriter, err *model.AppError, redirectURL string) {
var link = template.HTMLEscapeString(redirectURL)
u, redirectErr := url.Parse(redirectURL)
if redirectErr != nil || !slices.Contains(config.NativeAppSettings.AppCustomURLSchemes, u.Scheme) {
link = template.HTMLEscapeString(*config.ServiceSettings.SiteURL)
}
RenderMobileMessage(w, `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" style="width: 64px; height: 64px; fill: #ccc">
<!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->
<path d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/>
</svg>
<h2> `+i18n.T("error")+` </h2>
<p> `+template.HTMLEscapeString(err.Message)+` </p>
<a href="`+link+`">
`+string(i18n.TranslateAsHTML(i18n.T, "api.back_to_app", map[string]any{"SiteName": config.TeamSettings.SiteName}))+`
</a>
`)
}
func RenderMobileMessage(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover">
<style>
body {
color: #333;
background-color: #fff;
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
font-size: 14px;
line-height: 1.42857143;
}
a {
color: #337ab7;
text-decoration: none;
}
a:focus, a:hover {
color: #23527c;
text-decoration: underline;
}
h2 {
font-size: 30px;
margin: 20px 0 10px 0;
font-weight: 500;
line-height: 1.1
}
p {
margin: 0 0 10px;
}
.message-container {
color: #555;
display: table-cell;
padding: 5em 0;
text-align: left;
vertical-align: top;
}
</style>
</head>
<body>
<!-- mobile app message -->
<div class="message-container">
`+message+`
</div>
</body>
</html>
`)
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/asn1"
"encoding/base64"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func TestRenderWebError(t *testing.T) {
r := httptest.NewRequest("GET", "http://foo", nil)
w := httptest.NewRecorder()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
RenderWebError(&model.Config{}, w, r, http.StatusTemporaryRedirect, url.Values{
"foo": []string{"bar"},
}, key)
resp := w.Result()
location, err := url.Parse(resp.Header.Get("Location"))
require.NoError(t, err)
require.NotEmpty(t, location.Query().Get("s"))
type ecdsaSignature struct {
R, S *big.Int
}
var rs ecdsaSignature
s, err := base64.URLEncoding.DecodeString(location.Query().Get("s"))
require.NoError(t, err)
_, err = asn1.Unmarshal(s, &rs)
require.NoError(t, err)
assert.Equal(t, "bar", location.Query().Get("foo"))
h := sha256.Sum256([]byte("/error?foo=bar"))
assert.True(t, ecdsa.Verify(&key.PublicKey, h[:], rs.R, rs.S))
}
func TestRenderMobileError(t *testing.T) {
require.NoError(t, i18n.TranslationsPreInitFromFileBytes("en.json", []byte(`[{"id":"api.back_to_app","translation":"Back to {{.SiteName}}"}]`)))
cfg := &model.Config{}
cfg.SetDefaults()
*cfg.ServiceSettings.SiteURL = "http://localhost:8065"
*cfg.TeamSettings.SiteName = "Mattermost<test>"
cfg.NativeAppSettings.AppCustomURLSchemes = []string{"mattermost"}
appErr := model.NewAppError("test", "api.test.error", nil, "details", http.StatusBadRequest)
appErr.Message = "Something went <wrong>"
t.Run("renders html with special characters encoded in site name", func(t *testing.T) {
w := httptest.NewRecorder()
RenderMobileError(cfg, w, appErr, "mattermost://auth/complete")
body := w.Body.String()
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, body, "Mattermost&lt;test&gt;")
assert.NotContains(t, body, "Mattermost<test>")
})
t.Run("renders html with special characters encoded in error message", func(t *testing.T) {
w := httptest.NewRecorder()
RenderMobileError(cfg, w, appErr, "mattermost://auth/complete")
body := w.Body.String()
assert.Contains(t, body, "Something went &lt;wrong&gt;")
assert.NotContains(t, body, "Something went <wrong>")
})
t.Run("falls back to site url for invalid redirect scheme", func(t *testing.T) {
w := httptest.NewRecorder()
RenderMobileError(cfg, w, appErr, "https://evil.example.com/callback")
body := w.Body.String()
assert.Contains(t, body, "http://localhost:8065")
assert.False(t, strings.Contains(body, "evil.example.com"))
})
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func sanitizePath(p string) string {
dir := strings.ReplaceAll(filepath.Dir(filepath.Clean(p)), "..", "")
base := filepath.Base(p)
if strings.Count(base, ".") == len(base) {
return ""
}
return filepath.Join(dir, base)
}
// UnzipToPath extracts a given zip archive into a given path.
// It returns a list of extracted paths.
func UnzipToPath(zipFile io.ReaderAt, size int64, outPath string) ([]string, error) {
rd, err := zip.NewReader(zipFile, size)
if err != nil {
return nil, fmt.Errorf("failed to create reader: %w", err)
}
paths := make([]string, len(rd.File))
for i, f := range rd.File {
filePath := sanitizePath(f.Name)
if filePath == "" {
return nil, fmt.Errorf("invalid filepath `%s`", f.Name)
}
path := filepath.Join(outPath, filePath)
paths[i] = path
if f.FileInfo().IsDir() {
if err := os.Mkdir(path, 0700); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
continue
}
if _, err := os.Stat(filepath.Dir(path)); os.IsNotExist(err) {
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
}
outFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
file, err := f.Open()
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
if _, err := io.Copy(outFile, file); err != nil {
return nil, fmt.Errorf("failed to write to file: %w", err)
}
}
return paths, nil
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"archive/zip"
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
func TestSanitizePath(t *testing.T) {
cases := []struct {
input string
expected string
}{
{
".",
"",
},
{
"../",
"",
},
{
"...",
"",
},
{
"..//.",
"",
},
{
"/../",
"",
},
{
"/path/...../to/file",
"/path/to/file",
},
{
"/path/to/file...",
"/path/to/file...",
},
{
"/path/to/../../../file",
"/file",
},
{
"../../../../file",
"/file",
},
{
"/path/to/file..ext",
"/path/to/file..ext",
},
{
"/path/to/...file..ext",
"/path/to/...file..ext",
},
{
"./path/to/...file..ext",
"path/to/...file..ext",
},
{
"./...file",
"...file",
},
{
"path/",
"path",
},
}
for _, c := range cases {
t.Run(c.input, func(t *testing.T) {
require.Equal(t, c.expected, sanitizePath(c.input))
})
}
}
func TestUnzipToPath(t *testing.T) {
testDir, _ := fileutils.FindDir("tests")
require.NotEmpty(t, testDir)
dir, err := os.MkdirTemp("", "unzip")
require.NoError(t, err)
defer os.RemoveAll(dir)
t.Run("invalid archive", func(t *testing.T) {
file, err := os.Open(testDir + "/testplugin.tar.gz")
require.NoError(t, err)
defer file.Close()
info, err := file.Stat()
require.NoError(t, err)
paths, err := UnzipToPath(file, info.Size(), dir)
require.Error(t, err)
require.True(t, errors.Is(err, zip.ErrFormat))
require.Nil(t, paths)
})
t.Run("valid archive", func(t *testing.T) {
file, err := os.Open(testDir + "/testarchive.zip")
require.NoError(t, err)
defer file.Close()
info, err := file.Stat()
require.NoError(t, err)
paths, err := UnzipToPath(file, info.Size(), dir)
require.NoError(t, err)
require.NotEmpty(t, paths)
expectedFiles := map[string]int64{
dir + "/testfile.txt": 446,
dir + "/testdir/testfile2.txt": 866,
dir + "/testdir2/testfile3.txt": 845,
}
expectedDirs := []string{
dir + "/testdir",
dir + "/testdir2",
}
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
require.NoError(t, err)
if path == dir {
return nil
}
require.Contains(t, paths, path)
if info.IsDir() {
require.Contains(t, expectedDirs, path)
} else {
require.Equal(t, expectedFiles[path], info.Size())
}
return nil
})
require.NoError(t, err)
})
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"time"
)
var shortBackoffTimeouts = []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond, 400 * time.Millisecond}
var longBackoffTimeouts = []time.Duration{500 * time.Millisecond, 1 * time.Second, 2 * time.Second, 5 * time.Second, 8 * time.Second, 10 * time.Second}
// ProgressiveRetry executes a BackoffOperation and waits an increasing time before retrying the operation.
func ProgressiveRetry(operation func() error) error {
return CustomProgressiveRetry(operation, shortBackoffTimeouts)
}
func LongProgressiveRetry(operation func() error) error {
return CustomProgressiveRetry(operation, longBackoffTimeouts)
}
func CustomProgressiveRetry(operation func() error, backoffTimeouts []time.Duration) error {
var err error
for attempts := range backoffTimeouts {
err = operation()
if err == nil {
return nil
}
time.Sleep(backoffTimeouts[attempts])
}
return err
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProgressiveRetry(t *testing.T) {
var retries int
type args struct {
operation func() error
}
tests := []struct {
name string
args args
wantErr bool
expectedRetries int
}{
{
name: "Should fail and return error",
args: args{
operation: func() error {
retries++
return errors.New("Operation Failed")
},
},
wantErr: true,
expectedRetries: 6,
},
{
name: "Should succeed after two retries",
args: args{
operation: func() error {
retries++
if retries == 2 {
return nil
}
return errors.New("Operation Failed")
},
},
wantErr: false,
expectedRetries: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retries = 0
err := ProgressiveRetry(tt.args.operation)
if !tt.wantErr {
require.NoError(t, err)
}
assert.Equal(t, tt.expectedRetries, retries)
})
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"testing"
"github.com/stretchr/testify/require"
)
func CreateTestGif(tb testing.TB, width int, height int) []byte {
var buffer bytes.Buffer
err := gif.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, width, height)), nil)
require.NoErrorf(tb, err, "failed to create gif: %v", err)
return buffer.Bytes()
}
func CreateTestAnimatedGif(tb testing.TB, width int, height int, frames int) []byte {
var buffer bytes.Buffer
img := gif.GIF{
Image: make([]*image.Paletted, frames),
Delay: make([]int, frames),
}
for i := range frames {
img.Image[i] = image.NewPaletted(image.Rect(0, 0, width, height), color.Palette{color.Black})
img.Delay[i] = 0
}
err := gif.EncodeAll(&buffer, &img)
require.NoErrorf(tb, err, "failed to create animated gif: %v", err)
return buffer.Bytes()
}
func CreateTestJpeg(tb testing.TB, width int, height int) []byte {
var buffer bytes.Buffer
err := jpeg.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, width, height)), nil)
require.NoErrorf(tb, err, "failed to create jpeg: %v", err)
return buffer.Bytes()
}
func CreateTestPng(tb testing.TB, width int, height int) []byte {
var buffer bytes.Buffer
err := png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, width, height)))
require.NoErrorf(tb, err, "failed to create png: %v", err)
return buffer.Bytes()
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"errors"
"io"
)
var ErrSizeLimitExceeded = errors.New("size limit exceeded")
type LimitedReaderWithError struct {
limitedReader *io.LimitedReader
}
func NewLimitedReaderWithError(reader io.Reader, maxBytes int64) *LimitedReaderWithError {
return &LimitedReaderWithError{
limitedReader: &io.LimitedReader{R: reader, N: maxBytes + 1},
}
}
func (l *LimitedReaderWithError) Read(p []byte) (int, error) {
n, err := l.limitedReader.Read(p)
if l.limitedReader.N <= 0 && err == io.EOF {
return n, ErrSizeLimitExceeded
}
return n, err
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"bytes"
"crypto/rand"
"io"
"testing"
"github.com/stretchr/testify/require"
)
func TestLimitedReaderWithError(t *testing.T) {
t.Run("read less than max size", func(t *testing.T) {
maxBytes := 10
randomBytes := make([]byte, maxBytes)
n, err := rand.Read(randomBytes)
require.NoError(t, err)
require.Equal(t, n, maxBytes)
lr := NewLimitedReaderWithError(bytes.NewReader(randomBytes), int64(maxBytes))
smallerBuf := make([]byte, maxBytes-3)
_, err = io.ReadFull(lr, smallerBuf)
require.NoError(t, err)
})
t.Run("read equal to max size", func(t *testing.T) {
maxBytes := 10
randomBytes := make([]byte, maxBytes)
n, err := rand.Read(randomBytes)
require.NoError(t, err)
require.Equal(t, n, maxBytes)
lr := NewLimitedReaderWithError(bytes.NewReader(randomBytes), int64(maxBytes))
buf := make([]byte, maxBytes)
_, err = io.ReadFull(lr, buf)
require.Truef(t, err == nil || err == io.EOF, "err must be nil or %v, got %v", io.EOF, err)
})
t.Run("single read, larger than max size", func(t *testing.T) {
maxBytes := 5
moreThanMaxBytes := maxBytes + 10
randomBytes := make([]byte, moreThanMaxBytes)
n, err := rand.Read(randomBytes)
require.NoError(t, err)
require.Equal(t, moreThanMaxBytes, n)
lr := NewLimitedReaderWithError(bytes.NewReader(randomBytes), int64(maxBytes))
buf := make([]byte, moreThanMaxBytes)
_, err = io.ReadFull(lr, buf)
require.Error(t, err)
require.Equal(t, ErrSizeLimitExceeded, err)
})
t.Run("multiple small reads, total larger than max size", func(t *testing.T) {
maxBytes := 10
lessThanMaxBytes := maxBytes - 4
randomBytesLen := maxBytes * 2
randomBytes := make([]byte, randomBytesLen)
n, err := rand.Read(randomBytes)
require.NoError(t, err)
require.Equal(t, randomBytesLen, n)
lr := NewLimitedReaderWithError(bytes.NewReader(randomBytes), int64(maxBytes))
buf := make([]byte, lessThanMaxBytes)
_, err = io.ReadFull(lr, buf)
require.NoError(t, err)
// lets do it again
_, err = io.ReadFull(lr, buf)
require.Error(t, err)
require.Equal(t, ErrSizeLimitExceeded, err)
})
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package fileutils
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mattermost/mattermost/server/v8"
)
func CommonBaseSearchPaths() []string {
paths := []string{
".",
"..",
"../..",
"../../..",
"../../../..",
}
// this enables the server to be used in tests from a different repository
paths = append(paths, server.GetPackagePath())
return paths
}
func findPath(path string, baseSearchPaths []string, workingDirFirst bool, filter func(os.FileInfo) bool) string {
if filepath.IsAbs(path) {
if _, err := os.Stat(path); err == nil {
return path
}
return ""
}
searchPaths := []string{}
if workingDirFirst {
searchPaths = append(searchPaths, baseSearchPaths...)
}
// Attempt to search relative to the location of the running binary either before
// or after searching relative to the working directory, depending on `workingDirFirst`.
var binaryDir string
if exe, err := os.Executable(); err == nil {
if exe, err = filepath.EvalSymlinks(exe); err == nil {
if exe, err = filepath.Abs(exe); err == nil {
binaryDir = filepath.Dir(exe)
}
}
}
if binaryDir != "" {
for _, baseSearchPath := range baseSearchPaths {
searchPaths = append(
searchPaths,
filepath.Join(binaryDir, baseSearchPath),
)
}
}
if !workingDirFirst {
searchPaths = append(searchPaths, baseSearchPaths...)
}
for _, parent := range searchPaths {
found, err := filepath.Abs(filepath.Join(parent, path))
if err != nil {
continue
} else if fileInfo, err := os.Stat(found); err == nil {
if filter != nil {
if filter(fileInfo) {
return found
}
} else {
return found
}
}
}
return ""
}
func FindPath(path string, baseSearchPaths []string, filter func(os.FileInfo) bool) string {
return findPath(path, baseSearchPaths, true, filter)
}
// FindFile looks for the given file in nearby ancestors relative to the current working
// directory as well as the directory of the executable.
func FindFile(path string) string {
return FindPath(path, CommonBaseSearchPaths(), func(fileInfo os.FileInfo) bool {
return !fileInfo.IsDir()
})
}
// fileutils.FindDir looks for the given directory in nearby ancestors relative to the current working
// directory as well as the directory of the executable, falling back to `./` if not found.
func FindDir(dir string) (string, bool) {
found := FindPath(dir, CommonBaseSearchPaths(), func(fileInfo os.FileInfo) bool {
return fileInfo.IsDir()
})
if found == "" {
return "./", false
}
return found, true
}
// FindDirRelBinary looks for the given directory in nearby ancestors relative to the
// directory of the executable, then relative to the working directory, falling back to `./` if not found.
func FindDirRelBinary(dir string) (string, bool) {
found := findPath(dir, CommonBaseSearchPaths(), false, func(fileInfo os.FileInfo) bool {
return fileInfo.IsDir()
})
if found == "" {
return "./", false
}
return found, true
}
// CheckDirectoryConflict checks if either directory is a subdirectory of the other.
// Returns true if there is a conflict (one is a subdirectory of the other or they are the same).
// Returns an error if the directory paths cannot be resolved.
// If a directory does not exist, the absolute path is used without symlink resolution.
func CheckDirectoryConflict(dir1, dir2 string) (bool, error) {
absDir1, err := filepath.Abs(dir1)
if err != nil {
return false, fmt.Errorf("failed to resolve absolute path for %q: %w", dir1, err)
}
absDir2, err := filepath.Abs(dir2)
if err != nil {
return false, fmt.Errorf("failed to resolve absolute path for %q: %w", dir2, err)
}
if resolved, err := filepath.EvalSymlinks(absDir1); err == nil {
absDir1 = resolved
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("failed to evaluate symlinks for %q: %w", dir1, err)
}
if resolved, err := filepath.EvalSymlinks(absDir2); err == nil {
absDir2 = resolved
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("failed to evaluate symlinks for %q: %w", dir2, err)
}
if !strings.HasSuffix(absDir1, string(filepath.Separator)) {
absDir1 += string(filepath.Separator)
}
if !strings.HasSuffix(absDir2, string(filepath.Separator)) {
absDir2 += string(filepath.Separator)
}
return strings.HasPrefix(absDir1, absDir2) || strings.HasPrefix(absDir2, absDir1), nil
}

View File

@@ -0,0 +1,295 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package fileutils
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindFile(t *testing.T) {
t.Run("files from various paths", func(t *testing.T) {
// Create the following directory structure:
// tmpDir1/
// file1.json
// file2.xml
// other.txt
// tmpDir2/
// other.txt/ [directory]
// tmpDir3/
// tmpDir4/
// tmpDir5/
tmpDir1, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer func() {
err = os.RemoveAll(tmpDir1)
require.NoError(t, err)
}()
tmpDir2, err := os.MkdirTemp(tmpDir1, "")
require.NoError(t, err)
err = os.Mkdir(filepath.Join(tmpDir2, "other.txt"), 0700)
require.NoError(t, err)
tmpDir3, err := os.MkdirTemp(tmpDir2, "")
require.NoError(t, err)
tmpDir4, err := os.MkdirTemp(tmpDir3, "")
require.NoError(t, err)
tmpDir5, err := os.MkdirTemp(tmpDir4, "")
require.NoError(t, err)
type testCase struct {
Description string
Cwd *string
FileName string
Expected string
}
testCases := []testCase{}
for _, fileName := range []string{"file1.json", "file2.xml", "other.txt"} {
filePath := filepath.Join(tmpDir1, fileName)
err = os.WriteFile(filePath, []byte("{}"), 0600)
require.NoError(t, err)
// Relative paths end up getting symlinks fully resolved, so use this below as necessary.
filePathResolved, err := filepath.EvalSymlinks(filePath)
require.NoError(t, err)
testCases = append(testCases, []testCase{
{
fmt.Sprintf("absolute path to %s", fileName),
nil,
filePath,
filePath,
},
{
fmt.Sprintf("absolute path to %s from containing directory", fileName),
&tmpDir1,
filePath,
filePath,
},
{
fmt.Sprintf("relative path to %s from containing directory", fileName),
&tmpDir1,
fileName,
filePathResolved,
},
{
fmt.Sprintf("%s: subdirectory of containing directory", fileName),
&tmpDir2,
fileName,
filePathResolved,
},
{
fmt.Sprintf("%s: twice-nested subdirectory of containing directory", fileName),
&tmpDir3,
fileName,
filePathResolved,
},
{
fmt.Sprintf("%s: thrice-nested subdirectory of containing directory", fileName),
&tmpDir4,
fileName,
filePathResolved,
},
{
fmt.Sprintf("%s: quadruple-nested subdirectory of containing directory", fileName),
&tmpDir5,
fileName,
filePathResolved,
},
}...)
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
if testCase.Cwd != nil {
t.Chdir(*testCase.Cwd)
}
assert.Equal(t, testCase.Expected, FindFile(testCase.FileName))
})
}
})
}
func TestCheckDirectoryConflict(t *testing.T) {
t.Run("separate directories", func(t *testing.T) {
tmpDir1, err := os.MkdirTemp("", "dir1")
require.NoError(t, err)
defer os.RemoveAll(tmpDir1)
tmpDir2, err := os.MkdirTemp("", "dir2")
require.NoError(t, err)
defer os.RemoveAll(tmpDir2)
conflict, err := CheckDirectoryConflict(tmpDir1, tmpDir2)
require.NoError(t, err)
assert.False(t, conflict)
})
t.Run("same directory", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "samedir")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
conflict, err := CheckDirectoryConflict(tmpDir, tmpDir)
require.NoError(t, err)
assert.True(t, conflict)
})
t.Run("first is subdirectory of second", func(t *testing.T) {
tmpDir1, err := os.MkdirTemp("", "parent")
require.NoError(t, err)
defer os.RemoveAll(tmpDir1)
tmpDir2 := filepath.Join(tmpDir1, "child")
err = os.MkdirAll(tmpDir2, 0700)
require.NoError(t, err)
conflict, err := CheckDirectoryConflict(tmpDir2, tmpDir1)
require.NoError(t, err)
assert.True(t, conflict)
})
t.Run("second is subdirectory of first", func(t *testing.T) {
tmpDir1, err := os.MkdirTemp("", "parent")
require.NoError(t, err)
defer os.RemoveAll(tmpDir1)
tmpDir2 := filepath.Join(tmpDir1, "child")
err = os.MkdirAll(tmpDir2, 0700)
require.NoError(t, err)
conflict, err := CheckDirectoryConflict(tmpDir1, tmpDir2)
require.NoError(t, err)
assert.True(t, conflict)
})
t.Run("deeply nested subdirectory", func(t *testing.T) {
tmpDir1, err := os.MkdirTemp("", "parent")
require.NoError(t, err)
defer os.RemoveAll(tmpDir1)
tmpDir2 := filepath.Join(tmpDir1, "a", "b", "c")
err = os.MkdirAll(tmpDir2, 0700)
require.NoError(t, err)
conflict, err := CheckDirectoryConflict(tmpDir1, tmpDir2)
require.NoError(t, err)
assert.True(t, conflict)
conflict, err = CheckDirectoryConflict(tmpDir2, tmpDir1)
require.NoError(t, err)
assert.True(t, conflict)
})
t.Run("symlinked directory", func(t *testing.T) {
tmpDir1, err := os.MkdirTemp("", "real")
require.NoError(t, err)
defer os.RemoveAll(tmpDir1)
tmpDir2, err := os.MkdirTemp("", "symlinks")
require.NoError(t, err)
defer os.RemoveAll(tmpDir2)
symlink := filepath.Join(tmpDir2, "link")
err = os.Symlink(tmpDir1, symlink)
require.NoError(t, err)
conflict, err := CheckDirectoryConflict(tmpDir1, symlink)
require.NoError(t, err)
assert.True(t, conflict)
})
t.Run("relative paths", func(t *testing.T) {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, "subdir")
err := os.MkdirAll(subDir, 0700)
require.NoError(t, err)
t.Chdir(tmpDir)
conflict, err := CheckDirectoryConflict(".", ".")
require.NoError(t, err)
assert.True(t, conflict)
conflict, err = CheckDirectoryConflict(".", "./subdir")
require.NoError(t, err)
assert.True(t, conflict)
conflict, err = CheckDirectoryConflict("./subdir", ".")
require.NoError(t, err)
assert.True(t, conflict)
})
t.Run("similar prefix but not subdirectory", func(t *testing.T) {
tmpDir1, err := os.MkdirTemp("", "dir")
require.NoError(t, err)
defer os.RemoveAll(tmpDir1)
tmpDir2, err := os.MkdirTemp("", "dir")
require.NoError(t, err)
defer os.RemoveAll(tmpDir2)
conflict, err := CheckDirectoryConflict(tmpDir1, tmpDir2)
require.NoError(t, err)
assert.False(t, conflict)
})
t.Run("non-existent directories are handled", func(t *testing.T) {
// Both directories non-existent but separate - no error, no conflict
conflict, err := CheckDirectoryConflict("/nonexistent/path1", "/nonexistent/path2")
require.NoError(t, err)
assert.False(t, conflict)
// Both directories non-existent, one nested in the other
conflict, err = CheckDirectoryConflict("/nonexistent/parent", "/nonexistent/parent/child")
require.NoError(t, err)
assert.True(t, conflict)
// Same non-existent directory
conflict, err = CheckDirectoryConflict("/nonexistent/same", "/nonexistent/same")
require.NoError(t, err)
assert.True(t, conflict)
})
t.Run("root directory", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "dir")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Root directory handled as a conflict with a subdirectory of the root
conflict, err := CheckDirectoryConflict("/", tmpDir)
require.NoError(t, err)
assert.True(t, conflict)
})
t.Run("overlap but not path prefix", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "dir")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
subDir1 := filepath.Join(tmpDir, "prefix")
subDir2 := filepath.Join(tmpDir, "prefix-but-different-folder")
err = os.Mkdir(subDir1, 0700)
require.NoError(t, err)
err = os.Mkdir(subDir2, 0700)
require.NoError(t, err)
// Two paths, one a string prefix but not an actual path prefix
conflict, err := CheckDirectoryConflict(subDir1, subDir2)
require.NoError(t, err)
assert.False(t, conflict)
})
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"crypto/sha256"
"encoding/hex"
)
func HashSha256(text string) string {
hash := sha256.New()
hash.Write([]byte(text))
return hex.EncodeToString(hash.Sum(nil))
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"strings"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func JoinList(items []string) string {
if len(items) == 0 {
return ""
} else if len(items) == 1 {
return items[0]
}
return i18n.T(
"humanize.list_join",
map[string]any{
"OtherItems": strings.Join(items[:len(items)-1], ", "),
"LastItem": items[len(items)-1],
})
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"fmt"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
// TranslationsPreInit loads translations from filesystem if they are not
// loaded already and assigns english while loading server config.
func TranslationsPreInit() error {
translationsDir := "i18n"
i18nDirectory, found := fileutils.FindDirRelBinary(translationsDir)
if !found {
return fmt.Errorf("unable to find i18n directory at %q", translationsDir)
}
return i18n.TranslationsPreInit(i18nDirectory)
}

View File

@@ -0,0 +1,505 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This is a modified version, the original copyright was: Copyright (c) 2011
// The Go Authors.
package imgutils
// This contains a portion of Go's image/go library, modified to count the number of frames in a gif without loading
// the entire image into memory.
import (
"bufio"
"compress/lzw"
"encoding/binary"
"errors"
"fmt"
"io"
)
var (
errNotEnough = errors.New("gif: not enough image data")
errTooMuch = errors.New("gif: too much image data")
)
// If the io.Reader does not also have ReadByte, then decode will introduce its own buffering.
type reader interface {
io.Reader
io.ByteReader
}
// Masks etc.
const (
// Fields.
fColorTable = 1 << 7
fColorTableBitsMask = 7
// Graphic control flags.
gcTransparentColorSet = 1 << 0
gcDisposalMethodMask = 7 << 2
)
// Disposal Methods.
const (
DisposalNone = 0x01
DisposalBackground = 0x02
DisposalPrevious = 0x03
)
// Section indicators.
const (
sExtension = 0x21
sImageDescriptor = 0x2C
sTrailer = 0x3B
)
// Extensions.
const (
eText = 0x01 // Plain Text
eGraphicControl = 0xF9 // Graphic Control
eComment = 0xFE // Comment
eApplication = 0xFF // Application
)
func readFull(r io.Reader, b []byte) error {
_, err := io.ReadFull(r, b)
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
return err
}
func readByte(r io.ByteReader) (byte, error) {
b, err := r.ReadByte()
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
return b, err
}
// decoder is the type used to decode a GIF file.
type decoder struct {
r reader
// From header.
vers string
width int
height int
loopCount int
delayTime int
backgroundIndex byte
disposalMethod byte
// From image descriptor.
imageFields byte
// From graphics control.
transparentIndex byte
hasTransparentIndex bool
// Computed.
hasGlobalColorTable bool
// Used when decoding.
imageCount int
tmp [1024]byte // must be at least 768 so we can read color table
}
// blockReader parses the block structure of GIF image data, which comprises
// (n, (n bytes)) blocks, with 1 <= n <= 255. It is the reader given to the
// LZW decoder, which is thus immune to the blocking. After the LZW decoder
// completes, there will be a 0-byte block remaining (0, ()), which is
// consumed when checking that the blockReader is exhausted.
//
// To avoid the allocation of a bufio.Reader for the lzw Reader, blockReader
// implements io.ReadByte and buffers blocks into the decoder's "tmp" buffer.
type blockReader struct {
d *decoder
i, j uint8 // d.tmp[i:j] contains the buffered bytes
err error
}
func (b *blockReader) fill() {
if b.err != nil {
return
}
b.j, b.err = readByte(b.d.r)
if b.j == 0 && b.err == nil {
b.err = io.EOF
}
if b.err != nil {
return
}
b.i = 0
b.err = readFull(b.d.r, b.d.tmp[:b.j])
if b.err != nil {
b.j = 0
}
}
func (b *blockReader) ReadByte() (byte, error) {
if b.i == b.j {
b.fill()
if b.err != nil {
return 0, b.err
}
}
c := b.d.tmp[b.i]
b.i++
return c, nil
}
// blockReader must implement io.Reader, but its Read shouldn't ever actually
// be called in practice. The compress/lzw package will only call ReadByte.
func (b *blockReader) Read(p []byte) (int, error) {
if len(p) == 0 || b.err != nil {
return 0, b.err
}
if b.i == b.j {
b.fill()
if b.err != nil {
return 0, b.err
}
}
n := copy(p, b.d.tmp[b.i:b.j])
b.i += uint8(n)
return n, nil
}
// close primarily detects whether or not a block terminator was encountered
// after reading a sequence of data sub-blocks. It allows at most one trailing
// sub-block worth of data. I.e., if some number of bytes exist in one sub-block
// following the end of LZW data, the very next sub-block must be the block
// terminator. If the very end of LZW data happened to fill one sub-block, at
// most one more sub-block of length 1 may exist before the block-terminator.
// These accommodations allow us to support GIFs created by less strict encoders.
// See https://golang.org/issue/16146.
func (b *blockReader) close() error {
if b.err == io.EOF {
// A clean block-sequence terminator was encountered while reading.
return nil
} else if b.err != nil {
// Some other error was encountered while reading.
return b.err
}
if b.i == b.j {
// We reached the end of a sub block reading LZW data. We'll allow at
// most one more sub block of data with a length of 1 byte.
b.fill()
if b.err == io.EOF {
return nil
} else if b.err != nil {
return b.err
} else if b.j > 1 {
return errTooMuch
}
}
// Part of a sub-block remains buffered. We expect that the next attempt to
// buffer a sub-block will reach the block terminator.
b.fill()
if b.err == io.EOF {
return nil
} else if b.err != nil {
return b.err
}
return errTooMuch
}
// decode reads a GIF image from r and stores the result in d.
func (d *decoder) decode(r io.Reader, configOnly bool) error {
// Add buffering if r does not provide ReadByte.
if rr, ok := r.(reader); ok {
d.r = rr
} else {
d.r = bufio.NewReader(r)
}
d.loopCount = -1
err := d.readHeaderAndScreenDescriptor()
if err != nil {
return err
}
if configOnly {
return nil
}
for {
c, err := readByte(d.r)
if err != nil {
return fmt.Errorf("gif: reading frames: %v", err)
}
switch c {
case sExtension:
if err = d.readExtension(); err != nil {
return err
}
case sImageDescriptor:
if err = d.readImageDescriptor(); err != nil {
return err
}
case sTrailer:
if d.imageCount == 0 {
return fmt.Errorf("gif: missing image data")
}
return nil
default:
return fmt.Errorf("gif: unknown block type: 0x%.2x", c)
}
}
}
func (d *decoder) readHeaderAndScreenDescriptor() error {
err := readFull(d.r, d.tmp[:13])
if err != nil {
return fmt.Errorf("gif: reading header: %v", err)
}
d.vers = string(d.tmp[:6])
if d.vers != "GIF87a" && d.vers != "GIF89a" {
return fmt.Errorf("gif: can't recognize format %q", d.vers)
}
d.width = int(d.tmp[6]) + int(d.tmp[7])<<8
d.height = int(d.tmp[8]) + int(d.tmp[9])<<8
if fields := d.tmp[10]; fields&fColorTable != 0 {
d.backgroundIndex = d.tmp[11]
// readColorTable overwrites the contents of d.tmp, but that's OK.
if err = d.readColorTable(fields); err != nil {
return err
}
d.hasGlobalColorTable = true
}
// d.tmp[12] is the Pixel Aspect Ratio, which is ignored.
return nil
}
func (d *decoder) readColorTable(fields byte) error {
n := 1 << (1 + uint(fields&fColorTableBitsMask))
err := readFull(d.r, d.tmp[:3*n])
if err != nil {
return fmt.Errorf("gif: reading color table: %s", err)
}
return nil
}
func (d *decoder) readExtension() error {
extension, err := readByte(d.r)
if err != nil {
return fmt.Errorf("gif: reading extension: %v", err)
}
size := 0
switch extension {
case eText:
size = 13
case eGraphicControl:
return d.readGraphicControl()
case eComment:
// nothing to do but read the data.
case eApplication:
b, err := readByte(d.r)
if err != nil {
return fmt.Errorf("gif: reading extension: %v", err)
}
// The spec requires size be 11, but Adobe sometimes uses 10.
size = int(b)
default:
return fmt.Errorf("gif: unknown extension 0x%.2x", extension)
}
if size > 0 {
if err := readFull(d.r, d.tmp[:size]); err != nil {
return fmt.Errorf("gif: reading extension: %v", err)
}
}
// Application Extension with "NETSCAPE2.0" as string and 1 in data means
// this extension defines a loop count.
if extension == eApplication && string(d.tmp[:size]) == "NETSCAPE2.0" {
n, err := d.readBlock()
if err != nil {
return fmt.Errorf("gif: reading extension: %v", err)
}
if n == 0 {
return nil
}
if n == 3 && d.tmp[0] == 1 {
d.loopCount = int(d.tmp[1]) | int(d.tmp[2])<<8
}
}
for {
n, err := d.readBlock()
if err != nil {
return fmt.Errorf("gif: reading extension: %v", err)
}
if n == 0 {
return nil
}
}
}
func (d *decoder) readGraphicControl() error {
if err := readFull(d.r, d.tmp[:6]); err != nil {
return fmt.Errorf("gif: can't read graphic control: %s", err)
}
if d.tmp[0] != 4 {
return fmt.Errorf("gif: invalid graphic control extension block size: %d", d.tmp[0])
}
flags := d.tmp[1]
d.disposalMethod = (flags & gcDisposalMethodMask) >> 2
d.delayTime = int(d.tmp[2]) | int(d.tmp[3])<<8
if flags&gcTransparentColorSet != 0 {
d.transparentIndex = d.tmp[4]
d.hasTransparentIndex = true
}
if d.tmp[5] != 0 {
return fmt.Errorf("gif: invalid graphic control extension block terminator: %d", d.tmp[5])
}
return nil
}
func (d *decoder) readImageDescriptor() error {
w, h, err := d.readImageDimensionsFromDescriptor()
if err != nil {
return err
}
useLocalColorTable := d.imageFields&fColorTable != 0
if useLocalColorTable {
if err = d.readColorTable(d.imageFields); err != nil {
return err
}
} else if !d.hasGlobalColorTable {
return errors.New("gif: no color table")
}
litWidth, err := readByte(d.r)
if err != nil {
return fmt.Errorf("gif: reading image data: %v", err)
}
if litWidth < 2 || litWidth > 8 {
return fmt.Errorf("gif: pixel size in decode out of range: %d", litWidth)
}
// A wonderfully Go-like piece of magic.
br := &blockReader{d: d}
lzwr := lzw.NewReader(br, lzw.LSB, int(litWidth))
defer lzwr.Close()
if _, err := io.Copy(io.Discard, io.LimitReader(lzwr, int64(w*h))); err != nil {
if err != io.ErrUnexpectedEOF {
return fmt.Errorf("gif: reading image data: %v", err)
}
return errNotEnough
}
// In theory, both lzwr and br should be exhausted. Reading from them
// should yield (0, io.EOF).
//
// The spec (Appendix F - Compression), says that "An End of
// Information code... must be the last code output by the encoder
// for an image". In practice, though, giflib (a widely used C
// library) does not enforce this, so we also accept lzwr returning
// io.ErrUnexpectedEOF (meaning that the encoded stream hit io.EOF
// before the LZW decoder saw an explicit end code), provided that
// the io.ReadFull call above successfully read len(m.Pix) bytes.
// See https://golang.org/issue/9856 for an example GIF.
if n, err := lzwr.Read(d.tmp[256:257]); n != 0 || (err != io.EOF && err != io.ErrUnexpectedEOF) {
if err != nil {
return fmt.Errorf("gif: reading image data: %v", err)
}
return errTooMuch
}
// In practice, some GIFs have an extra byte in the data sub-block
// stream, which we ignore. See https://golang.org/issue/16146.
if err := br.close(); err == errTooMuch {
return errTooMuch
} else if err != nil {
return fmt.Errorf("gif: reading image data: %v", err)
}
d.imageCount += 1
return nil
}
func (d *decoder) readImageDimensionsFromDescriptor() (int, int, error) {
if err := readFull(d.r, d.tmp[:9]); err != nil {
return 0, 0, fmt.Errorf("gif: can't read image descriptor: %s", err)
}
left := int(d.tmp[0]) + int(d.tmp[1])<<8
top := int(d.tmp[2]) + int(d.tmp[3])<<8
width := int(d.tmp[4]) + int(d.tmp[5])<<8
height := int(d.tmp[6]) + int(d.tmp[7])<<8
d.imageFields = d.tmp[8]
// The GIF89a spec, Section 20 (Image Descriptor) says: "Each image must
// fit within the boundaries of the Logical Screen, as defined in the
// Logical Screen Descriptor."
//
// This is conceptually similar to testing
// frameBounds := image.Rect(left, top, left+width, top+height)
// imageBounds := image.Rect(0, 0, d.width, d.height)
// if !frameBounds.In(imageBounds) { etc }
// but the semantics of the Go image.Rectangle type is that r.In(s) is true
// whenever r is an empty rectangle, even if r.Min.X > s.Max.X. Here, we
// want something stricter.
//
// Note that, by construction, left >= 0 && top >= 0, so we only have to
// explicitly compare frameBounds.Max (left+width, top+height) against
// imageBounds.Max (d.width, d.height) and not frameBounds.Min (left, top)
// against imageBounds.Min (0, 0).
if left+width > d.width || top+height > d.height {
return 0, 0, errors.New("gif: frame bounds larger than image bounds")
}
return width, height, nil
}
func (d *decoder) readBlock() (int, error) {
n, err := readByte(d.r)
if n == 0 || err != nil {
return 0, err
}
if err := readFull(d.r, d.tmp[:n]); err != nil {
return 0, err
}
return int(n), nil
}
func CountGIFFrames(r io.Reader) (int, error) {
var d decoder
if err := d.decode(r, false); err != nil {
return -1, err
}
return d.imageCount, nil
}
func GenGIFData(width, height uint16, nFrames int) []byte {
header := []byte{
'G', 'I', 'F', '8', '9', 'a', // header
0, 0, 0, 0, // width and height
128, 0, 0, // other header information
0, 0, 0, 1, 1, 1, // color table
}
binary.LittleEndian.PutUint16(header[6:], width)
binary.LittleEndian.PutUint16(header[8:], height)
frame := []byte{
0x2c, // block introducer
0, 0, 0, 0, 1, 0, 1, 0, // position and dimensions of the frame
0, // other frame information
0x2, 0x2, 0x4c, 0x1, 0, // encoded pixel data
}
trailer := []byte{0x3b}
gifData := header
for range nFrames {
gifData = append(gifData, frame...)
}
gifData = append(gifData, trailer...)
return gifData
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imgutils
import (
"bytes"
"image"
_ "image/gif"
"io"
"os"
"path/filepath"
"testing"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func readTestFile(t *testing.T, name string) ([]byte, error) {
t.Helper()
path, _ := fileutils.FindDir("tests")
file, err := os.Open(filepath.Join(path, name))
if err != nil {
return nil, err
}
defer file.Close()
data := &bytes.Buffer{}
if _, err := io.Copy(data, file); err != nil {
return nil, err
}
return data.Bytes(), nil
}
func TestGenGIFData(t *testing.T) {
data := GenGIFData(600, 400, 1)
img, format, err := image.DecodeConfig(bytes.NewReader(data))
require.NoError(t, err)
require.Equal(t, 600, img.Width)
require.Equal(t, 400, img.Height)
require.Equal(t, "gif", format)
}
func TestCountGIFFrames(t *testing.T) {
t.Run("should count the frames of a static gif", func(t *testing.T) {
gifData := GenGIFData(400, 400, 1)
count, err := CountGIFFrames(bytes.NewReader(gifData))
assert.NoError(t, err)
assert.Equal(t, 1, count)
})
t.Run("should count the frames of an animated gif", func(t *testing.T) {
gifData := GenGIFData(400, 400, 100)
count, err := CountGIFFrames(bytes.NewReader(gifData))
assert.NoError(t, err)
assert.Equal(t, 100, count)
})
t.Run("should count the frames of an actual animated gif", func(t *testing.T) {
b, err := readTestFile(t, "testgif.gif")
require.NoError(t, err)
count, err := CountGIFFrames(bytes.NewReader(b))
assert.NoError(t, err)
assert.Equal(t, 4, count)
})
t.Run("should return an error for a non-gif image", func(t *testing.T) {
b, err := readTestFile(t, "test.png")
require.NoError(t, err)
_, err = CountGIFFrames(bytes.NewReader(b))
assert.Error(t, err)
})
t.Run("should return an error for garbage data", func(t *testing.T) {
_, err := CountGIFFrames(bytes.NewReader([]byte("garbage data")))
assert.Error(t, err)
})
t.Run("should return an error for excessively large compressed data", func(t *testing.T) {
b, err := readTestFile(t, "large_lzw_frame.gif")
require.NoError(t, err)
_, err = CountGIFFrames(bytes.NewReader(b))
assert.Error(t, err)
assert.Equal(t, errTooMuch, err)
})
}

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwVMaUtQMFtQsRoa2FQd+
17+acMRMqzPsFTJEix7n+tA8ieCAX0lvNBOjVh8lrt/AUe7B3ZJ0HE6v8xuKj9YA
zUqV8R27UmnIxf5TCXFWrt+fnzX31yLjqK4Fd6JmiDheUatd1hG+gkScMAPuj+Xw
4O+V5sMwaVusytVgmehqcVKYrKpDyhBDUEbDRbKKbP4YAHbmNs3AzlGBbQtvc1fi
0ww7oNKs9cZVcCIeNdwbkXmf8pK5zSXqVsmyPyH3Ue8M6JMNGuPGh1fyHRhFdIiD
lxd7LBKSh7BTjbRtG9DEt1dyMnYQDgRVAdpururuK/otowCowr6X/Etnk2NNeXcZ
4QIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZmShlU8Z8HdG0IWSZ8r
tSyzyxrXkJjsFUf0Ke7bm/TLtIggRdqOcUF3XEWqQk5RGD5vuq7Rlg1zZqMEBk8N
EZeRhkxyaZW8pLjxwuBUOnXfJew31+gsTNdKZzRjrvPumKr3EtkleuoxNdoatu4E
HrKmR/4Yi71EqAvkhk7ZjQFuF0osSWJMEEGGCSUYQnTEqUzcZSh1BhVpkIkeu8Kk
1wCtptODixvEujgqVe+SrE3UlZjBmPjC/CL+3cYmufpSNgcEJm2mwsdaXp2OPpfn
a0v85XL6i9ote2P+fLZ3wX9EoioHzgdgB7arOxY50QRJO7OyCqpKFKv6lRWTXuSt
hwIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,297 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"crypto"
"crypto/rsa"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"maps"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
// ErrLicenseProductionInTestEnvironment indicates the license signature is valid against
// the production key, but this server is running in a test or development service
// environment (i.e. a production license uploaded to a test/dev server).
var ErrLicenseProductionInTestEnvironment = errors.New("license is a production license but the server is running in a test or development service environment")
// ErrLicenseTestInProductionEnvironment indicates the license signature is valid against
// the test key, but this server is running in a production service environment (i.e. a
// test/dev license uploaded to a production server).
var ErrLicenseTestInProductionEnvironment = errors.New("license is a test or development license but the server is running in a production service environment")
var LicenseValidator LicenseValidatorIface
func init() {
if LicenseValidator == nil {
LicenseValidator = &LicenseValidatorImpl{}
}
}
type LicenseValidatorIface interface {
LicenseFromBytes(licenseBytes []byte) (*model.License, *model.AppError)
ValidateLicense(signed []byte) (string, error)
}
type LicenseValidatorImpl struct {
}
func (l *LicenseValidatorImpl) LicenseFromBytes(licenseBytes []byte) (*model.License, *model.AppError) {
licenseStr, err := l.ValidateLicense(licenseBytes)
if err != nil {
return nil, NewLicenseValidationAppError("LicenseFromBytes", err)
}
var license model.License
if err := json.Unmarshal([]byte(licenseStr), &license); err != nil {
return nil, model.NewAppError("LicenseFromBytes", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &license, nil
}
func (l *LicenseValidatorImpl) ValidateLicense(signed []byte) (string, error) {
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(signed)))
_, err := base64.StdEncoding.Decode(decoded, signed)
if err != nil {
return "", fmt.Errorf("encountered error decoding license: %w", err)
}
// remove null terminator
for len(decoded) > 0 && decoded[len(decoded)-1] == byte(0) {
decoded = decoded[:len(decoded)-1]
}
if len(decoded) <= 256 {
return "", fmt.Errorf("Signed license not long enough")
}
plaintext := decoded[:len(decoded)-256]
signature := decoded[len(decoded)-256:]
primaryKey, alternateKey := licenseKeysForEnvironment(model.GetServiceEnvironment())
h := sha512.New()
h.Write(plaintext)
d := h.Sum(nil)
if err := verifyLicenseSignature(primaryKey, d, signature); err != nil {
// Only a genuine signature mismatch should trigger the wrong-environment
// fallback. Any other failure (e.g. malformed key material) means the
// current environment's key is broken, so surface it unchanged.
if !errors.Is(err, rsa.ErrVerification) {
return "", err
}
// The license did not verify against this environment's key. If it verifies
// against the other environment's key, the license is genuine but was signed
// for a different service environment, so report which way the mismatch goes.
if altErr := verifyLicenseSignature(alternateKey, d, signature); altErr == nil {
return "", wrongEnvironmentError(model.GetServiceEnvironment())
} else if !errors.Is(altErr, rsa.ErrVerification) {
return "", altErr
}
return "", fmt.Errorf("Invalid signature: %w", err)
}
return string(plaintext), nil
}
// wrongEnvironmentError returns the sentinel describing a license that is genuine but
// signed for the opposite service environment from the one this server runs in.
func wrongEnvironmentError(environment string) error {
if environment == model.ServiceEnvironmentProduction {
// Running production, but the license verified against the test key.
return ErrLicenseTestInProductionEnvironment
}
// Running test/dev, but the license verified against the production key.
return ErrLicenseProductionInTestEnvironment
}
// licenseKeysForEnvironment returns the public key for the given service environment
// (primary) along with the public key for the other environment (alternate).
func licenseKeysForEnvironment(environment string) (primary, alternate []byte) {
switch environment {
case model.ServiceEnvironmentProduction:
return productionPublicKey, testPublicKey
case model.ServiceEnvironmentTest, model.ServiceEnvironmentDev:
return testPublicKey, productionPublicKey
default:
return nil, nil
}
}
// verifyLicenseSignature verifies the RSA PKCS1v15 signature of a license digest against
// the provided PEM-encoded public key.
func verifyLicenseSignature(publicKey, digest, signature []byte) error {
block, _ := pem.Decode(publicKey)
if block == nil {
return fmt.Errorf("failed to decode public key PEM block")
}
public, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("encountered error parsing public key: %w", err)
}
rsaPublic, ok := public.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("public key is not an RSA key")
}
return rsa.VerifyPKCS1v15(rsaPublic, crypto.SHA512, digest, signature)
}
// NewLicenseValidationAppError converts a license validation error returned by
// ValidateLicense into a user-facing AppError, distinguishing a license signed for the
// wrong service environment from a generally invalid license.
func NewLicenseValidationAppError(where string, err error) *model.AppError {
switch {
case errors.Is(err, ErrLicenseProductionInTestEnvironment):
return model.NewAppError(where, model.WrongEnvironmentProductionLicenseError, nil, "", http.StatusBadRequest).Wrap(err)
case errors.Is(err, ErrLicenseTestInProductionEnvironment):
return model.NewAppError(where, model.WrongEnvironmentTestLicenseError, nil, "", http.StatusBadRequest).Wrap(err)
}
return model.NewAppError(where, model.InvalidLicenseError, nil, "", http.StatusBadRequest).Wrap(err)
}
func GetAndValidateLicenseFileFromDisk(location string) (*model.License, []byte, error) {
fileName := GetLicenseFileLocation(location)
mlog.Info("License key has not been uploaded. Loading license key from disk.", mlog.String("filename", fileName))
if _, err := os.Stat(fileName); err != nil {
return nil, nil, fmt.Errorf("We could not find the license key on disk at %s: %w", fileName, err)
}
licenseBytes := GetLicenseFileFromDisk(fileName)
licenseStr, err := LicenseValidator.ValidateLicense(licenseBytes)
if err != nil {
return nil, nil, fmt.Errorf("Found license key at %s but it appears to be invalid: %w", fileName, err)
}
var license model.License
if jsonErr := json.Unmarshal([]byte(licenseStr), &license); jsonErr != nil {
return nil, nil, fmt.Errorf("Found license key at %s but it appears to be invalid: %w", fileName, jsonErr)
}
return &license, licenseBytes, nil
}
func GetLicenseFileFromDisk(fileName string) []byte {
file, err := os.Open(fileName)
if err != nil {
mlog.Error("Failed to open license key from disk at", mlog.String("filename", fileName), mlog.Err(err))
return nil
}
defer file.Close()
licenseBytes, err := io.ReadAll(file)
if err != nil {
mlog.Error("Failed to read license key from disk at", mlog.String("filename", fileName), mlog.Err(err))
return nil
}
return licenseBytes
}
func GetLicenseFileLocation(fileLocation string) string {
if fileLocation == "" {
configDir, _ := fileutils.FindDir("config")
return filepath.Join(configDir, "mattermost.mattermost-license")
}
return fileLocation
}
func GetClientLicense(l *model.License) map[string]string {
props := make(map[string]string)
props["IsLicensed"] = strconv.FormatBool(l != nil)
if l != nil {
props["Id"] = l.Id
props["SkuName"] = l.SkuName
props["SkuShortName"] = l.SkuShortName
props["Users"] = strconv.Itoa(*l.Features.Users)
props["LDAP"] = strconv.FormatBool(*l.Features.LDAP)
props["LDAPGroups"] = strconv.FormatBool(*l.Features.LDAPGroups)
props["MFA"] = strconv.FormatBool(*l.Features.MFA)
props["SAML"] = strconv.FormatBool(*l.Features.SAML)
props["Cluster"] = strconv.FormatBool(*l.Features.Cluster)
props["Metrics"] = strconv.FormatBool(*l.Features.Metrics)
props["GoogleOAuth"] = strconv.FormatBool(*l.Features.GoogleOAuth)
props["Office365OAuth"] = strconv.FormatBool(*l.Features.Office365OAuth)
props["OpenId"] = strconv.FormatBool(*l.Features.OpenId)
props["Compliance"] = strconv.FormatBool(*l.Features.Compliance)
props["MHPNS"] = strconv.FormatBool(*l.Features.MHPNS)
props["Announcement"] = strconv.FormatBool(*l.Features.Announcement)
props["Elasticsearch"] = strconv.FormatBool(*l.Features.Elasticsearch)
props["DataRetention"] = strconv.FormatBool(*l.Features.DataRetention)
props["IDLoadedPushNotifications"] = strconv.FormatBool(*l.Features.IDLoadedPushNotifications)
props["IssuedAt"] = strconv.FormatInt(l.IssuedAt, 10)
props["StartsAt"] = strconv.FormatInt(l.StartsAt, 10)
props["ExpiresAt"] = strconv.FormatInt(l.ExpiresAt, 10)
props["Name"] = l.Customer.Name
props["Email"] = l.Customer.Email
props["Company"] = l.Customer.Company
props["EmailNotificationContents"] = strconv.FormatBool(*l.Features.EmailNotificationContents)
props["MessageExport"] = strconv.FormatBool(*l.Features.MessageExport)
props["CustomPermissionsSchemes"] = strconv.FormatBool(*l.Features.CustomPermissionsSchemes)
props["GuestAccounts"] = strconv.FormatBool(*l.Features.GuestAccounts)
props["GuestAccountsPermissions"] = strconv.FormatBool(*l.Features.GuestAccountsPermissions)
props["CustomTermsOfService"] = strconv.FormatBool(*l.Features.CustomTermsOfService)
props["LockTeammateNameDisplay"] = strconv.FormatBool(*l.Features.LockTeammateNameDisplay)
props["Cloud"] = strconv.FormatBool(*l.Features.Cloud)
props["SharedChannels"] = strconv.FormatBool(*l.Features.SharedChannels)
props["RemoteClusterService"] = strconv.FormatBool(*l.Features.RemoteClusterService)
props["OutgoingOAuthConnections"] = strconv.FormatBool(*l.Features.OutgoingOAuthConnections)
props["IsTrial"] = strconv.FormatBool(l.IsTrial)
props["IsGovSku"] = strconv.FormatBool(l.IsGovSku)
props["IsNonProduction"] = strconv.FormatBool(l.IsNonProduction)
// Consumers must split on "," not substring match, so "crossguard" is not
// satisfied by "crossguard-premium".
props["AddOns"] = strings.Join(l.AddOns, ",")
}
return props
}
func GetSanitizedClientLicense(l map[string]string) map[string]string {
sanitizedLicense := make(map[string]string)
maps.Copy(sanitizedLicense, l)
delete(sanitizedLicense, "Id")
delete(sanitizedLicense, "Name")
delete(sanitizedLicense, "Email")
delete(sanitizedLicense, "IssuedAt")
delete(sanitizedLicense, "StartsAt")
delete(sanitizedLicense, "ExpiresAt")
delete(sanitizedLicense, "SkuName")
// Do not strip AddOns. Only identity and date fields are removed above;
// entitlements survive, and add-ons are entitlements. The license_changed
// websocket event broadcasts this map and the webapp replaces its license
// object wholesale, so anything stripped here is lost from the System Console
// after any license change. Narrowing this endpoint is tracked in MM-68045.
return sanitizedLicense
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import _ "embed"
//go:embed license-public-key.txt
var productionPublicKey []byte
//go:embed license-public-key-test.txt
var testPublicKey []byte

View File

@@ -0,0 +1,333 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"os"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var validTestLicense = []byte("eyJpZCI6InpvZ3c2NW44Z2lmajVkbHJoYThtYnUxcGl3IiwiaXNzdWVkX2F0IjoxNjg0Nzg3MzcxODY5LCJzdGFydHNfYXQiOjE2ODQ3ODczNzE4NjksImV4cGlyZXNfYXQiOjIwMDA0MDY1MzgwMDAsInNrdV9uYW1lIjoiUHJvZmVzc2lvbmFsIiwic2t1X3Nob3J0X25hbWUiOiJwcm9mZXNzaW9uYWwiLCJjdXN0b21lciI6eyJpZCI6InA5dW4zNjlhNjdnaW1qNHlkNmk2aWIzOXdoIiwibmFtZSI6Ik1hdHRlcm1vc3QiLCJlbWFpbCI6ImpvcmFtQG1hdHRlcm1vc3QuY29tIiwiY29tcGFueSI6Ik1hdHRlcm1vc3QifSwiZmVhdHVyZXMiOnsidXNlcnMiOjIwMDAwMCwibGRhcCI6dHJ1ZSwibGRhcF9ncm91cHMiOmZhbHNlLCJtZmEiOnRydWUsImdvb2dsZV9vYXV0aCI6dHJ1ZSwib2ZmaWNlMzY1X29hdXRoIjp0cnVlLCJjb21wbGlhbmNlIjpmYWxzZSwiY2x1c3RlciI6dHJ1ZSwibWV0cmljcyI6dHJ1ZSwibWhwbnMiOnRydWUsInNhbWwiOnRydWUsImVsYXN0aWNfc2VhcmNoIjp0cnVlLCJhbm5vdW5jZW1lbnQiOnRydWUsInRoZW1lX21hbmFnZW1lbnQiOmZhbHNlLCJlbWFpbF9ub3RpZmljYXRpb25fY29udGVudHMiOmZhbHNlLCJkYXRhX3JldGVudGlvbiI6ZmFsc2UsIm1lc3NhZ2VfZXhwb3J0IjpmYWxzZSwiY3VzdG9tX3Blcm1pc3Npb25zX3NjaGVtZXMiOmZhbHNlLCJjdXN0b21fdGVybXNfb2Zfc2VydmljZSI6ZmFsc2UsImd1ZXN0X2FjY291bnRzIjp0cnVlLCJndWVzdF9hY2NvdW50c19wZXJtaXNzaW9ucyI6dHJ1ZSwiaWRfbG9hZGVkIjpmYWxzZSwibG9ja190ZWFtbWF0ZV9uYW1lX2Rpc3BsYXkiOmZhbHNlLCJjbG91ZCI6ZmFsc2UsInNoYXJlZF9jaGFubmVscyI6ZmFsc2UsInJlbW90ZV9jbHVzdGVyX3NlcnZpY2UiOmZhbHNlLCJvcGVuaWQiOnRydWUsImVudGVycHJpc2VfcGx1Z2lucyI6dHJ1ZSwiYWR2YW5jZWRfbG9nZ2luZyI6dHJ1ZSwiZnV0dXJlX2ZlYXR1cmVzIjpmYWxzZX0sImlzX3RyaWFsIjp0cnVlLCJpc19nb3Zfc2t1IjpmYWxzZX0bEOVk2GdE1kSWKJ3dENWnkj0htY6QyXTtNA5hqnQ71Uc6teqXc7htHAxrnT/hV42xu+G24OMrAIsQtX4NjFSX6jvehIMRL5II3RPXYhHKUd2wruQ5ITEh1htFb5DgOJW3tvBdMmXt09nXjLRS1UYJ7ZsX3mU0uQndt7qfMriGAkk71veYuUJgztB3MsV7lRWB+8ZTp6WJ7RH+uWnuDspiA8B85mLnyuoCDokYksF2uIb+CtPGBTUB6qSOgxBBJxu5qftQXISCDAWY4O8lCrN3p5HCA/zf/rSRRNtet06QFobbjUDI4B7ZEAescKBKoHpP6nZPhg4KmhnkUi/o04ox")
func TestValidateLicense(t *testing.T) {
t.Run("should fail with junk data", func(t *testing.T) {
b1 := []byte("junk")
_, err := LicenseValidator.ValidateLicense(b1)
require.Error(t, err, "should have failed - bad license")
b2 := []byte("junkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunk")
_, err = LicenseValidator.ValidateLicense(b2)
require.Error(t, err, "should have failed - bad license")
})
t.Run("should not panic on shorter than expected input", func(t *testing.T) {
var licenseData bytes.Buffer
var inputData []byte
for range 255 {
inputData = append(inputData, 'A')
}
inputData = append(inputData, 0x00)
encoder := base64.NewEncoder(base64.StdEncoding, &licenseData)
_, err := encoder.Write(inputData)
require.NoError(t, err)
err = encoder.Close()
require.NoError(t, err)
str, err := LicenseValidator.ValidateLicense(licenseData.Bytes())
require.Error(t, err)
require.Empty(t, str)
})
t.Run("should not panic with input filled of null terminators", func(t *testing.T) {
var licenseData bytes.Buffer
var inputData []byte
for range 256 {
inputData = append(inputData, 0x00)
}
encoder := base64.NewEncoder(base64.StdEncoding, &licenseData)
_, err := encoder.Write(inputData)
require.NoError(t, err)
err = encoder.Close()
require.NoError(t, err)
str, err := LicenseValidator.ValidateLicense(licenseData.Bytes())
require.Error(t, err)
require.Empty(t, str)
})
t.Run("should reject invalid license in test service environment", func(t *testing.T) {
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
str, err := LicenseValidator.ValidateLicense(nil)
require.Error(t, err)
require.Empty(t, str)
})
t.Run("should validate valid test license in test service environment", func(t *testing.T) {
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
str, err := LicenseValidator.ValidateLicense(validTestLicense)
require.NoError(t, err)
require.NotEmpty(t, str)
})
t.Run("should reject valid test license in production service environment", func(t *testing.T) {
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentProduction)
str, err := LicenseValidator.ValidateLicense(validTestLicense)
require.Error(t, err)
require.ErrorIs(t, err, ErrLicenseTestInProductionEnvironment)
require.Empty(t, str)
})
t.Run("should handle corrupted public key without panicking", func(t *testing.T) {
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
mockValidator := &LicenseValidatorImpl{}
originalTestKey := testPublicKey
defer func() { testPublicKey = originalTestKey }()
testPublicKey = []byte("not a valid PEM block")
str, err := mockValidator.ValidateLicense(validTestLicense)
require.Error(t, err)
require.Empty(t, str)
require.Contains(t, err.Error(), "failed to decode public key PEM block")
})
t.Run("broken primary key is surfaced and not misreported as a wrong-environment license", func(t *testing.T) {
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
// Corrupt the primary (test) key so its verification fails for a non-signature
// reason, while standing in a generated key as the alternate (production) key
// and signing a license with it. The broken primary key must be surfaced
// rather than the license being misreported as wrong-environment.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
originalTestKey := testPublicKey
originalProductionKey := productionPublicKey
defer func() {
testPublicKey = originalTestKey
productionPublicKey = originalProductionKey
}()
testPublicKey = []byte("not a valid PEM block")
productionPublicKey = marshalPublicKeyPEM(t, &priv.PublicKey)
signed := signLicense(t, priv, []byte(`{"id":"emulated-production-license"}`))
str, err := LicenseValidator.ValidateLicense(signed)
require.Error(t, err)
require.Empty(t, str)
require.NotErrorIs(t, err, ErrLicenseProductionInTestEnvironment)
require.NotErrorIs(t, err, ErrLicenseTestInProductionEnvironment)
require.Contains(t, err.Error(), "failed to decode public key PEM block")
})
}
func TestLicenseFromBytesEnvironmentMismatch(t *testing.T) {
t.Run("test license uploaded to a production server returns the wrong-environment error", func(t *testing.T) {
t.Skip("Skipped due to flakiness — tracked in https://mattermost.atlassian.net/browse/MM-70560")
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentProduction)
license, appErr := LicenseValidator.LicenseFromBytes(validTestLicense)
require.Nil(t, license)
require.NotNil(t, appErr)
require.Equal(t, model.WrongEnvironmentTestLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("production license uploaded to a test/dev server returns the wrong-environment error", func(t *testing.T) {
t.Skip("Skipped due to flakiness — tracked in https://mattermost.atlassian.net/browse/MM-70560")
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
// We cannot sign with the real production key, so stand in a generated key as
// the production key and sign a license with it to emulate a production license
// arriving at a test server.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
originalProductionKey := productionPublicKey
defer func() { productionPublicKey = originalProductionKey }()
productionPublicKey = marshalPublicKeyPEM(t, &priv.PublicKey)
signed := signLicense(t, priv, []byte(`{"id":"emulated-production-license"}`))
license, appErr := LicenseValidator.LicenseFromBytes(signed)
require.Nil(t, license)
require.NotNil(t, appErr)
require.Equal(t, model.WrongEnvironmentProductionLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("genuinely corrupt license returns the generic invalid error", func(t *testing.T) {
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentTest)
// A blob long enough to pass the length check but signed by no known key.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
corrupt := signLicense(t, priv, []byte(`{"id":"corrupt-license"}`))
license, appErr := LicenseValidator.LicenseFromBytes(corrupt)
require.Nil(t, license)
require.NotNil(t, appErr)
require.Equal(t, model.InvalidLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
}
func TestNewLicenseValidationAppError(t *testing.T) {
t.Run("maps the production-in-test sentinel to WrongEnvironmentProductionLicenseError", func(t *testing.T) {
appErr := NewLicenseValidationAppError("Test", fmt.Errorf("wrap: %w", ErrLicenseProductionInTestEnvironment))
require.Equal(t, model.WrongEnvironmentProductionLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
require.ErrorIs(t, appErr, ErrLicenseProductionInTestEnvironment)
})
t.Run("maps the test-in-production sentinel to WrongEnvironmentTestLicenseError", func(t *testing.T) {
appErr := NewLicenseValidationAppError("Test", fmt.Errorf("wrap: %w", ErrLicenseTestInProductionEnvironment))
require.Equal(t, model.WrongEnvironmentTestLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
require.ErrorIs(t, appErr, ErrLicenseTestInProductionEnvironment)
})
t.Run("maps any other error to InvalidLicenseError", func(t *testing.T) {
appErr := NewLicenseValidationAppError("Test", fmt.Errorf("Invalid signature"))
require.Equal(t, model.InvalidLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
require.NotErrorIs(t, appErr, ErrLicenseProductionInTestEnvironment)
require.NotErrorIs(t, appErr, ErrLicenseTestInProductionEnvironment)
})
}
func signLicense(t *testing.T, priv *rsa.PrivateKey, payload []byte) []byte {
t.Helper()
h := sha512.New()
h.Write(payload)
digest := h.Sum(nil)
signature, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA512, digest)
require.NoError(t, err)
raw := append(append([]byte{}, payload...), signature...)
return []byte(base64.StdEncoding.EncodeToString(raw))
}
func marshalPublicKeyPEM(t *testing.T, pub *rsa.PublicKey) []byte {
t.Helper()
der, err := x509.MarshalPKIXPublicKey(pub)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})
}
func TestGetLicenseFileLocation(t *testing.T) {
fileName := GetLicenseFileLocation("")
require.NotEmpty(t, fileName, "invalid default file name")
fileName = GetLicenseFileLocation("mattermost.mattermost-license")
require.Equal(t, fileName, "mattermost.mattermost-license", "invalid file name")
}
func TestGetClientLicense(t *testing.T) {
license := &model.License{
Customer: &model.Customer{},
Features: &model.Features{},
}
license.Features.SetDefaults()
props := GetClientLicense(license)
require.Equal(t, "false", props["IsNonProduction"])
license.IsNonProduction = true
props = GetClientLicense(license)
require.Equal(t, "true", props["IsNonProduction"])
// The flag must survive sanitization so all users can see the non-production banner.
sanitized := GetSanitizedClientLicense(props)
require.Equal(t, "true", sanitized["IsNonProduction"])
}
func TestGetClientLicenseAddOns(t *testing.T) {
newLicense := func(addOns ...string) *model.License {
license := &model.License{
Customer: &model.Customer{},
Features: &model.Features{},
AddOns: addOns,
}
license.Features.SetDefaults()
return license
}
t.Run("no add-ons", func(t *testing.T) {
props := GetClientLicense(newLicense())
require.Equal(t, "", props["AddOns"])
})
t.Run("single add-on", func(t *testing.T) {
props := GetClientLicense(newLicense("crossguard"))
require.Equal(t, "crossguard", props["AddOns"])
})
t.Run("multiple add-ons are comma separated", func(t *testing.T) {
props := GetClientLicense(newLicense("crossguard", "another"))
require.Equal(t, "crossguard,another", props["AddOns"])
})
t.Run("survives sanitization", func(t *testing.T) {
// Anything stripped here disappears from the System Console after a license
// change. Do not add AddOns to the delete list.
props := GetClientLicense(newLicense("crossguard"))
require.Equal(t, "crossguard", props["AddOns"])
sanitized := GetSanitizedClientLicense(props)
require.Equal(t, "crossguard", sanitized["AddOns"], "AddOns must survive sanitization")
})
}
func TestGetLicenseFileFromDisk(t *testing.T) {
t.Run("missing file", func(t *testing.T) {
fileBytes := GetLicenseFileFromDisk("thisfileshouldnotexist.mattermost-license")
assert.Empty(t, fileBytes, "invalid bytes")
})
t.Run("not a license file", func(t *testing.T) {
f, err := os.CreateTemp("", "TestGetLicenseFileFromDisk")
require.NoError(t, err)
defer os.Remove(f.Name())
err = os.WriteFile(f.Name(), []byte("not a license"), 0777)
require.NoError(t, err)
fileBytes := GetLicenseFileFromDisk(f.Name())
require.NotEmpty(t, fileBytes, "should have read the file")
_, err = LicenseValidator.ValidateLicense(fileBytes)
assert.Error(t, err, "should have been an invalid file")
})
}

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"html"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
astExt "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// StripMarkdown removes markdown syntax from text and returns plain text.
func StripMarkdown(markdown string) (string, error) {
md := goldmark.New(
goldmark.WithExtensions(extension.Strikethrough),
goldmark.WithRenderer(
renderer.NewRenderer(renderer.WithNodeRenderers(
util.Prioritized(newNotificationRenderer(), 500),
)),
),
)
var buf strings.Builder
if err := md.Convert([]byte(markdown), &buf); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
// StripMarkdownAndDecode removes markdown syntax and decodes HTML entities
// (both named like &lt; and numeric like &#60;) to their character equivalents.
// This is useful for plain text contexts like mobile push notifications where
// HTML will not be rendered.
//
// SECURITY NOTE: The output is intended for plain text contexts only.
// Do NOT use in contexts where HTML could be rendered without proper escaping.
func StripMarkdownAndDecode(markdown string) (string, error) {
stripped, err := StripMarkdown(markdown)
if err != nil {
return "", err
}
return html.UnescapeString(stripped), nil
}
var relLinkReg = regexp.MustCompile(`\[(.*)]\((/.*)\)`)
var blockquoteReg = regexp.MustCompile(`^|\n(&gt;)`)
// MarkdownToHTML takes a string containing Markdown and returns a string with HTML tagged version
func MarkdownToHTML(markdown, siteURL string) (string, error) {
// Turn relative links into absolute links
absLinkMarkdown := relLinkReg.ReplaceAllStringFunc(markdown, func(s string) string {
return relLinkReg.ReplaceAllString(s, "[$1]("+siteURL+"$2)")
})
// Unescape any blockquote text to be parsed by the markdown parser.
markdownClean := blockquoteReg.ReplaceAllStringFunc(absLinkMarkdown, func(s string) string {
return html.UnescapeString(s)
})
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
)
var b strings.Builder
err := md.Convert([]byte(markdownClean), &b)
if err != nil {
return "", err
}
return b.String(), nil
}
type notificationRenderer struct {
}
func newNotificationRenderer() *notificationRenderer {
return &notificationRenderer{}
}
func (r *notificationRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// block
reg.Register(ast.KindDocument, r.renderDefault)
reg.Register(ast.KindHeading, r.renderItem)
reg.Register(ast.KindBlockquote, r.renderDefault)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
reg.Register(ast.KindHTMLBlock, r.renderDefault)
reg.Register(ast.KindList, r.renderDefault)
reg.Register(ast.KindListItem, r.renderItem)
reg.Register(ast.KindParagraph, r.renderItem)
reg.Register(ast.KindTextBlock, r.renderTextBlock)
reg.Register(ast.KindThematicBreak, r.renderDefault)
// inlines
reg.Register(ast.KindAutoLink, r.renderDefault)
reg.Register(ast.KindCodeSpan, r.renderDefault)
reg.Register(ast.KindEmphasis, r.renderDefault)
reg.Register(ast.KindImage, r.renderDefault)
reg.Register(ast.KindLink, r.renderDefault)
reg.Register(ast.KindRawHTML, r.renderDefault)
reg.Register(ast.KindText, r.renderText)
reg.Register(ast.KindString, r.renderString)
// strikethrough
reg.Register(astExt.KindStrikethrough, r.renderDefault)
}
// renderDefault renderer function to renderDefault without changes
func (r *notificationRenderer) renderDefault(_ util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkContinue, nil
}
func (r *notificationRenderer) renderItem(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
if node.NextSibling() != nil {
_ = w.WriteByte(' ')
}
}
return ast.WalkContinue, nil
}
func (r *notificationRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.CodeBlock)
if entering {
r.writeLines(w, source, n)
}
return ast.WalkContinue, nil
}
func (r *notificationRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.FencedCodeBlock)
if entering {
r.writeLines(w, source, n)
}
return ast.WalkContinue, nil
}
func (r *notificationRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
segment := n.Segment
_, _ = w.Write(segment.Value(source))
if !n.IsRaw() {
if n.HardLineBreak() || n.SoftLineBreak() {
_ = w.WriteByte('\n')
}
}
return ast.WalkContinue, nil
}
func (r *notificationRenderer) renderTextBlock(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
if node.NextSibling() != nil && node.FirstChild() != nil {
_ = w.WriteByte(' ')
}
}
return ast.WalkContinue, nil
}
func (r *notificationRenderer) renderString(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.String)
_, _ = w.Write(n.Value)
return ast.WalkContinue, nil
}
func (r *notificationRenderer) writeLines(w util.BufWriter, source []byte, n ast.Node) {
for i := range n.Lines().Len() {
line := n.Lines().At(i)
value := line.Value(source)
_, _ = w.Write(value)
}
}

View File

@@ -0,0 +1,492 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
)
// stripMarkdownTestCase defines a test case for markdown stripping functions.
type stripMarkdownTestCase struct {
name string
args string
want string
}
// getStripMarkdownTestCases returns the shared test cases for StripMarkdown and StripMarkdownAndDecode.
// These test cases do not contain HTML entities that would be decoded differently by the two functions.
func getStripMarkdownTestCases() []stripMarkdownTestCase {
return []stripMarkdownTestCase{
{
name: "emoji: same",
args: "Hey :smile: :+1: :)",
want: "Hey :smile: :+1: :)",
},
{
name: "at-mention: same",
args: "Hey @user and @test",
want: "Hey @user and @test",
},
{
name: "channel-link: same",
args: "join ~channelname",
want: "join ~channelname",
},
{
name: "codespan: single backtick",
args: "`single backtick`",
want: "single backtick",
},
{
name: "codespan: double backtick",
args: "``double backtick``",
want: "double backtick",
},
{
name: "codespan: triple backtick",
args: "```triple backtick```",
want: "triple backtick",
},
{
name: "codespan: inline code",
args: "Inline `code` has ``double backtick`` and ```triple backtick``` around it.",
want: "Inline code has double backtick and triple backtick around it.",
},
{
name: "code block: single line code block",
args: "Code block\n```\nline\n```",
want: "Code block line",
},
{
name: "code block: multiline code block 2",
args: "Multiline\n```\nfunction(number) {\n return number + 1;\n}\n```",
want: "Multiline function(number) {\n return number + 1;\n}",
},
{
name: "code block: language highlighting",
args: "```javascript\nvar s = \"JavaScript syntax highlighting\";\nalert(s);\n```",
want: "var s = \"JavaScript syntax highlighting\";\nalert(s);",
},
{
name: "blockquote:",
args: "> Hey quote",
want: "Hey quote",
},
{
name: "blockquote: multiline",
args: "> Hey quote.\n> Hello quote.",
want: "Hey quote.\nHello quote.",
},
{
name: "heading: # H1 header",
args: "# H1 header",
want: "H1 header",
},
{
name: "heading: heading with @user",
args: "# H1 @user",
want: "H1 @user",
},
{
name: "heading: ## H2 header",
args: "## H2 header",
want: "H2 header",
},
{
name: "heading: ### H3 header",
args: "### H3 header",
want: "H3 header",
},
{
name: "heading: #### H4 header",
args: "#### H4 header",
want: "H4 header",
},
{
name: "heading: ##### H5 header",
args: "##### H5 header",
want: "H5 header",
},
{
name: "heading: ###### H6 header",
args: "###### H6 header",
want: "H6 header",
},
{
name: "heading: multiline with header and paragraph",
args: "###### H6 header\nThis is next line.\nAnother line.",
want: "H6 header This is next line.\nAnother line.",
},
{
name: "heading: multiline with header and list items",
args: "###### H6 header\n- list item 1\n- list item 2",
want: "H6 header list item 1 list item 2",
},
{
name: "heading: multiline with header and links",
args: "###### H6 header\n[link 1](https://mattermost.com) - [link 2](https://mattermost.com)",
want: "H6 header link 1 - link 2",
},
{
name: "list: 1. First ordered list item",
args: "1. First ordered list item",
want: "First ordered list item",
},
{
name: "list: 2. Another item",
args: "1. 2. Another item",
want: "Another item",
},
{
name: "list: * Unordered sub-list.",
args: "* Unordered sub-list.",
want: "Unordered sub-list.",
},
{
name: "list: - Or minuses",
args: "- Or minuses",
want: "Or minuses",
},
{
name: "list: + Or pluses",
args: "+ Or pluses",
want: "Or pluses",
},
{
name: "list: multiline",
args: "1. First ordered list item\n2. Another item",
want: "First ordered list item Another item",
},
{
name: "tablerow:)",
args: "Markdown | Less | Pretty\n" +
"--- | --- | ---\n" +
"*Still* | `renders` | **nicely**\n" +
"1 | 2 | 3\n",
want: "Markdown | Less | Pretty\n" +
"--- | --- | ---\n" +
"Still | renders | nicely\n" +
"1 | 2 | 3",
},
{
name: "table:",
args: "| Tables | Are | Cool |\n" +
"| ------------- |:-------------:| -----:|\n" +
"| col 3 is | right-aligned | $1600 |\n" +
"| col 2 is | centered | $12 |\n" +
"| zebra stripes | are neat | $1 |\n",
want: "| Tables | Are | Cool |\n" +
"| ------------- |:-------------:| -----:|\n" +
"| col 3 is | right-aligned | $1600 |\n" +
"| col 2 is | centered | $12 |\n" +
"| zebra stripes | are neat | $1 |",
},
{
name: "strong: Bold with **asterisks** or __underscores__.",
args: "Bold with **asterisks** or __underscores__.",
want: "Bold with asterisks or underscores.",
},
{
name: "strong & em: Bold and italics with **asterisks and _underscores_**.",
args: "Bold and italics with **asterisks and _underscores_**.",
want: "Bold and italics with asterisks and underscores.",
},
{
name: "em: Italics with *asterisks* or _underscores_.",
args: "Italics with *asterisks* or _underscores_.",
want: "Italics with asterisks or underscores.",
},
{
name: "del: Strikethrough ~~strike this.~~",
args: "Strikethrough ~~strike this.~~",
want: "Strikethrough strike this.",
},
{
name: "links: [inline-style link](http://localhost:8065)",
args: "[inline-style link](http://localhost:8065)",
want: "inline-style link",
},
{
name: "image: ![image link](http://localhost:8065/image)",
args: "![image link](http://localhost:8065/image)",
want: "image link",
},
{
name: "text: plain",
args: "This is plain text.",
want: "This is plain text.",
},
{
name: "text: multiline",
args: "This is multiline text.\nHere is the next line.\n",
want: "This is multiline text.\nHere is the next line.",
},
{
name: "text: multiline with blockquote",
args: "This is multiline text.\n> With quote",
want: "This is multiline text. With quote",
},
{
name: "text: multiline with list items",
args: "This is multiline text.\n * List item ",
want: "This is multiline text. List item",
},
{
name: "text: &amp; entity",
args: "you & me",
want: "you & me",
},
{
name: "text: &lt; entity",
args: "1<2",
want: "1<2",
},
{
name: "text: &gt; entity",
args: "2>1",
want: "2>1",
},
{
name: "text: &#39; entity",
args: "he's out",
want: "he's out",
},
{
name: "text: &quot; entity",
args: `That is "unique"`,
want: `That is "unique"`,
},
{
name: "text: multiple entities",
args: "&<>'",
want: "&<>'",
},
{
name: "text: multiple entities reversed",
args: "'><&",
want: "'><&",
},
{
name: "text: empty string",
args: "",
want: "",
},
}
}
func TestStripMarkdown(t *testing.T) {
tests := getStripMarkdownTestCases()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := StripMarkdown(tt.args)
if err != nil {
t.Fatalf("error: %v", err)
}
assert.Equal(t, tt.want, got)
})
}
}
func TestStripMarkdownAndDecode(t *testing.T) {
// First, run the shared test cases - StripMarkdownAndDecode should produce the same
// results as StripMarkdown for inputs without HTML entities
t.Run("shared test cases", func(t *testing.T) {
tests := getStripMarkdownTestCases()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := StripMarkdownAndDecode(tt.args)
if err != nil {
t.Fatalf("error: %v", err)
}
assert.Equal(t, tt.want, got)
})
}
})
// Additional test cases specific to HTML entity decoding
t.Run("HTML entity decoding", func(t *testing.T) {
entityTests := []stripMarkdownTestCase{
// Named HTML entities
{
name: "named entity: &lt;",
args: "1 &lt; 2",
want: "1 < 2",
},
{
name: "named entity: &gt;",
args: "2 &gt; 1",
want: "2 > 1",
},
{
name: "named entity: &amp;",
args: "you &amp; me",
want: "you & me",
},
{
name: "named entity: &quot;",
args: "&quot;quoted&quot;",
want: `"quoted"`,
},
{
name: "named entity: &apos;",
args: "it&apos;s fine",
want: "it's fine",
},
// Decimal numeric entities (as used by the plugin)
{
name: "numeric entity: &#33; (exclamation)",
args: "Hello&#33;",
want: "Hello!",
},
{
name: "numeric entity: &#35; (hash)",
args: "&#35;channel",
want: "#channel",
},
{
name: "numeric entity: &#40; and &#41; (parentheses)",
args: "func&#40;arg&#41;",
want: "func(arg)",
},
{
name: "numeric entity: &#42; (asterisk)",
args: "&#42;bold&#42;",
want: "*bold*",
},
{
name: "numeric entity: &#43; (plus)",
args: "1 &#43; 1",
want: "1 + 1",
},
{
name: "numeric entity: &#45; (dash)",
args: "a &#45; b",
want: "a - b",
},
{
name: "numeric entity: &#46; (period)",
args: "end&#46;",
want: "end.",
},
{
name: "numeric entity: &#47; (forward slash)",
args: "path&#47;to&#47;file",
want: "path/to/file",
},
{
name: "numeric entity: &#58; (colon)",
args: "key&#58; value",
want: "key: value",
},
{
name: "numeric entity: &#60; and &#62; (angle brackets)",
args: "&#60;tag&#62;",
want: "<tag>",
},
{
name: "numeric entity: &#91; and &#93; (square brackets)",
args: "&#91;link&#93;",
want: "[link]",
},
{
name: "numeric entity: &#92; (backslash)",
args: "path&#92;file",
want: "path\\file",
},
{
name: "numeric entity: &#95; (underscore)",
args: "snake&#95;case",
want: "snake_case",
},
{
name: "numeric entity: &#96; (backtick)",
args: "&#96;code&#96;",
want: "`code`",
},
{
name: "numeric entity: &#124; (vertical bar)",
args: "a &#124; b",
want: "a | b",
},
{
name: "numeric entity: &#126; (tilde)",
args: "&#126;channel",
want: "~channel",
},
// Mixed content
{
name: "mixed: markdown and entities",
args: "**bold** and &#60;tag&#62;",
want: "bold and <tag>",
},
{
name: "mixed: multiple numeric entities",
args: "&#33;&#35;&#40;&#41;&#42;",
want: "!#()*",
},
{
name: "mixed: sentence with encoded punctuation",
args: "Hello&#33; How are you&#63;",
want: "Hello! How are you?",
},
// Edge cases
{
name: "invalid entity: preserved as-is after decode",
args: "&invalid;",
want: "&invalid;",
},
{
name: "partial entity: ampersand alone",
args: "Tom & Jerry",
want: "Tom & Jerry",
},
}
for _, tt := range entityTests {
t.Run(tt.name, func(t *testing.T) {
got, err := StripMarkdownAndDecode(tt.args)
if err != nil {
t.Fatalf("error: %v", err)
}
assert.Equal(t, tt.want, got)
})
}
})
}
func TestMarkdownToHTML(t *testing.T) {
siteURL := "https://example.com"
tests := []struct {
name string
markdown string
want string
}{
{
name: "absolute url not changed",
markdown: "[Link](https://example.com)",
want: "<p><a href=\"https://example.com\">Link</a></p>\n",
},
{
name: "relative url changed to absolute url",
markdown: "[Link](/foo)",
want: "<p><a href=\"https://example.com/foo\">Link</a></p>\n",
},
{
name: "relative url with query params changed to absolute url",
markdown: "[Link](/foo?bar=true)",
want: "<p><a href=\"https://example.com/foo?bar=true\">Link</a></p>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := MarkdownToHTML(tt.markdown, siteURL)
if err != nil {
t.Fatalf("error: %v", err)
}
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -0,0 +1,170 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"fmt"
"reflect"
)
// StructFieldFilter defines a callback function used to decide if a patch value should be applied.
type StructFieldFilter func(structField reflect.StructField, base reflect.Value, patch reflect.Value) bool
// MergeConfig allows for optional merge customizations.
type MergeConfig struct {
StructFieldFilter StructFieldFilter
}
// Merge will return a new value of the same type as base and patch, recursively merging non-nil values from patch on top of base.
//
// Restrictions/guarantees:
// - base and patch must be the same type
// - base and patch will never be modified
// - values from patch are always selected when non-nil
// - structs are merged recursively
// - maps and slices are treated as pointers, and merged as a single value
//
// Note that callers need to cast the returned interface back into the original type:
//
// func mergeTestStruct(base, patch *testStruct) (*testStruct, error) {
// ret, err := merge(base, patch)
// if err != nil {
// return nil, err
// }
//
// retTS := ret.(testStruct)
// return &retTS, nil
// }
func Merge[T any](base T, patch T, mergeConfig *MergeConfig) (T, error) {
commonType := reflect.TypeOf(base)
baseVal := reflect.ValueOf(base)
patchVal := reflect.ValueOf(patch)
ret := reflect.New(commonType)
val, ok := merge(baseVal, patchVal, mergeConfig)
if ok {
ret.Elem().Set(val)
}
r, ok := ret.Elem().Interface().(T)
if !ok {
return r, fmt.Errorf(
"Unexpected type of return element, expected %s, is %s",
commonType,
reflect.TypeOf(r),
)
}
return r, nil
}
// merge recursively merges patch into base and returns the new struct, ptr, slice/map, or value
func merge(base, patch reflect.Value, mergeConfig *MergeConfig) (reflect.Value, bool) {
commonType := base.Type()
switch commonType.Kind() {
case reflect.Struct:
merged := reflect.New(commonType).Elem()
for i := range base.NumField() {
if !merged.Field(i).CanSet() {
continue
}
if mergeConfig != nil && mergeConfig.StructFieldFilter != nil {
if !mergeConfig.StructFieldFilter(commonType.Field(i), base.Field(i), patch.Field(i)) {
merged.Field(i).Set(base.Field(i))
continue
}
}
val, ok := merge(base.Field(i), patch.Field(i), mergeConfig)
if ok {
merged.Field(i).Set(val)
}
}
return merged, true
case reflect.Pointer:
mergedPtr := reflect.New(commonType.Elem())
if base.IsNil() && patch.IsNil() {
return mergedPtr, false
}
// clone reference values (if any)
if base.IsNil() {
val, _ := merge(patch.Elem(), patch.Elem(), mergeConfig)
mergedPtr.Elem().Set(val)
} else if patch.IsNil() {
val, _ := merge(base.Elem(), base.Elem(), mergeConfig)
mergedPtr.Elem().Set(val)
} else {
val, _ := merge(base.Elem(), patch.Elem(), mergeConfig)
mergedPtr.Elem().Set(val)
}
return mergedPtr, true
case reflect.Slice:
if base.IsNil() && patch.IsNil() {
return reflect.Zero(commonType), false
}
if !patch.IsNil() {
// use patch
merged := reflect.MakeSlice(commonType, 0, patch.Len())
for i := range patch.Len() {
// recursively merge patch with itself. This will clone reference values.
val, _ := merge(patch.Index(i), patch.Index(i), mergeConfig)
merged = reflect.Append(merged, val)
}
return merged, true
}
// use base
merged := reflect.MakeSlice(commonType, 0, base.Len())
for i := range base.Len() {
// recursively merge base with itself. This will clone reference values.
val, _ := merge(base.Index(i), base.Index(i), mergeConfig)
merged = reflect.Append(merged, val)
}
return merged, true
case reflect.Map:
// maps are merged according to these rules:
// - if patch is not nil, replace the base map completely
// - otherwise, keep the base map
// - reference values (eg. slice/ptr/map) will be cloned
if base.IsNil() && patch.IsNil() {
return reflect.Zero(commonType), false
}
merged := reflect.MakeMap(commonType)
mapPtr := base
if !patch.IsNil() {
mapPtr = patch
}
for _, key := range mapPtr.MapKeys() {
// clone reference values
val, ok := merge(mapPtr.MapIndex(key), mapPtr.MapIndex(key), mergeConfig)
if !ok {
val = reflect.New(mapPtr.MapIndex(key).Type()).Elem()
}
merged.SetMapIndex(key, val)
}
return merged, true
case reflect.Interface:
var val reflect.Value
if base.IsNil() && patch.IsNil() {
return reflect.Zero(commonType), false
}
// clone reference values (if any)
if base.IsNil() {
val, _ = merge(patch.Elem(), patch.Elem(), mergeConfig)
} else if patch.IsNil() {
val, _ = merge(base.Elem(), base.Elem(), mergeConfig)
} else {
val, _ = merge(base.Elem(), patch.Elem(), mergeConfig)
}
return val, true
default:
return patch, true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make misc-mocks`.
package mocks
import (
model "github.com/mattermost/mattermost/server/public/model"
mock "github.com/stretchr/testify/mock"
)
// LicenseValidatorIface is an autogenerated mock type for the LicenseValidatorIface type
type LicenseValidatorIface struct {
mock.Mock
}
// LicenseFromBytes provides a mock function with given fields: licenseBytes
func (_m *LicenseValidatorIface) LicenseFromBytes(licenseBytes []byte) (*model.License, *model.AppError) {
ret := _m.Called(licenseBytes)
if len(ret) == 0 {
panic("no return value specified for LicenseFromBytes")
}
var r0 *model.License
var r1 *model.AppError
if rf, ok := ret.Get(0).(func([]byte) (*model.License, *model.AppError)); ok {
return rf(licenseBytes)
}
if rf, ok := ret.Get(0).(func([]byte) *model.License); ok {
r0 = rf(licenseBytes)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.License)
}
}
if rf, ok := ret.Get(1).(func([]byte) *model.AppError); ok {
r1 = rf(licenseBytes)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// ValidateLicense provides a mock function with given fields: signed
func (_m *LicenseValidatorIface) ValidateLicense(signed []byte) (string, error) {
ret := _m.Called(signed)
if len(ret) == 0 {
panic("no return value specified for ValidateLicense")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func([]byte) (string, error)); ok {
return rf(signed)
}
if rf, ok := ret.Get(0).(func([]byte) string); ok {
r0 = rf(signed)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func([]byte) error); ok {
r1 = rf(signed)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewLicenseValidatorIface creates a new instance of LicenseValidatorIface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewLicenseValidatorIface(t interface {
mock.TestingT
Cleanup(func())
}) *LicenseValidatorIface {
mock := &LicenseValidatorIface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,582 @@
{
"restrictTeamInvite": {
"all": [
{
"roleName": "team_user",
"permission": "invite_user",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "team_user",
"permission": "invite_user",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "invite_user",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "team_user",
"permission": "invite_user",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "invite_user",
"shouldHave": false
}
]
},
"restrictPublicChannelCreation": {
"all": [
{
"roleName": "team_user",
"permission": "create_public_channel",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "team_user",
"permission": "create_public_channel",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "create_public_channel",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "team_user",
"permission": "create_public_channel",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "create_public_channel",
"shouldHave": false
}
]
},
"restrictPrivateChannelCreation": {
"all": [
{
"roleName": "team_user",
"permission": "create_private_channel",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "team_user",
"permission": "create_private_channel",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "create_private_channel",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "team_user",
"permission": "create_private_channel",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "create_private_channel",
"shouldHave": false
}
]
},
"restrictPublicChannelManagement": {
"all": [
{
"roleName": "channel_user",
"permission": "manage_public_channel_properties",
"shouldHave": true
}
],
"channel_admin": [
{
"roleName": "channel_user",
"permission": "manage_public_channel_properties",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_public_channel_properties",
"shouldHave": true
},
{
"roleName": "team_admin",
"permission": "manage_public_channel_properties",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "channel_user",
"permission": "manage_public_channel_properties",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_public_channel_properties",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "manage_public_channel_properties",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "channel_user",
"permission": "manage_public_channel_properties",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_public_channel_properties",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "manage_public_channel_properties",
"shouldHave": false
}
]
},
"restrictPublicChannelDeletion": {
"all": [
{
"roleName": "channel_user",
"permission": "delete_public_channel",
"shouldHave": true
}
],
"channel_admin": [
{
"roleName": "channel_user",
"permission": "delete_public_channel",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "delete_public_channel",
"shouldHave": true
},
{
"roleName": "team_admin",
"permission": "delete_public_channel",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "channel_user",
"permission": "delete_public_channel",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "delete_public_channel",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "delete_public_channel",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "channel_user",
"permission": "delete_public_channel",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "delete_public_channel",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "delete_public_channel",
"shouldHave": false
}
]
},
"restrictPrivateChannelManagement": {
"all": [
{
"roleName": "channel_user",
"permission": "manage_private_channel_properties",
"shouldHave": true
}
],
"channel_admin": [
{
"roleName": "channel_user",
"permission": "manage_private_channel_properties",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_private_channel_properties",
"shouldHave": true
},
{
"roleName": "team_admin",
"permission": "manage_private_channel_properties",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "channel_user",
"permission": "manage_private_channel_properties",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_private_channel_properties",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "manage_private_channel_properties",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "channel_user",
"permission": "manage_private_channel_properties",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_private_channel_properties",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "manage_private_channel_properties",
"shouldHave": false
}
]
},
"restrictPrivateChannelManageMembers": {
"all": [
{
"roleName": "channel_user",
"permission": "manage_private_channel_members",
"shouldHave": true
}
],
"channel_admin": [
{
"roleName": "channel_user",
"permission": "manage_private_channel_members",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_private_channel_members",
"shouldHave": true
},
{
"roleName": "team_admin",
"permission": "manage_private_channel_members",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "channel_user",
"permission": "manage_private_channel_members",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_private_channel_members",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "manage_private_channel_members",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "channel_user",
"permission": "manage_private_channel_members",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "manage_private_channel_members",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "manage_private_channel_members",
"shouldHave": false
}
]
},
"restrictPrivateChannelDeletion": {
"all": [
{
"roleName": "channel_user",
"permission": "delete_private_channel",
"shouldHave": true
}
],
"channel_admin": [
{
"roleName": "channel_user",
"permission": "delete_private_channel",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "delete_private_channel",
"shouldHave": true
},
{
"roleName": "team_admin",
"permission": "delete_private_channel",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "channel_user",
"permission": "delete_private_channel",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "delete_private_channel",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "delete_private_channel",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "channel_user",
"permission": "delete_private_channel",
"shouldHave": false
},
{
"roleName": "channel_admin",
"permission": "delete_private_channel",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "delete_private_channel",
"shouldHave": false
}
]
},
"allowEditPost": {
"always": [
{
"roleName": "channel_user",
"permission": "edit_post",
"shouldHave": true
},
{
"roleName": "system_admin",
"permission": "edit_post",
"shouldHave": true
}
],
"never": [
{
"roleName": "channel_user",
"permission": "edit_post",
"shouldHave": false
},
{
"roleName": "system_admin",
"permission": "edit_post",
"shouldHave": true
}
]
},
"restrictPostDelete": {
"all": [
{
"roleName": "channel_user",
"permission": "delete_post",
"shouldHave": true
},
{
"roleName": "team_admin",
"permission": "delete_post",
"shouldHave": true
},
{
"roleName": "team_admin",
"permission": "delete_others_posts",
"shouldHave": true
}
],
"team_admin": [
{
"roleName": "channel_user",
"permission": "delete_post",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "delete_post",
"shouldHave": true
},
{
"roleName": "team_admin",
"permission": "delete_others_posts",
"shouldHave": true
}
],
"system_admin": [
{
"roleName": "channel_user",
"permission": "delete_post",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "delete_post",
"shouldHave": false
},
{
"roleName": "team_admin",
"permission": "delete_others_posts",
"shouldHave": false
}
]
},
"enableTeamCreation": {
"true": [
{
"roleName": "system_user",
"permission": "create_team",
"shouldHave": true
}
],
"false": [
{
"roleName": "system_user",
"permission": "create_team",
"shouldHave": false
}
]
},
"enableOnlyAdminIntegrations": {
"true": [
{
"roleName": "team_user",
"permission": "manage_incoming_webhooks",
"shouldHave": false
},
{
"roleName": "team_user",
"permission": "manage_own_incoming_webhooks",
"shouldHave": false
},
{
"roleName": "team_user",
"permission": "manage_outgoing_webhooks",
"shouldHave": false
},
{
"roleName": "team_user",
"permission": "manage_own_outgoing_webhooks",
"shouldHave": false
},
{
"roleName": "team_user",
"permission": "manage_slash_commands",
"shouldHave": false
},
{
"roleName": "team_user",
"permission": "manage_own_slash_commands",
"shouldHave": false
},
{
"roleName": "team_user",
"permission": "bypass_incoming_webhook_channel_lock",
"shouldHave": false
},
{
"roleName": "system_user",
"permission": "manage_oauth",
"shouldHave": false
}
],
"false": [
{
"roleName": "team_user",
"permission": "manage_incoming_webhooks",
"shouldHave": true
},
{
"roleName": "team_user",
"permission": "manage_own_incoming_webhooks",
"shouldHave": true
},
{
"roleName": "team_user",
"permission": "manage_outgoing_webhooks",
"shouldHave": true
},
{
"roleName": "team_user",
"permission": "manage_own_outgoing_webhooks",
"shouldHave": true
},
{
"roleName": "team_user",
"permission": "manage_slash_commands",
"shouldHave": true
},
{
"roleName": "team_user",
"permission": "manage_own_slash_commands",
"shouldHave": true
},
{
"roleName": "team_user",
"permission": "bypass_incoming_webhook_channel_lock",
"shouldHave": true
},
{
"roleName": "system_user",
"permission": "manage_oauth",
"shouldHave": true
}
]
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"math/rand"
)
type Range struct {
Begin int
End int
}
func RandIntFromRange(r Range) int {
if r.End-r.Begin <= 0 {
return r.Begin
}
return rand.Intn((r.End-r.Begin)+1) + r.Begin
}

View File

@@ -0,0 +1,223 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
// getSubpathScript renders the inline script that defines window.publicPath to change how webpack loads assets.
func getSubpathScript(subpath string) string {
if subpath == "" || subpath == "/" {
return ""
}
newPath := path.Join(subpath, "static") + "/"
return fmt.Sprintf("window.publicPath='%s'", newPath)
}
// GetScriptHash computes the script-src addition required for an inline script injected into the root.html.
func GetScriptHash(script string) string {
// No hash is required when there's no script
if script == "" {
return ""
}
scriptHash := sha256.Sum256([]byte(script))
return fmt.Sprintf(" 'sha256-%s'", base64.StdEncoding.EncodeToString(scriptHash[:]))
}
// GetStaticScriptHashes computes the combined script-src additions required for the inline scripts injected
// into root.html to bypass CSP protections.
func GetStaticScriptHashes(subpath string) string {
return GetScriptHash(getSubpathScript(subpath))
}
// UpdateAssetsSubpathInDir rewrites static assets in the given directory so that HTML and CSS files assume the
// application is hosted at the given subpath instead of at the root.
//
// No changes are written unless necessary.
func UpdateAssetsSubpathInDir(subpath, directory string) error {
if subpath == "" {
subpath = "/"
}
// Resolve the static directory
staticDir, found := fileutils.FindDir(directory)
if !found {
return errors.New("failed to find client dir")
}
staticDir, err := filepath.EvalSymlinks(staticDir)
if err != nil {
return errors.Wrapf(err, "failed to resolve symlinks to %s", staticDir)
}
// Read the old root.html file
rootHTMLPath := filepath.Join(staticDir, "root.html")
oldRootHTML, err := os.ReadFile(rootHTMLPath)
if err != nil {
return errors.Wrap(err, "failed to open root.html")
}
oldSubpath := "/"
// Determine if a previous subpath had already been rewritten into the assets.
reWebpackPublicPathScript := regexp.MustCompile("window.publicPath='([^']+/)static/'")
alreadyRewritten := false
if matches := reWebpackPublicPathScript.FindStringSubmatch(string(oldRootHTML)); matches != nil {
oldSubpath = matches[1]
alreadyRewritten = true
}
// Determine the old and new paths
pathToReplace := path.Join(oldSubpath, "static") + "/"
newPath := path.Join(subpath, "static") + "/"
// Update the root.html file
if err := updateRootFile(string(oldRootHTML), rootHTMLPath, alreadyRewritten, pathToReplace, newPath, subpath); err != nil {
return fmt.Errorf("failed to update root.html: %w", err)
}
// Update the manifest.json and *.css files
if err := updateManifestAndCSSFiles(staticDir, pathToReplace, newPath, subpath); err != nil {
return fmt.Errorf("failed to update manifest.json and *.css files: %w", err)
}
return nil
}
func updateRootFile(oldRootHTML string, rootHTMLPath string, alreadyRewritten bool, pathToReplace, newPath, subpath string) error {
newRootHTML := oldRootHTML
reCSP := regexp.MustCompile(`<meta http-equiv="Content-Security-Policy" content="script-src 'self'([^"]*)">`)
if results := reCSP.FindAllString(newRootHTML, -1); len(results) == 0 {
return fmt.Errorf("failed to find 'Content-Security-Policy' meta tag to rewrite")
}
newRootHTML = reCSP.ReplaceAllLiteralString(newRootHTML, fmt.Sprintf(
`<meta http-equiv="Content-Security-Policy" content="script-src 'self'%s">`,
GetStaticScriptHashes(subpath),
))
// Rewrite the root.html references to `/static/*` to include the given subpath.
// This potentially includes a previously injected inline script that needs to
// be updated (and isn't covered by the cases above).
newRootHTML = strings.Replace(newRootHTML, pathToReplace, newPath, -1)
publicPathInWindowsScriptRegex := regexp.MustCompile(`(?s)<script id="publicPathInWindowScript">(.*?)</script>`)
if alreadyRewritten && subpath == "/" {
// Remove window global publicPath definition if subpath is root
newRootHTML = publicPathInWindowsScriptRegex.ReplaceAllLiteralString(newRootHTML, "<script id=\"publicPathInWindowScript\"></script>")
} else if !alreadyRewritten && subpath != "/" {
// Inject the script to define `window.publicPath` for the specified subpath
subpathScript := getSubpathScript(subpath)
newRootHTML = publicPathInWindowsScriptRegex.ReplaceAllLiteralString(newRootHTML, fmt.Sprintf("<script id=\"publicPathInWindowScript\">%s</script>", subpathScript))
}
if newRootHTML == oldRootHTML {
mlog.Debug("No need to rewrite unmodified root.html", mlog.String("from_subpath", pathToReplace), mlog.String("to_subpath", newPath))
return nil
}
mlog.Debug("Rewriting root.html", mlog.String("from_subpath", pathToReplace), mlog.String("to_subpath", newPath))
// Write out the updated root.html.
if err := os.WriteFile(rootHTMLPath, []byte(newRootHTML), 0); err != nil {
return errors.Wrapf(err, "failed to update root.html with subpath %s", subpath)
}
return nil
}
func updateManifestAndCSSFiles(staticDir, pathToReplace, newPath, subpath string) error {
if pathToReplace == newPath {
mlog.Debug("No need to rewrite unmodified manifest.json and *.css files", mlog.String("from_subpath", pathToReplace), mlog.String("to_subpath", newPath))
return nil
}
mlog.Debug("Rewriting manifest.json and *.css files", mlog.String("from_subpath", pathToReplace), mlog.String("to_subpath", newPath))
// Rewrite the manifest.json and *.css references to `/static/*` (or a previously rewritten subpath).
err := filepath.Walk(staticDir, func(walkPath string, info os.FileInfo, err error) error {
if filepath.Base(walkPath) == "manifest.json" || filepath.Ext(walkPath) == ".css" {
old, err := os.ReadFile(walkPath)
if err != nil {
return errors.Wrapf(err, "failed to open %s", walkPath)
}
n := strings.Replace(string(old), pathToReplace, newPath, -1)
if err = os.WriteFile(walkPath, []byte(n), 0); err != nil {
return errors.Wrapf(err, "failed to update %s with subpath %s", walkPath, subpath)
}
}
return nil
})
if err != nil {
return errors.Wrapf(err, "error walking %s", staticDir)
}
return nil
}
// UpdateAssetsSubpath rewrites assets in the /client directory to assume the application is hosted
// at the given subpath instead of at the root. No changes are written unless necessary.
func UpdateAssetsSubpath(subpath string) error {
return UpdateAssetsSubpathInDir(subpath, model.ClientDir)
}
// UpdateAssetsSubpathFromConfig uses UpdateAssetsSubpath and any path defined in the SiteURL.
func UpdateAssetsSubpathFromConfig(config *model.Config) error {
// Don't rewrite in development environments, since webpack in developer mode constantly
// updates the assets and must be configured separately.
if model.BuildNumber == "dev" {
mlog.Debug("Skipping update to assets subpath since dev build")
return nil
}
// Similarly, don't rewrite during a CI build, or a local testing build, when the assets may not even be present.
if os.Getenv("IS_CI") == "true" || os.Getenv("IS_LOCAL_TESTING") == "true" {
mlog.Debug("Skipping update to assets subpath since CI build")
return nil
}
subpath, err := GetSubpathFromConfig(config)
if err != nil {
return err
}
return UpdateAssetsSubpath(subpath)
}
func GetSubpathFromConfig(config *model.Config) (string, error) {
if config == nil {
return "", errors.New("no config provided")
} else if config.ServiceSettings.SiteURL == nil {
return "/", nil
}
u, err := url.Parse(*config.ServiceSettings.SiteURL)
if err != nil {
return "", errors.Wrap(err, "failed to parse SiteURL from config")
}
if u.Path == "" {
return "/", nil
}
return path.Clean(u.Path), nil
}

View File

@@ -0,0 +1,478 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils_test
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
func TestUpdateAssetsSubpathFromConfig(t *testing.T) {
t.Run("dev build", func(t *testing.T) {
var oldBuildNumber = model.BuildNumber
model.BuildNumber = "dev"
defer func() {
model.BuildNumber = oldBuildNumber
}()
err := utils.UpdateAssetsSubpathFromConfig(nil)
require.NoError(t, err)
})
t.Run("IS_CI=true", func(t *testing.T) {
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("IS_CI", "true")
err := utils.UpdateAssetsSubpathFromConfig(nil)
require.NoError(t, err)
})
t.Run("no config", func(t *testing.T) {
// Explicitly clear IS_CI so UpdateAssetsSubpathFromConfig doesn't return early.
// IS_CI is "true" in GitHub Actions runners and persists from the sibling subtest.
t.Setenv("IS_CI", "")
tempDir := t.TempDir()
t.Chdir(tempDir)
err := utils.UpdateAssetsSubpathFromConfig(nil)
require.Error(t, err)
})
}
func TestUpdateAssetsSubpath(t *testing.T) {
t.Run("no client dir", func(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)
err := utils.UpdateAssetsSubpath("/")
require.Error(t, err)
})
t.Run("valid", func(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)
err := os.Mkdir(model.ClientDir, 0700)
require.NoError(t, err)
testCases := []struct {
Description string
RootHTML string
MainCSS string
ManifestJSON string
Subpath string
ExpectedError error
ExpectedRootHTML string
ExpectedMainCSS string
ExpectedManifestJSON string
}{
{
"no changes required, empty subpath provided",
baseRootHTML,
baseCSS,
baseManifestJSON,
"",
nil,
baseRootHTML,
baseCSS,
baseManifestJSON,
},
{
"no changes required",
baseRootHTML,
baseCSS,
baseManifestJSON,
"/",
nil,
baseRootHTML,
baseCSS,
baseManifestJSON,
},
{
"content security policy not found (missing quotes)",
contentSecurityPolicyNotFoundHTML,
baseCSS,
baseManifestJSON,
"/subpath",
fmt.Errorf("failed to update root.html: %w", fmt.Errorf("failed to find 'Content-Security-Policy' meta tag to rewrite")),
contentSecurityPolicyNotFoundHTML,
baseCSS,
baseManifestJSON,
},
{
"content security policy not found (missing unsafe-eval)",
contentSecurityPolicyNotFound2HTML,
baseCSS,
baseManifestJSON,
"/subpath",
fmt.Errorf("failed to update root.html: %w", fmt.Errorf("failed to find 'Content-Security-Policy' meta tag to rewrite")),
contentSecurityPolicyNotFound2HTML,
baseCSS,
baseManifestJSON,
},
{
"subpath",
baseRootHTML,
baseCSS,
baseManifestJSON,
"/subpath",
nil,
subpathRootHTML,
subpathCSS,
subpathManifestJSON,
},
{
"new subpath from old",
subpathRootHTML,
subpathCSS,
subpathManifestJSON,
"/nested/subpath",
nil,
newSubpathRootHTML,
newSubpathCSS,
newSubpathManifestJSON,
},
{
"resetting to /",
subpathRootHTML,
subpathCSS,
baseManifestJSON,
"/",
nil,
baseRootHTML,
baseCSS,
baseManifestJSON,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(tempDir, model.ClientDir, "root.html"), []byte(testCase.RootHTML), 0700))
require.NoError(t, os.WriteFile(filepath.Join(tempDir, model.ClientDir, "main.css"), []byte(testCase.MainCSS), 0700))
require.NoError(t, os.WriteFile(filepath.Join(tempDir, model.ClientDir, "manifest.json"), []byte(testCase.ManifestJSON), 0700))
err := utils.UpdateAssetsSubpath(testCase.Subpath)
if testCase.ExpectedError != nil {
require.Equal(t, testCase.ExpectedError, err)
} else {
require.NoError(t, err)
}
contents, err := os.ReadFile(filepath.Join(tempDir, model.ClientDir, "root.html"))
require.NoError(t, err)
// Rewrite the expected and contents for simpler diffs when failed.
expectedRootHTML := strings.Replace(testCase.ExpectedRootHTML, ">", ">\n", -1)
contentsStr := strings.Replace(string(contents), ">", ">\n", -1)
require.Equal(t, expectedRootHTML, contentsStr)
contents, err = os.ReadFile(filepath.Join(tempDir, model.ClientDir, "main.css"))
require.NoError(t, err)
require.Equal(t, testCase.ExpectedMainCSS, string(contents))
contents, err = os.ReadFile(filepath.Join(tempDir, model.ClientDir, "manifest.json"))
require.NoError(t, err)
require.Equal(t, testCase.ExpectedManifestJSON, string(contents))
})
}
})
}
func TestGetSubpathFromConfig(t *testing.T) {
testCases := []struct {
Description string
SiteURL *string
ExpectedError bool
ExpectedSubpath string
}{
{
"empty SiteURL",
new(""),
false,
"/",
},
{
"invalid SiteURL",
new("cache_object:foo/bar"),
true,
"",
},
{
"nil SiteURL",
nil,
false,
"/",
},
{
"no trailing slash",
new("http://localhost:8065"),
false,
"/",
},
{
"trailing slash",
new("http://localhost:8065/"),
false,
"/",
},
{
"subpath, no trailing slash",
new("http://localhost:8065/subpath"),
false,
"/subpath",
},
{
"trailing slash",
new("http://localhost:8065/subpath/"),
false,
"/subpath",
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: testCase.SiteURL,
},
}
subpath, err := utils.GetSubpathFromConfig(config)
if testCase.ExpectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, testCase.ExpectedSubpath, subpath)
})
}
}
const contentSecurityPolicyNotFoundHTML = `<!DOCTYPE html> <html lang=en> <head> <meta charset=utf-8> <meta http-equiv=Content-Security-Policy content="script-src 'self'"> <meta http-equiv=X-UA-Compatible content="IE=edge"> <meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"> <meta name=robots content="noindex, nofollow"> <meta name=referrer content=no-referrer> <title>Mattermost</title> <meta name=apple-mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-status-bar-style content=default> <meta name=mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-title content=Mattermost> <meta name=application-name content=Mattermost> <meta name=format-detection content="telephone=no"> <link rel=apple-touch-icon sizes=57x57 href=/static/files/78b7e73b41b8731ce2c41c870ecc8886.png> <link rel=apple-touch-icon sizes=60x60 href=/static/files/51d00ffd13afb6d74fd8f6dfdeef768a.png> <link rel=apple-touch-icon sizes=72x72 href=/static/files/23645596f8f78f017bd4d457abb855c4.png> <link rel=apple-touch-icon sizes=76x76 href=/static/files/26e9d72f472663a00b4b206149459fab.png> <link rel=apple-touch-icon sizes=144x144 href=/static/files/7bd91659bf3fc8c68fcd45fc1db9c630.png> <link rel=apple-touch-icon sizes=120x120 href=/static/files/fa69ffe11eb334aaef5aece8d848ca62.png> <link rel=apple-touch-icon sizes=152x152 href=/static/files/f046777feb6ab12fc43b8f9908b1db35.png> <link rel=icon type=image/png sizes=16x16 href=/static/files/02b96247d275680adaaabf01c71c571d.png> <link rel=icon type=image/png sizes=32x32 href=/static/files/1d9020f201a6762421cab8d30624fdd8.png> <link rel=icon type=image/png sizes=96x96 href=/static/files/fe23af39ae98d77dc26ae8586565970f.png> <link rel=icon type=image/png sizes=192x192 href=/static/files/d7ff68a7675f84337cc154c3d4abe713.png> <link rel=manifest href=/static/files/a985ad72552ad069537d6eea81e719c7.json> <link rel=stylesheet class=code_theme> < <link href="/static/main.364fd054d7a6d741efc6.css" rel="stylesheet"><script type="text/javascript" src="/static/main.e49599ac425584ffead5.js"></script></head> <body class=font--open_sans> <div id=root> <div class=error-screen> <h2>Cannot connect to Mattermost</h2> <hr/> <p>We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.</p> <br/> </div> <div class=loading-screen style=position:relative> <div class=loading__content> <div class="round round-1"></div> <div class="round round-2"></div> <div class="round round-3"></div> </div> </div> </div> <noscript> To use Mattermost, please enable JavaScript. </noscript> </body> </html>`
const contentSecurityPolicyNotFound2HTML = `<!DOCTYPE html> <html lang=en> <head> <meta charset=utf-8> <meta http-equiv=Content-Security-Policy content="script-src 'self' 'unsafe-eval'"> <meta http-equiv=X-UA-Compatible content="IE=edge"> <meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"> <meta name=robots content="noindex, nofollow"> <meta name=referrer content=no-referrer> <title>Mattermost</title> <meta name=apple-mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-status-bar-style content=default> <meta name=mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-title content=Mattermost> <meta name=application-name content=Mattermost> <meta name=format-detection content="telephone=no"> <link rel=apple-touch-icon sizes=57x57 href=/static/files/78b7e73b41b8731ce2c41c870ecc8886.png> <link rel=apple-touch-icon sizes=60x60 href=/static/files/51d00ffd13afb6d74fd8f6dfdeef768a.png> <link rel=apple-touch-icon sizes=72x72 href=/static/files/23645596f8f78f017bd4d457abb855c4.png> <link rel=apple-touch-icon sizes=76x76 href=/static/files/26e9d72f472663a00b4b206149459fab.png> <link rel=apple-touch-icon sizes=144x144 href=/static/files/7bd91659bf3fc8c68fcd45fc1db9c630.png> <link rel=apple-touch-icon sizes=120x120 href=/static/files/fa69ffe11eb334aaef5aece8d848ca62.png> <link rel=apple-touch-icon sizes=152x152 href=/static/files/f046777feb6ab12fc43b8f9908b1db35.png> <link rel=icon type=image/png sizes=16x16 href=/static/files/02b96247d275680adaaabf01c71c571d.png> <link rel=icon type=image/png sizes=32x32 href=/static/files/1d9020f201a6762421cab8d30624fdd8.png> <link rel=icon type=image/png sizes=96x96 href=/static/files/fe23af39ae98d77dc26ae8586565970f.png> <link rel=icon type=image/png sizes=192x192 href=/static/files/d7ff68a7675f84337cc154c3d4abe713.png> <link rel=manifest href=/static/files/a985ad72552ad069537d6eea81e719c7.json> <link rel=stylesheet class=code_theme> <link href="/static/main.364fd054d7a6d741efc6.css" rel="stylesheet"><script type="text/javascript" src="/static/main.e49599ac425584ffead5.js"></script></head> <body class=font--open_sans> <div id=root> <div class=error-screen> <h2>Cannot connect to Mattermost</h2> <hr/> <p>We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.</p> <br/> </div> <div class=loading-screen style=position:relative> <div class=loading__content> <div class="round round-1"></div> <div class="round round-2"></div> <div class="round round-3"></div> </div> </div> </div> <noscript> To use Mattermost, please enable JavaScript. </noscript> </body> </html>`
const baseRootHTML = `<!DOCTYPE html> <html lang=en> <head> <meta charset=utf-8> <meta http-equiv="Content-Security-Policy" content="script-src 'self'"> <meta http-equiv=X-UA-Compatible content="IE=edge"> <meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"> <meta name=robots content="noindex, nofollow"> <meta name=referrer content=no-referrer> <title>Mattermost</title> <meta name=apple-mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-status-bar-style content=default> <meta name=mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-title content=Mattermost> <meta name=application-name content=Mattermost> <meta name=format-detection content="telephone=no"> <link rel=apple-touch-icon sizes=57x57 href=/static/files/78b7e73b41b8731ce2c41c870ecc8886.png> <link rel=apple-touch-icon sizes=60x60 href=/static/files/51d00ffd13afb6d74fd8f6dfdeef768a.png> <link rel=apple-touch-icon sizes=72x72 href=/static/files/23645596f8f78f017bd4d457abb855c4.png> <link rel=apple-touch-icon sizes=76x76 href=/static/files/26e9d72f472663a00b4b206149459fab.png> <link rel=apple-touch-icon sizes=144x144 href=/static/files/7bd91659bf3fc8c68fcd45fc1db9c630.png> <link rel=apple-touch-icon sizes=120x120 href=/static/files/fa69ffe11eb334aaef5aece8d848ca62.png> <link rel=apple-touch-icon sizes=152x152 href=/static/files/f046777feb6ab12fc43b8f9908b1db35.png> <link rel=icon type=image/png sizes=16x16 href=/static/files/02b96247d275680adaaabf01c71c571d.png> <link rel=icon type=image/png sizes=32x32 href=/static/files/1d9020f201a6762421cab8d30624fdd8.png> <link rel=icon type=image/png sizes=96x96 href=/static/files/fe23af39ae98d77dc26ae8586565970f.png> <link rel=icon type=image/png sizes=192x192 href=/static/files/d7ff68a7675f84337cc154c3d4abe713.png> <link rel=manifest href=/static/files/a985ad72552ad069537d6eea81e719c7.json> <link rel=stylesheet class=code_theme> <script id="publicPathInWindowScript"></script> <link href="/static/main.364fd054d7a6d741efc6.css" rel="stylesheet"><script type="text/javascript" src="/static/main.e49599ac425584ffead5.js"></script></head> <body class=font--open_sans> <div id=root> <div class=error-screen> <h2>Cannot connect to Mattermost</h2> <hr/> <p>We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.</p> <br/> </div> <div class=loading-screen style=position:relative> <div class=loading__content> <div class="round round-1"></div> <div class="round round-2"></div> <div class="round round-3"></div> </div> </div> </div> <noscript> To use Mattermost, please enable JavaScript. </noscript> </body> </html>`
const baseCSS = `@font-face{font-family:FontAwesome;src:url(/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}`
const subpathRootHTML = `<!DOCTYPE html> <html lang=en> <head> <meta charset=utf-8> <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'sha256-tPOjw+tkVs9axL78ZwGtYl975dtyPHB6LYKAO2R3gR4='"> <meta http-equiv=X-UA-Compatible content="IE=edge"> <meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"> <meta name=robots content="noindex, nofollow"> <meta name=referrer content=no-referrer> <title>Mattermost</title> <meta name=apple-mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-status-bar-style content=default> <meta name=mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-title content=Mattermost> <meta name=application-name content=Mattermost> <meta name=format-detection content="telephone=no"> <link rel=apple-touch-icon sizes=57x57 href=/subpath/static/files/78b7e73b41b8731ce2c41c870ecc8886.png> <link rel=apple-touch-icon sizes=60x60 href=/subpath/static/files/51d00ffd13afb6d74fd8f6dfdeef768a.png> <link rel=apple-touch-icon sizes=72x72 href=/subpath/static/files/23645596f8f78f017bd4d457abb855c4.png> <link rel=apple-touch-icon sizes=76x76 href=/subpath/static/files/26e9d72f472663a00b4b206149459fab.png> <link rel=apple-touch-icon sizes=144x144 href=/subpath/static/files/7bd91659bf3fc8c68fcd45fc1db9c630.png> <link rel=apple-touch-icon sizes=120x120 href=/subpath/static/files/fa69ffe11eb334aaef5aece8d848ca62.png> <link rel=apple-touch-icon sizes=152x152 href=/subpath/static/files/f046777feb6ab12fc43b8f9908b1db35.png> <link rel=icon type=image/png sizes=16x16 href=/subpath/static/files/02b96247d275680adaaabf01c71c571d.png> <link rel=icon type=image/png sizes=32x32 href=/subpath/static/files/1d9020f201a6762421cab8d30624fdd8.png> <link rel=icon type=image/png sizes=96x96 href=/subpath/static/files/fe23af39ae98d77dc26ae8586565970f.png> <link rel=icon type=image/png sizes=192x192 href=/subpath/static/files/d7ff68a7675f84337cc154c3d4abe713.png> <link rel=manifest href=/subpath/static/files/a985ad72552ad069537d6eea81e719c7.json> <link rel=stylesheet class=code_theme> <script id="publicPathInWindowScript">window.publicPath='/subpath/static/'</script> <link href="/subpath/static/main.364fd054d7a6d741efc6.css" rel="stylesheet"><script type="text/javascript" src="/subpath/static/main.e49599ac425584ffead5.js"></script></head> <body class=font--open_sans> <div id=root> <div class=error-screen> <h2>Cannot connect to Mattermost</h2> <hr/> <p>We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.</p> <br/> </div> <div class=loading-screen style=position:relative> <div class=loading__content> <div class="round round-1"></div> <div class="round round-2"></div> <div class="round round-3"></div> </div> </div> </div> <noscript> To use Mattermost, please enable JavaScript. </noscript> </body> </html>`
const subpathCSS = `@font-face{font-family:FontAwesome;src:url(/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/subpath/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/subpath/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/subpath/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/subpath/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}`
const newSubpathRootHTML = `<!DOCTYPE html> <html lang=en> <head> <meta charset=utf-8> <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'sha256-mbRaPRRpWz6MNkX9SyXWMJ8XnWV4w/DoqK2M0ryUAvc='"> <meta http-equiv=X-UA-Compatible content="IE=edge"> <meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"> <meta name=robots content="noindex, nofollow"> <meta name=referrer content=no-referrer> <title>Mattermost</title> <meta name=apple-mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-status-bar-style content=default> <meta name=mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-title content=Mattermost> <meta name=application-name content=Mattermost> <meta name=format-detection content="telephone=no"> <link rel=apple-touch-icon sizes=57x57 href=/nested/subpath/static/files/78b7e73b41b8731ce2c41c870ecc8886.png> <link rel=apple-touch-icon sizes=60x60 href=/nested/subpath/static/files/51d00ffd13afb6d74fd8f6dfdeef768a.png> <link rel=apple-touch-icon sizes=72x72 href=/nested/subpath/static/files/23645596f8f78f017bd4d457abb855c4.png> <link rel=apple-touch-icon sizes=76x76 href=/nested/subpath/static/files/26e9d72f472663a00b4b206149459fab.png> <link rel=apple-touch-icon sizes=144x144 href=/nested/subpath/static/files/7bd91659bf3fc8c68fcd45fc1db9c630.png> <link rel=apple-touch-icon sizes=120x120 href=/nested/subpath/static/files/fa69ffe11eb334aaef5aece8d848ca62.png> <link rel=apple-touch-icon sizes=152x152 href=/nested/subpath/static/files/f046777feb6ab12fc43b8f9908b1db35.png> <link rel=icon type=image/png sizes=16x16 href=/nested/subpath/static/files/02b96247d275680adaaabf01c71c571d.png> <link rel=icon type=image/png sizes=32x32 href=/nested/subpath/static/files/1d9020f201a6762421cab8d30624fdd8.png> <link rel=icon type=image/png sizes=96x96 href=/nested/subpath/static/files/fe23af39ae98d77dc26ae8586565970f.png> <link rel=icon type=image/png sizes=192x192 href=/nested/subpath/static/files/d7ff68a7675f84337cc154c3d4abe713.png> <link rel=manifest href=/nested/subpath/static/files/a985ad72552ad069537d6eea81e719c7.json> <link rel=stylesheet class=code_theme> <script id="publicPathInWindowScript">window.publicPath='/nested/subpath/static/'</script> <link href="/nested/subpath/static/main.364fd054d7a6d741efc6.css" rel="stylesheet"><script type="text/javascript" src="/nested/subpath/static/main.e49599ac425584ffead5.js"></script></head> <body class=font--open_sans> <div id=root> <div class=error-screen> <h2>Cannot connect to Mattermost</h2> <hr/> <p>We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.</p> <br/> </div> <div class=loading-screen style=position:relative> <div class=loading__content> <div class="round round-1"></div> <div class="round round-2"></div> <div class="round round-3"></div> </div> </div> </div> <noscript> To use Mattermost, please enable JavaScript. </noscript> </body> </html>`
const newSubpathCSS = `@font-face{font-family:FontAwesome;src:url(/nested/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/nested/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/nested/subpath/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/nested/subpath/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/nested/subpath/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/nested/subpath/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}`
const baseManifestJSON = `{
"icons": [
{
"src": "/static/icon_96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/static/icon_32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/static/icon_16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/static/icon_76x76.png",
"sizes": "76x76",
"type": "image/png"
},
{
"src": "/static/icon_72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/static/icon_60x60.png",
"sizes": "60x60",
"type": "image/png"
},
{
"src": "/static/icon_57x57.png",
"sizes": "57x57",
"type": "image/png"
},
{
"src": "/static/icon_152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/static/icon_144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/static/icon_120x120.png",
"sizes": "120x120",
"type": "image/png"
},
{
"src": "/static/icon_192x192.png",
"sizes": "192x192",
"type": "image/png"
}
],
"name": "Mattermost",
"short_name": "Mattermost",
"orientation": "any",
"display": "standalone",
"start_url": ".",
"description": "Mattermost is an open source, self-hosted Slack-alternative",
"background_color": "#ffffff"
}
`
const subpathManifestJSON = `{
"icons": [
{
"src": "/subpath/static/icon_96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/subpath/static/icon_32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/subpath/static/icon_16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/subpath/static/icon_76x76.png",
"sizes": "76x76",
"type": "image/png"
},
{
"src": "/subpath/static/icon_72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/subpath/static/icon_60x60.png",
"sizes": "60x60",
"type": "image/png"
},
{
"src": "/subpath/static/icon_57x57.png",
"sizes": "57x57",
"type": "image/png"
},
{
"src": "/subpath/static/icon_152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/subpath/static/icon_144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/subpath/static/icon_120x120.png",
"sizes": "120x120",
"type": "image/png"
},
{
"src": "/subpath/static/icon_192x192.png",
"sizes": "192x192",
"type": "image/png"
}
],
"name": "Mattermost",
"short_name": "Mattermost",
"orientation": "any",
"display": "standalone",
"start_url": ".",
"description": "Mattermost is an open source, self-hosted Slack-alternative",
"background_color": "#ffffff"
}
`
const newSubpathManifestJSON = `{
"icons": [
{
"src": "/nested/subpath/static/icon_96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_76x76.png",
"sizes": "76x76",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_60x60.png",
"sizes": "60x60",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_57x57.png",
"sizes": "57x57",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_120x120.png",
"sizes": "120x120",
"type": "image/png"
},
{
"src": "/nested/subpath/static/icon_192x192.png",
"sizes": "192x192",
"type": "image/png"
}
],
"name": "Mattermost",
"short_name": "Mattermost",
"orientation": "any",
"display": "standalone",
"start_url": ".",
"description": "Mattermost is an open source, self-hosted Slack-alternative",
"background_color": "#ffffff"
}
`

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package testutils
import (
"crypto/ecdsa"
"github.com/mattermost/mattermost/server/public/model"
)
type StaticConfigService struct {
Cfg *model.Config
listeners map[string]func(old, current *model.Config)
}
func (s *StaticConfigService) Config() *model.Config {
return s.Cfg
}
func (s *StaticConfigService) AddConfigListener(listener func(old, current *model.Config)) string {
if s.listeners == nil {
s.listeners = make(map[string]func(old, current *model.Config))
}
listenerID := model.NewId()
s.listeners[listenerID] = listener
return listenerID
}
func (s *StaticConfigService) RemoveConfigListener(listenerID string) {
delete(s.listeners, listenerID)
}
func (s *StaticConfigService) AsymmetricSigningKey() *ecdsa.PrivateKey {
return &ecdsa.PrivateKey{}
}
func (s *StaticConfigService) PostActionCookieSecret() []byte {
return make([]byte, 32)
}
func (s *StaticConfigService) UpdateConfig(newConfig *model.Config) {
oldConfig := s.Config()
s.Cfg = newConfig
for _, listener := range s.listeners {
listener(oldConfig, newConfig)
}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package testutils
import (
"bytes"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"time"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
// CollectTWithLogf adds Logf to assert.CollectT to make this pattern possible:
//
// assert.EventuallyWithT(t, func(c *assert.CollectT) {
// mockAPI.AssertExpectations(&testutils.CollectTWithLogf{CollectT: c})
// }, 5*time.Second, 100*time.Millisecond)
type CollectTWithLogf struct {
*assert.CollectT
}
func (*CollectTWithLogf) Logf(string, ...any) {
}
func ReadTestFile(name string) ([]byte, error) {
path, _ := fileutils.FindDir("tests")
file, err := os.Open(filepath.Join(path, name))
if err != nil {
return nil, err
}
defer file.Close()
data := &bytes.Buffer{}
if _, err := io.Copy(data, file); err != nil {
return nil, err
}
return data.Bytes(), nil
}
// GetInterface returns the best match of an interface that might be listening on a given port.
// This is helpful when a test is being run in a CI environment under docker.
func GetInterface(port int) string {
dial := func(iface string, port int) bool {
c, err := net.DialTimeout("tcp", iface+":"+strconv.Itoa(port), time.Second)
if err != nil {
return false
}
c.Close()
return true
}
// First, we check dockerhost
iface := "dockerhost"
if ok := dial(iface, port); ok {
return iface
}
// If not, we check localhost
iface = "localhost"
if ok := dial(iface, port); ok {
return iface
}
// If nothing works, we just attempt to use a hack and get the interface IP.
// https://stackoverflow.com/a/37212665/4962526.
cmdStr := ""
switch runtime.GOOS {
// Using ip address for Linux, ifconfig for Darwin.
case "linux":
cmdStr = `ip address | grep -E "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -v 127.0.0.1 | awk '{ print $2 }' | cut -f2 -d: | cut -f1 -d/ | head -n1`
case "darwin":
cmdStr = `ifconfig | grep -E "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -v 127.0.0.1 | awk '{ print $2 }' | cut -f2 -d: | head -n1`
default:
return ""
}
cmd := exec.Command("bash", "-c", cmdStr)
out, err := cmd.CombinedOutput()
if err != nil {
return ""
}
return string(out)
}
func ResetLicenseValidator() {
utils.LicenseValidator = &utils.LicenseValidatorImpl{}
}

View File

@@ -0,0 +1,543 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
crand "crypto/rand"
"math/big"
mrand "math/rand"
"strings"
)
const (
ALPHANUMERIC = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890"
LOWERCASE = "abcdefghijklmnopqrstuvwxyz"
)
// Strings that should pass as acceptable posts
var FuzzyStringsPosts = []string{
`**[1] - [Markdown Tests]**
_italics_
more _italics_
**bold**
more **bold**
**_bold-italic_**
more **_bold-italic_*8
~~strikethrough~~
more ~~strikethrough~~
` + "```" + `
multi-line code block<enter here>
multi-line code block
emoji that should not render in code block: :ice_cream:
` + "```" + `
` + "`monospace`" + `
[Link to Mattermost](www.mattermost.com)
Inline Image with link, alt text, and hover text: ![Build Status](https://travis-ci.org/mattermost/mattermost-server.svg?branch=master)](https://travis-ci.org/mattermost/mattermost-server)
Three types of lines:
***
___
---
`,
` **[2] - **[More Markdown Tests]**
> i am a blockquote!
> i am a 2nd multiline
> quote.
i am text right after a multiline quote, but not in the quote
* list item
* another list item
* indented list item
1. numbered list, item number 1
2. item number two
`,
` **[3]** - **[More Markdown Tests]**
Table
| Left-Aligned | Center Aligned | Right Aligned |
| :------------ |:---------------:| -----:|
| Left column 1 | this text | $100 |
| Left column 2 | is | $10 |
| Left column 3 | centered | $1 |
Ugly table
Markdown | Less | Pretty
--- | --- | ---
*Still* | ~~renders~~ | **nicely**
1 | 2 | 3
# Large heading
## Smaller heading
### Even smaller heading
# Large heading
## Smaller heading
### Even smaller heading
`,
` **[4]** - **[More Markdown Tests]**
# This is a heading
I am a multiline
text.
#### I am a level four heading
` + "```tex" + `
f(x) = \int_{-\infty}^\infty
\hat f(\xi)\,e^{2 \pi i \xi x}
\,d\xi
` + "```" + `
* This was some tex code*
`,
`**[5]** - **[Markdown and automatic preview of content test]**
## This should display a preview for the given vine url
Some text *before* the link
And a smiley :)
https://vine.co/v/eDeVgbFrt9L
Some more text here
and here
and even more here
`,
`**[6]** - **[More markdown and automatic preview of content test]**
## Only the first given url should render an "attachment"
Lets also add a table here, because why not
| Left-Aligned | Center Aligned | Right Aligned |
| :------------ |:---------------:| -----:|
| Left column 1 | this text | $100 |
| Left column 2 | is | $10 |
| Left column 3 | centered | $1 |
Wiki should render:
http://en.wikipedia.org/wiki/Foo
https://vine.co/v/eDeVgbFrt9L
`,
`**[7] [Image Test]**
## this *should* display an image
http://37.media.tumblr.com/tumblr_mavsumGGAd1qboaw8o1_500.jpg
`,
/* `**[2] [Username Linking Test]**
I saw @alice--and I said "Hi @alice!" then "What's up @alice?" and then @alice, was totally @alice; she just "@alice"'d me and walked on by. That's @alice...
@alice‽‽
`,
`**[3] [Mention Highlighting Test]**
`,*/
`**[8] [Emoji Display Test 1]**
:+1: :-1: :100: :1234: :8ball: :a: :ab: :abc: :abcd: :accept:
:aerial_tramway: :airplane: :alarm_clock: :ambulance: :anchor: :angel: :anger: :angry: :anguished: :ant:
:apple: :aquarius: :aries: :arrow_backward: :arrow_double_down: :arrow_double_up: :arrow_down: :arrow_down_small: :arrow_forward: :arrow_heading_down:
:arrow_heading_up: :arrow_left: :arrow_lower_left: :arrow_lower_right: :arrow_right: :arrow_right_hook: :arrow_up: :arrow_up_down:
:arrow_upper_left: :arrow_upper_right: :arrows_clockwise: :arrows_counterclockwise: :art: :articulated_lorry: :astonished: :atm: :arrow_up_small: :b:
:baby: :baby_bottle: :baby_chick: :baby_symbol: :back: :baggage_claim: :balloon: :ballot_box_with_check: :bamboo: :banana:
:bangbang: :bank: :bar_chart: :barber: :baseball: :basketball: :bath: :bathtub: :battery: :bear:
:bee: :beer: :beers: :beetle: :beginner: :bell: :bento: :bicyclist: :bike: :bikini:
:bird: :birthday: :black_circle: :black_joker: :black_medium_small_square: :black_medium_square: :black_nib: :black_small_square: :black_square: :black_square_button:
:blossom: :blowfish: :blue_book: :blue_car: :blue_heart: :blush: :boar: :boat: :bomb: :book:
:bookmark: :bookmark_tabs: :books: :boom: :boot: :bouquet: :bow: :bowling: :bowtie: :boy:
:bread: :bride_with_veil: :bridge_at_night: :briefcase: :broken_heart: :bug: :bulb: :bullettrain_front: :bullettrain_side: :bus:
:busstop: :bust_in_silhouette: :busts_in_silhouette: :cactus: :cake: :calendar: :calling: :camel: :camera: :cancer:
:candy: :capital_abcd: :capricorn: :car: :card_index: :carousel_horse: :cat: :cat2: :cd: :chart:
:chart_with_downwards_trend: :chart_with_upwards_trend: :checkered_flag: :cherries: :cherry_blossom: :chestnut: :chicken: :children_crossing: :chocolate_bar: :christmas_tree:
:church: :cinema: :circus_tent: :city_sunrise: :city_sunset: :cl: :clap: :clapper: :clipboard: :clock1:
:clock10: :clock1030: :clock11: :clock1130: :clock12: :clock1230: :clock130: :clock2: :clock230: :clock3:
:clock330: :clock4: :clock430: :clock5: :clock530: :clock6: :clock630: :clock7: :clock730: :clock8:
:clock830: :clock9: :clock930: :closed_book: :closed_lock_with_key: :closed_umbrella: :cloud: :clubs: :cn: :cocktail:
:coffee: :cold_sweat: :collision: :computer: :confetti_ball: :confounded: :confused: :congratulations: :construction: :construction_worker:
:convenience_store: :cookie: :cool: :cop: :copyright: :corn: :couple: :couple_with_heart: :couplekiss: :cow:
:cow2: :credit_card: :crescent_moon: :crocodile: :crossed_flags: :crown: :cry: :crying_cat_face: :crystal_ball: :cupid:
:curly_loop: :currency_exchange: :curry: :custard: :customs: :cyclone: :dancer: :dancers: :dango: :dart:
:dash: :date: :de: :deciduous_tree: :department_store: :diamond_shape_with_a_dot_inside: :diamonds: :disappointed: :disappointed_relieved: :dizzy:
:dizzy_face: :do_not_litter: :dog: :dog2: :dollar: :dolls: :dolphin: :donut: :door: :doughnut:
:dragon: :dragon_face: :dress: :dromedary_camel: :droplet: :dvd: :e-mail: :ear: :ear_of_rice: :earth_africa:
:earth_americas: :earth_asia: :egg: :eggplant: :eight: :eight_pointed_black_star: :eight_spoked_asterisk: :electric_plug: :elephant: :email:
:end: :envelope: :es: :euro: :european_castle: :european_post_office: :evergreen_tree: :exclamation: :expressionless: :eyeglasses:
:eyes: :facepunch: :factory: :fallen_leaf: :family: :fast_forward: :fax: :fearful: :feelsgood: :feet:
:ferris_wheel: :file_folder: :finnadie: :fire: :fire_engine: :fireworks: :first_quarter_moon: :first_quarter_moon_with_face: :fish: :fish_cake:
:fishing_pole_and_fish: :fist: :five: :flags: :flashlight: :floppy_disk: :flower_playing_cards: :flushed: :foggy: :football:
:fork_and_knife: :fountain: :four: :four_leaf_clover: :fr: :free: :fried_shrimp: :fries: :frog: :frowning:
:fu: :fuelpump: :full_moon: :full_moon_with_face: :game_die: :gb: :gem: :gemini: :ghost: :gift:`,
`**[9] [Emoji Display Test 2]**
:gift_heart: :girl: :globe_with_meridians: :goat: :goberserk: :godmode: :golf: :grapes: :green_apple: :green_book:
:green_heart: :grey_exclamation: :grey_question: :grimacing: :grin: :grinning: :guardsman: :guitar: :gun: :haircut:
:hamburger: :hammer: :hamster: :hand: :handbag: :hankey: :hash: :hatched_chick: :hatching_chick: :headphones:
:hear_no_evil: :heart: :heart_decoration: :heart_eyes: :heart_eyes_cat: :heartbeat: :heartpulse: :hearts: :heavy_check_mark: :heavy_division_sign:
:heavy_dollar_sign: :heavy_exclamation_mark: :heavy_minus_sign: :heavy_multiplication_x: :heavy_plus_sign: :helicopter: :herb: :hibiscus: :high_brightness: :high_heel:
:hocho: :honey_pot: :honeybee: :horse: :horse_racing: :hospital: :hotel: :hotsprings: :hourglass: :hourglass_flowing_sand:
:house: :house_with_garden: :hurtrealbad: :hushed: :ice_cream: :icecream: :id: :ideograph_advantage: :imp: :inbox_tray:
:incoming_envelope: :information_desk_person: :information_source: :innocent: :interrobang: :iphone: :it: :izakaya_lantern: :jack_o_lantern:
:japan: :japanese_castle: :japanese_goblin: :japanese_ogre: :jeans: :joy: :joy_cat: :jp: :key: :keycap_ten:
:kimono: :kiss: :kissing: :kissing_cat: :kissing_closed_eyes: :kissing_face: :kissing_heart: :kissing_smiling_eyes: :koala: :koko:
:kr: :large_blue_circle: :large_blue_diamond: :large_orange_diamond: :last_quarter_moon: :last_quarter_moon_with_face: :laughing: :leaves: :ledger: :left_luggage:
:left_right_arrow: :leftwards_arrow_with_hook: :lemon: :leo: :leopard: :libra: :light_rail: :link: :lips: :lipstick:
:lock: :lock_with_ink_pen: :lollipop: :loop: :loudspeaker: :love_hotel: :love_letter: :low_brightness: :m: :mag:
:mag_right: :mahjong: :mailbox: :mailbox_closed: :mailbox_with_mail: :mailbox_with_no_mail: :man: :man_with_gua_pi_mao: :man_with_turban: :mans_shoe:
:maple_leaf: :mask: :massage: :meat_on_bone: :mega: :melon: :memo: :mens: :metal: :metro:
:microphone: :microscope: :milky_way: :minibus: :minidisc: :mobile_phone_off: :money_with_wings: :moneybag: :monkey: :monkey_face:
:monorail: :mortar_board: :mount_fuji: :mountain_bicyclist: :mountain_cableway: :mountain_railway: :mouse: :mouse2: :movie_camera: :moyai:
:muscle: :mushroom: :musical_keyboard: :musical_note: :musical_score: :mute: :nail_care: :name_badge: :neckbeard: :necktie:
:negative_squared_cross_mark: :neutral_face: :new: :new_moon: :new_moon_with_face: :newspaper: :ng: :nine: :no_bell:
:no_bicycles: :no_entry: :no_entry_sign: :no_good: :no_mobile_phones: :no_mouth: :no_pedestrians: :no_smoking: :non-potable_water: :nose:
:notebook: :notebook_with_decorative_cover: :notes: :nut_and_bolt: :o: :o2: :ocean: :octocat: :octopus: :oden:
:office: :ok: :ok_hand: :ok_woman: :older_man: :older_woman: :on: :oncoming_automobile: :oncoming_bus: :oncoming_police_car:
:oncoming_taxi: :one: :open_file_folder: :open_hands: :open_mouth: :ophiuchus: :orange_book: :outbox_tray: :ox: :package:
:page_facing_up: :page_with_curl: :pager: :palm_tree: :panda_face: :paperclip: :parking: :part_alternation_mark: :partly_sunny: :passport_control:
:paw_prints: :peach: :pear: :pencil: :pencil2: :penguin: :pensive: :performing_arts: :persevere: :person_frowning:
:person_with_blond_hair: :person_with_pouting_face: :phone: :pig: :pig2: :pig_nose: :pill: :pineapple: :pisces: :pizza:
`,
`**[10] [Emoji Display Test 3]**
:plus1: :point_down: :point_left: :point_right: :point_up: :point_up_2: :police_car: :poodle: :poop: :post_office:
:postal_horn: :postbox: :potable_water: :pouch: :poultry_leg: :pound: :pouting_cat: :pray: :princess: :punch:
:purple_heart: :purse: :pushpin: :put_litter_in_its_place: :question: :rabbit: :rabbit2: :racehorse: :radio: :radio_button:
:rage: :rage1: :rage2: :rage3: :rage4: :railway_car: :rainbow: :raised_hand: :raised_hands: :raising_hand:
:ram: :ramen: :rat: :recycle: :red_car: :red_circle: :registered: :relaxed: :relieved: :repeat:
:repeat_one: :restroom: :revolving_hearts: :rewind: :ribbon: :rice: :rice_ball: :rice_cracker: :rice_scene: :ring:
:rocket: :roller_coaster: :rooster: :rose: :rotating_light: :round_pushpin: :rowboat: :ru:
:rugby_football: :runner: :running: :running_shirt_with_sash: :sa: :sagittarius: :sailboat: :sake: :sandal: :santa:
:satellite: :satisfied: :saxophone: :school: :school_satchel: :scissors: :scorpius: :scream: :scream_cat: :scroll:
:seat: :secret: :see_no_evil: :seedling: :seven: :shaved_ice: :sheep: :shell: :ship: :shipit:
:shirt: :shit: :shoe: :shower: :signal_strength: :six: :six_pointed_star: :ski: :skull: :sleeping:
:sleepy: :slot_machine: :small_blue_diamond: :small_orange_diamond: :small_red_triangle: :small_red_triangle_down: :smile: :smile_cat: :smiley: :smiley_cat:
:smiling_imp: :smirk: :smirk_cat: :smoking: :snail: :snake: :snowboarder: :snowflake: :snowman: :sob:
:soccer: :soon: :sos: :sound: :space_invader: :spades: :spaghetti: :sparkle: :sparkler: :sparkles:
:sparkling_heart: :speak_no_evil: :speaker: :speech_balloon: :speedboat: :squirrel: :star: :star2: :stars: :station:
:statue_of_liberty: :steam_locomotive: :stew: :straight_ruler: :strawberry: :stuck_out_tongue: :stuck_out_tongue_closed_eyes: :stuck_out_tongue_winking_eye: :sun_with_face: :sunflower:
:sunglasses: :sunny: :sunrise: :sunrise_over_mountains: :surfer: :sushi: :suspect: :suspension_railway: :sweat: :sweat_drops:
:sweat_smile: :sweet_potato: :swimmer: :symbols: :syringe: :tada: :tanabata_tree: :tangerine: :taurus: :taxi:
:tea: :telephone: :telephone_receiver: :telescope: :tennis: :tent: :thought_balloon: :three: :thumbsdown: :thumbsup:
:ticket: :tiger: :tiger2: :tired_face: :tm: :toilet: :tokyo_tower: :tomato: :tongue: :top:
:tophat: :tractor: :traffic_light: :train: :train2: :tram: :triangular_flag_on_post: :triangular_ruler: :trident: :triumph:
:trolleybus: :trollface: :trophy: :tropical_drink: :tropical_fish: :truck: :trumpet: :tshirt: :tulip: :turtle:
:tv: :twisted_rightwards_arrows: :two: :two_hearts: :two_men_holding_hands: :two_women_holding_hands:
:uk: :umbrella: :unamused: :underage: :unlock: :up: :us: :v: :vertical_traffic_light: :vhs:
:vibration_mode: :video_camera: :video_game: :violin: :virgo: :volcano: :vs: :walking: :waning_crescent_moon: :waning_gibbous_moon:
:warning: :watch: :water_buffalo: :watermelon: :wave: :wavy_dash: :waxing_crescent_moon: :waxing_gibbous_moon: :wc: :weary:
:wedding: :whale: :whale2: :wheelchair: :white_check_mark: :white_circle: :white_flower: :white_large_square: :white_medium_small_square: :white_medium_square:
:white_small_square: :white_square_button: :wind_chime: :wine_glass: :wink: :wolf: :woman: :womans_clothes: :womans_hat: :womens:
:worried: :wrench: :x: :yellow_heart: :yen: :yum: :zap: :zero: :zzz:
Unnamed: :u5272: :u5408: :u55b6: :u6307: :u6708: :u6709: :u6e80: :u7121: :u7533: :u7981: :u7a7a:
`,
`**[11] [Auto Linking]**
#### should be turned into links:
http://example.com
https://example.com
www.example.com
www.example.com/index
www.example.com/index.html
www.example.com/index/sub
www.example.com/index?params=1
www.example.com/index?params=1&other=2
www.example.com/index?params=1;other=2
http://example.com:8065
<http://example.com>
<www.example.com>
http://www.example.com/_/page
www.example.com/_/page
https://en.wikipedia.org/wiki/🐬
https://en.wikipedia.org/wiki/Rendering_(computer_graphics)
http://127.0.0.1
http://192.168.1.1:4040
http://[::1]:80
http://[::1]:8065
https://[::1]:80
http://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80
http://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:8065
https://[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:443
http://username:password@example.com
http://username:password@127.0.0.1
http://username:password@[2001:0:5ef5:79fb:303a:62d5:3312:ff42]:80
test@example.com
#### should be turned into links which link to the correct place:
[example link](example.com) links to ` + "`" + `http://example.com` + "`" + `
[example.com](example.com) links to ` + "`" + `http://example.com` + "`" + `
[example.com/other](example.com) links to ` + "`" + `http://example.com` + "`" + `
[example.com/other_link](example.com/example) links to ` + "`" + `http://example.com/example` + "`" + `
www.example.com links to ` + "`" + `http://www.example.com` + "`" + `
https://example.com links to ` + "`" + `https://example.com` + "`" + `and not ` + "`" + `http://example.com` + "`" + `
https://en.wikipedia.org/wiki/🐬 links to the Wikipedia article on dolphins
https://en.wikipedia.org/wiki/URLs#Syntax links to the Syntax section of the Wikipedia article on URLs
test@example.com links to ` + "`" + `mailto:test@example.com` + "`" + `
[email link](mailto:test@example.com) links to ` + "`" + `mailto:test@example.com` + "`" + `and not ` + "`" + `http://mailto:test@example.com` + "`" + `
[other link](ts3server://example.com) links to ` + "`" + `ts3server://example.com` + "`" + `and not ` + "`" + `http://ts3server://example.com` + "`" + `
#### should not be turned into links:
example.com
readme.md
<example.com>
http://
@example.com
#### should only turn the actual link into a link and not change surrounding text
(http://example.com)
(test@example.com)
This is a sentence with a http://example.com in it.
This is a sentence with a [link](http://example.com) in it.
This is a sentence with a http://example.com/_/underscore in it.
This is a sentence with a link (http://example.com) in it.
This is a sentence with a (https://en.wikipedia.org/wiki/Rendering_(computer_graphics)) in it.
This is a sentence with a http://192.168.1.1:4040 in it.
This is a sentence with a https://::1 in it.
This is a link to http://example.com.
`,
"*", "?", ".", "}{][)(><", "{}[]()<>",
"qahwah ( قهوة)",
"שָׁלוֹם עֲלֵיכֶם",
"Ramen チャーシュー chāshū",
"言而无信",
"Ṫ͌ó̍ ̍͂̓̍̍̀i̊ͯ͒",
"&amp; &lt; &qu",
"' or '1'='1' -- ",
"' or '1'='1' ({ ",
"' or '1'='1' /* ",
"1;DROP TABLE users",
"<b><i><u><strong><em>",
"sue@thatmightbe",
"sue@thatmightbe.",
"sue@thatmightbe.c",
"sue@thatmightbe.co",
"su+san@thatmightbe.com",
"a@b.中国",
"1@2.am",
"a@b.co.uk",
"a@b.cancerresearch",
"local@[127.0.0.1]",
"!@$%^&:*.,/|;'\"+=?`~#",
"'\"/\\\"\"''\\/",
"gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg",
"gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg",
"ą ć ę ł ń ó ś ź ż č ď ě ň ř š ť ž ă î ø å æ á é í ó ú Ç Ğ İ Ö Ş Ü",
"abcdefghijklmnopqrstuvwrxyz0123456789 -_",
"Ṫ͌ó̍ ̍͂̓̍̍̀i̊ͯ͒nͧ̍̓̃͋vok̂̓ͤ̓̂ěͬ ͆tͬ̐́̐͆h̒̏͌̓e͂ ̎̊h̽͆ͯ̄ͮi͊̂ͧͫ̇̃vͥͦ́ẻͤ-͒m̈́̀i̓ͮ͗̑͌̆̅n̓̓ͨd̊̑͛̔̚ ͨͮ̊̾rͪeͭͭ͑ͧ́͋p̈́̅̚rͧe̒̈̌s̍̽ͩ̓̇e͗n̏͊ͬͭtͨ͆ͤ̚iͪ͗̍n͐͒g̾ͦ̎ ͥ͌̽̊ͩͥ͗c̀ͬͣha̍̏̉ͪ̈̚o̊̏s̊̋̀̏̽̚.͒ͫ͛͛̎ͥ",
"H҉̵̞̟̠̖̗̘Ȅ̐̑̒̚̕̚ IS C̒̓̔̿̿̿̕̚̚̕̚̕̚̕̚̕̚̕̚OMI҉̵̞̟̠̖̗̘NG > ͡҉҉ ̵̡̢̛̗̘̙̜̝̞̟̠͇̊̋̌̍̎̏̿̿̿̚ ҉ ҉҉̡̢̡̢̛̛̖̗̘̙̜̝̞̟̠̖̗̘̙̜̝̞̟̠̊̋̌̍̎̏̐̑̒̓̔̊̋̌̍̎̏̐̑ ͡҉҉",
"<a href=\"//www.google.com\">Teh Googles</a>",
"<img src=\"//upload.wikimedia.org/wikipedia/meta/b/be/Wikipedia-logo-v2_2x.png\" />",
"&amp; &lt; &quot; &apos;",
" %21 %23 %24 %26 %27 %28 %29 %2A %2B %2C %2F %3A %3B %3D %3F %40 %5B %5D %0D %0A %0D%0A %20 %22 %25 %2D %2E %3C %3E %5C %5E %5F %60 %7B %7C %7D %7E",
";alert('Well this is awkward.');",
"<script type='text/javascript'>alert('yay puppies');</script>",
"http?q=foobar%0d%0aContent-\nLength:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-\nType:%20text/html%0d%0aContent-Length:%2019%0d%0a%0d%0a<html>Shazam</html>",
"apos'trophe@thatmightbe.com",
"apos''''trophe@thatmightbe.com",
"su+s+an@thatmightbe.com",
"per.iod@thatmightbe.com",
"per..iods@thatmightbe.com",
".period@thatmightbe.com",
"tom(comment)@thatmightbe.com",
"(comment)tom@thatmightbe.com",
"\"quotes\"@thatmightbe.com",
"\"\\\"(),:;<>@[\\]\"@thatmightbe.com",
"a!#$%&'*+-/=?^_`{|}~b@thatmightbe.com",
"jill@(comment)example.com",
"jill@example.com(comment)",
"ben@ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg.com",
"judy@gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg.com",
"ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.com",
}
// Strings that should pass as acceptable team names
var FuzzyStringsNames = []string{
"*",
"?",
".",
"}{][)(><",
"{}[]()<>",
"qahwah ( قهوة)",
"שָׁלוֹם עֲלֵיכֶם",
"Ramen チャーシュー chāshū",
"言而无信",
"Ṫ͌ó̍ ̍͂̓̍̍̀i̊ͯ͒",
"&amp; &lt; &qu",
"' or '1'='1' -- ",
"' or '1'='1' ({ ",
"' or '1'='1' /* ",
"1;DROP TABLE users",
"<b><i><u><strong><em>",
"sue@thatmightbe",
"sue@thatmightbe.",
"sue@thatmightbe.c",
"sue@thatmightbe.co",
"sue @ thatmightbe.com",
"apos'trophe@thatmightbe.com",
"apos''''trophe@thatmightbe.com",
"su+san@thatmightbe.com",
"su+s+an@thatmightbe.com",
"per.iod@thatmightbe.com",
"per..iods@thatmightbe.com",
".period@thatmightbe.com",
"tom(comment)@thatmightbe.com",
"(comment)tom@thatmightbe.com",
"\"quotes\"@thatmightbe.com",
"\"\\\"(),:;<>@[\\]\"@thatmightbe.com",
"a!#$%&'*+-/=?^_`{|}~b@thatmightbe.com",
"local@[127.0.0.1]",
"jill@(comment)example.com",
"jill@example.com(comment)",
"a@b.中国",
"1@2.am",
"a@b.co.uk",
"a@b.cancerresearch",
"<a href=\"//www.google.com\">Teh Googles</a>",
"<img src=\"//upload.wikimedia.org/wikipelogo-v2_2x.png\" />",
"<b><i><u><strong><em>",
"&amp; &lt; &quot; &apos;",
";alert('Well this is awkward.');",
"<script type='text/javascript'>alert('yay puppies');</script>",
"Ṫ͌ó̍ ̍͂̓̍̍̀i̊ͯ͒nͧ̍̓̃͋v",
"H҉̵̞̟̠̖̗̘Ȅ̐̐̑̒̚OMI҉̵̞̟̠",
}
// Strings that should pass as acceptable emails
var FuzzyStringsEmails = []string{
"sue@thatmightbe",
"sue@thatmightbe.c",
"sue@thatmightbe.co",
"su+san@thatmightbe.com",
"1@2.am",
"a@b.co.uk",
"a@b.cancerresearch",
"su+s+an@thatmightbe.com",
"per.iod@thatmightbe.com",
}
// Lovely giberish for all to use
const GibberishText = `
Thus one besides much goodness shyly far some hyena overtook since rhinoceros nodded withdrew wombat before deserved apart a alongside the far dalmatian less ouch where yet a salmon.
Then jeez far marginal hey aboard more as leaned much oversold that inside spoke showed much went crud close save so and and after and informally much lion commendably less conductive oh excepting conductive compassionate jeepers hey a much leopard alas woolly untruthful outside snug rashly one cunning past fabulous adjusted far woodchuck and and indecisive crud loving exotic less resolute ladybug sprang drank under following far the as hence passably stolidly jeez the inset spaciously more cozily fishily the hey alas petted one audible yikes dear preparatory darn goldfinch gosh a then as moth more guinea.
Timid mislaid as salamander yikes alas ouch much that goldfinch shark in before instead dear one swore vivid versus one until regardless sang panther tolerable much preparatory hardily shuddered where coquettish far sheep coarsely exaggerated preparatory because cordial awesome gradually nutria that dear mocking behind off staunchly regarding a the komodo crud shrewd well jeez iguanodon strove strived and moodily and sought and and mounted gosh aboard crud spitefully boa.
One as highhanded fortuitous angelfish so one woodchuck dazedly kangaroo nasty instead far parrot away the worm yet testy where caribou a cuckoo onto dear reined because less tranquil kindhearted and shuddered plankton astride monkey methodically above evasive otter this wrung and courageous iguana wayward along cowered prior a.
Freely since ouch octopus the heated apart on hey the some pending placed fearless jeepers hardheadedly more that less jolly bit cuddled.
Caterpillar laboriously far wistful spilled aside far oriole newt and immeasurably yikes revealed raptly obdurately definitely scallop titilatingly one alongside monumentally ouch much wretched the spoke a before alas insolent abortive that turned hey hare much poignantly re-laid goodness yet the dear compassionate a hey scooped sped darn warmly oh and more darn craven that overtook fell and bluebird misheard that needless less ravenously in positively far romantically some babbled that rose honey then immaturely this and jollily irresistible much rarely earthworm parrot wow.
Less less bluntly jeez at goodness panther opposite oh purred a pathetically mildly less cat badly much much on from obscure in gull off manatee hatchet goodness euphemistically hence or understandable after this so that thus shook hence that mindfully yellow behind far bat wayward thanks more wrote so the flapped however alas and mallard that temperately irritably yikes squirrel.
Some reset some therefore demonstrably considering dachshund kindhearted far wow far whispered far clung this by partook much upon fit inscrutably so affirmative diligently far grinned and manifestly hummingbird hello caudal considering when aboard much buoyantly that unfitting far attractively far during much crud baneful jeez one toneless cynically oh spurious athletic meadowlark much generously one subconsciously arguable much forthrightly hawk inoffensively.
Snorted tidy stiffly against one fiendishly began burst hey revealed a beside the soothingly ceremonially affirmatively cowered when fitted this static hello emoted assenting however while far that gross besides because and dear.
Far therefore the blushed momentously the however one a wholeheartedly and considering incessantly that neurotically wore firefly grouped impotently dear one abjectly goodness so far a honey far insolently far so greyhound between above raucously echidna more halfhearted thankful squid one.
Raccoon cockatoo this while but this a far among ouch and hey alas scallop black sane as yikes hello sexy far tacky and balked wrongly more near shrewdly the yet gosh much caribou ruthlessly a on far a threw well less at the one after.
Spoke touched barbarously before much thus therefore darn scratched oh howled the less much hello after and jeez flagrantly weirdly crud komodo fabulous the much some cow jeering much egregiously a bucolically a admirably jeepers essential when ouch and tapir this while and wolverine.
Cm more much in this rewrote ouch on from aside wildebeest crane saddled where much opposite endearingly hummingbird together some beside a the goodness dear ouch ouch struck the input smooched shrugged until slick as waked hawk sincere irksomely.
Camel the pulled this richly grimaced leopard more false thought dear militant added yikes supp infallibly set orca beat hello while accurately reliably while lorikeet one strategic less hello without and smooched across plankton but jeepers pangolin the rich seal sneered pre-set lynx on radical nasty alas onto more hence flabby outbid murkily congenially dived much lubber added far eccentrically turtle before outsold onto ouch thus much and hawk tolerable much knitted yikes shot much limpet one this woolly much however hence up angry up well.
Unicorn yawned hello boundless this when express jaded closed wept tranquil after came airily merry much dismounted for much extensively less interminably far one far armadillo pled dolphin alas nutria and more oh positively koala grizzly after falcon goat strict hooted next browbeat split more far far antagonistic lingering the depending pending sheared since up before jeepers distant mastodon dropped as this more some much set far infinitesimal well shark grasshopper as hey one via some fishy and immaturely remote where weasel leopard annoying correctly wherever that sniffled much mandrill on jeez adventurous much.
Jeepers before spitefully buoyant concentric the reset moth a darn decidedly baboon giraffe outrageously groundhog on one at more overslept gosh worm away far far less much hysteric showed on so rattlesnake the and immature yikes baneful hence wow lynx hence past scornfully groaned pounded dived this one outside dachshund scowled one prior tenable therefore before scratched much much drank hey while added rabbit shark and supp cut this ironic limpet hedgehog bound more rebuking the jeepers thorough while more far due but yikes nastily brave dangerous opened tangibly aside after acrimoniously one cackled scratched.
Canny salmon hatchet more far opposite much coughed excited expedient far lizard one indiscriminate yikes jeez powerlessly forcefully tiger rooster and brought far more during this sank onto after then less amorally rude unerring some alongside irrespective bat hungrily kangaroo extravagantly inside ouch much gosh dreadfully oh much darn prior as fired guinea.
Irksomely upon up for amicably one since contrary one until flamingo tarantula far koala despite easy well gazelle ungracefully rose less that under hey more criminal unique furrowed so disbanded normal where one a a hey circuitous ouch feverish for the kookaburra and pithy far far then more the versus cliquishly across oh and explicitly much therefore as tamely alongside underlay much yikes imminently off however far across instantaneous therefore wallaby evidently foul foretold as far a jeepers invidious bearish.
More and until scandalously after wallaby petted oh much as poked much caterpillar drank beside rode actively walking scooped weird this duteous that far before human during dear house thrust more flinched opposite that ahead in far.
The painful essential jeepers merrily proudly essential and less far dismounted inside mongoose beyond confessedly robin shined heron the during since according suggestively and less some strident combed alas much man-of-war forgave so and to then inanimately.
Beside far this this a crud polite cantankerous exclusively misheard pled far circuitously and frugal less more temperately gauche goldfinch oh against this along excitedly goodhearted more classically quit serenely outside vulture ouch after one a this yet.
Less and handsomely manatee some amidst much reined komodo busted exultingly but fatuously less across mighty goodness objective alas glaringly gregariously hello the since one pridefully much well placed far less goodness jellyfish unnecessary reciprocating a far stylistic gazed one.
Hey rethought excepting lamely much and naughtily amidst more since jeez then bluebird hence less bald by some brought left the across logic loyal brightly jeez capitally that less more forward rebound a yikes chose convulsively confidently repeated broadcast much dipped when awesomely or some some regal the scowled merry zebra since more credible so inescapably fetchingly and lantern that due dear one went gosh wow well furrowed much much specially spoiled as vitally instead the seriously some rooster irrespective well imprecisely rapidly more llama.
Up to and hey without pill that this squid alas brusque on inventoried and spread the more excepting aristocratically due piquant wove beneath that macaw in more until much grimaced far and jeez enticingly unicorn some far crab more barring purely jeepers clear groomed glaring hey dear hence before the this hello.`
func RandString(l int, charset string) string {
ret := make([]byte, l)
for i := range l {
ret[i] = charset[mrand.Intn(len(charset))]
}
return string(ret)
}
func SecureRandString(n int) string {
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*0123456789"
var str strings.Builder
for range n {
num, _ := crand.Int(crand.Reader, big.NewInt(int64(len(charset))))
str.WriteString(string(charset[num.Int64()]))
}
return str.String()
}
// func RandomEmail(length Range, charset string) string {
// emaillen := RandIntFromRange(length)
// username := RandString(emaillen, charset)
// domain := "simulator.amazonses.com"
// return "success+" + username + "@" + domain
// }
// func FuzzEmail() string {
// return FuzzyStringsEmails[RandIntFromRange(Range{0, len(FuzzyStringsEmails) - 1})]
// }
func RandomName(length Range, charset string) string {
namelen := RandIntFromRange(length)
return RandString(namelen, charset)
}
func FuzzName() string {
return FuzzyStringsNames[RandIntFromRange(Range{0, len(FuzzyStringsNames) - 1})]
}
// Random selection of text for post
func RandomText(length Range, hashtags Range, mentions Range, users []string) string {
textLength := RandIntFromRange(length)
numHashtags := RandIntFromRange(hashtags)
numMentions := RandIntFromRange(mentions)
if textLength > len(GibberishText) || textLength < 0 {
textLength = len(GibberishText)
}
startPosition := RandIntFromRange(Range{0, len(GibberishText) - textLength - 1})
words := strings.Split(GibberishText[startPosition:startPosition+textLength], " ")
for range numHashtags {
randword := RandIntFromRange(Range{0, len(words) - 1})
words = append(words, " #"+words[randword])
}
if len(users) > 0 {
for range numMentions {
randuser := RandIntFromRange(Range{0, len(users) - 1})
words = append(words, " @"+users[randuser])
}
}
// Shuffle the words
for i := range words {
j := mrand.Intn(i + 1)
words[i], words[j] = words[j], words[i]
}
return strings.Join(words, " ")
}
func FuzzPost() string {
return FuzzyStringsPosts[RandIntFromRange(Range{0, len(FuzzyStringsPosts) - 1})]
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"fmt"
"strconv"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func MillisFromTime(t time.Time) int64 {
return t.UnixNano() / int64(time.Millisecond)
}
func TimeFromMillis(millis int64) time.Time {
return time.Unix(0, millis*int64(time.Millisecond))
}
func StartOfDay(t time.Time) time.Time {
year, month, day := t.Date()
return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
}
func EndOfDay(t time.Time) time.Time {
year, month, day := t.Date()
return time.Date(year, month, day, 23, 59, 59, 999999999, t.Location())
}
func Yesterday() time.Time {
return time.Now().AddDate(0, 0, -1)
}
type FormattedPostTime struct {
Time time.Time
Year string
Month string
Day string
Hour string
Minute string
TimeZone string
}
func GetFormattedPostTime(user *model.User, post *model.Post, useMilitaryTime bool, translateFunc i18n.TranslateFunc) FormattedPostTime {
preferredTimezone := user.GetPreferredTimezone()
postTime := time.Unix(post.CreateAt/1000, 0)
zone, _ := postTime.Zone()
localTime := postTime
if preferredTimezone != "" {
loc, _ := time.LoadLocation(preferredTimezone)
if loc != nil {
localTime = postTime.In(loc)
zone, _ = localTime.Zone()
}
}
hour := localTime.Format("15")
period := ""
if !useMilitaryTime {
hour = localTime.Format("3")
period = " " + localTime.Format("PM")
}
return FormattedPostTime{
Time: localTime,
Year: strconv.Itoa(localTime.Year()),
Month: translateFunc(localTime.Month().String()),
Day: strconv.Itoa(localTime.Day()),
Hour: hour,
Minute: fmt.Sprintf("%02d"+period, localTime.Minute()),
TimeZone: zone,
}
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
var format = "2006-01-02 15:04:05.000000000"
func TestMillisFromTime(t *testing.T) {
input, _ := time.Parse(format, "2015-01-01 12:34:00.000000000")
actual := MillisFromTime(input)
expected := int64(1420115640000)
assert.Equal(t, expected, actual)
}
func TestYesterday(t *testing.T) {
actual := Yesterday()
expected := time.Now().AddDate(0, 0, -1)
assert.Equal(t, expected.Year(), actual.Year())
assert.Equal(t, expected.Day(), actual.Day())
assert.Equal(t, expected.Month(), actual.Month())
}
func TestStartOfDay(t *testing.T) {
input, _ := time.Parse(format, "2015-01-01 12:34:00.000000000")
actual := StartOfDay(input)
expected, _ := time.Parse(format, "2015-01-01 00:00:00.000000000")
assert.Equal(t, expected, actual)
}
func TestEndOfDay(t *testing.T) {
input, _ := time.Parse(format, "2015-01-01 12:34:00.000000000")
actual := EndOfDay(input)
expected, _ := time.Parse(format, "2015-01-01 23:59:59.999999999")
assert.Equal(t, expected, actual)
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import "golang.org/x/text/unicode/norm"
// NormalizeFilename normalizes a filename to NFC (composed) form.
// This ensures consistent string comparison between filesystems that use
// different Unicode normalization forms (macOS uses NFD, Linux/Windows use NFC).
// This is particularly important for Japanese dakuten/handakuten characters
// (e.g., "ガ" can be represented as U+30AC (NFC) or U+30AB + U+3099 (NFD)).
func NormalizeFilename(name string) string {
return norm.NFC.String(name)
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeFilename(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "ASCII only",
input: "test.jpg",
expected: "test.jpg",
},
{
name: "Japanese katakana dakuten NFC",
input: "\u30AC", // ガ (NFC)
expected: "\u30AC",
},
{
name: "Japanese katakana dakuten NFD",
input: "\u30AB\u3099", // カ + combining dakuten → ガ
expected: "\u30AC",
},
{
name: "Japanese katakana handakuten NFC",
input: "\u30D1", // パ (NFC)
expected: "\u30D1",
},
{
name: "Japanese katakana handakuten NFD",
input: "\u30CF\u309A", // ハ + combining handakuten → パ
expected: "\u30D1",
},
{
name: "Japanese hiragana dakuten NFC",
input: "\u3079", // べ (NFC)
expected: "\u3079",
},
{
name: "Japanese hiragana dakuten NFD",
input: "\u3078\u3099", // へ + combining dakuten → べ
expected: "\u3079",
},
{
name: "Mixed path with NFD",
input: "data/\u30AB\u3099test.jpg", // data/カ゛test.jpg
expected: "data/\u30ACtest.jpg", // data/ガtest.jpg
},
{
name: "Complex Japanese filename NFD",
input: "\u304B\u3099\u304D\u3099\u3050", // が + ぎ + ぐ (NFD: か゛き゛く゛)
expected: "\u304C\u304E\u3050", // がぎぐ (NFC)
},
{
name: "Path with multiple NFD characters",
input: "data/\u30D5\u309A\u30ED\u30B7\u3099\u30A7\u30AF\u30C8.png", // data/プロジェクト.png (NFD)
expected: "data/\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8.png", // data/プロジェクト.png (NFC)
},
{
name: "Empty string",
input: "",
expected: "",
},
{
name: "Already NFC normalized",
input: "ファイル名.txt",
expected: "ファイル名.txt",
},
{
name: "Korean Hangul NFC",
input: "\uAC00", // 가 (NFC precomposed)
expected: "\uAC00",
},
{
name: "Korean Hangul NFD",
input: "\u1100\u1161", // ᄀ + ᅡ (NFD Jamo) → 가
expected: "\uAC00",
},
{
name: "Korean word NFD",
input: "\u1112\u1161\u11AB\u1100\u1173\u11AF", // 한글 (NFD Jamo: 한글)
expected: "\uD55C\uAE00", // 한글 (NFC)
},
{
name: "Korean filename with path NFD",
input: "data/\u1111\u1161\u110B\u1175\u11AF.txt", // 파일 (NFD) + .txt
expected: "data/\uD30C\uC77C.txt", // 파일.txt (NFC)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NormalizeFilename(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestNormalizeFilenameIdempotent(t *testing.T) {
// NFC normalization should be idempotent
inputs := []string{
"test.jpg",
"\u30AC", // ガ (NFC)
"\u30AB\u3099", // カ + combining dakuten (NFD)
"data/テスト.jpg",
"\uD55C\uAE00", // 한글 (NFC)
"\u1112\u1161\u11AB\u1100\u1173\u11AF", // 한글 (NFD Jamo)
"",
}
for _, input := range inputs {
first := NormalizeFilename(input)
second := NormalizeFilename(first)
assert.Equal(t, first, second, "NormalizeFilename should be idempotent for input: %q", input)
}
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"net/url"
"strings"
)
func URLEncode(str string) string {
strs := strings.Split(str, " ")
for i, s := range strs {
strs[i] = url.QueryEscape(s)
}
return strings.Join(strs, "%20")
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestURLEncode(t *testing.T) {
toEncode := "testing 1 2 3"
encoded := URLEncode(toEncode)
require.Equal(t, encoded, "testing%201%202%203")
toEncode = "testing123"
encoded = URLEncode(toEncode)
require.Equal(t, encoded, "testing123")
toEncode = "testing$#~123"
encoded = URLEncode(toEncode)
require.Equal(t, encoded, "testing%24%23~123")
}

View File

@@ -0,0 +1,298 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"io"
"math"
"net"
"net/http"
"net/url"
"slices"
"strings"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
)
// RemoveStringFromSlice removes the first occurrence of a from slice.
func RemoveStringFromSlice(a string, slice []string) []string {
for i, str := range slice {
if str == a {
return append(slice[:i], slice[i+1:]...)
}
}
return slice
}
// RemoveStringsFromSlice removes all occurrences of strings from slice.
func RemoveStringsFromSlice(slice []string, strings ...string) []string {
newSlice := []string{}
for _, item := range slice {
if !slices.Contains(strings, item) {
newSlice = append(newSlice, item)
}
}
return newSlice
}
func StringArrayIntersection(arr1, arr2 []string) []string {
arrMap := map[string]bool{}
result := []string{}
for _, value := range arr1 {
arrMap[value] = true
}
for _, value := range arr2 {
if arrMap[value] {
result = append(result, value)
}
}
return result
}
func RemoveDuplicatesFromStringArray(arr []string) []string {
result := make([]string, 0, len(arr))
seen := make(map[string]bool)
for _, item := range arr {
if !seen[item] {
result = append(result, item)
seen[item] = true
}
}
return result
}
func StringSliceDiff(a, b []string) []string {
m := make(map[string]bool)
result := []string{}
for _, item := range b {
m[item] = true
}
for _, item := range a {
if !m[item] {
result = append(result, item)
}
}
return result
}
func RemoveElementFromSliceAtIndex[S ~[]E, E any](slice S, index int) S {
return slices.Delete(slice, index, index+1)
}
func GetIPAddress(r *http.Request, trustedProxyIPHeader []string) string {
address := ""
for _, proxyHeader := range trustedProxyIPHeader {
header := r.Header.Get(proxyHeader)
if header != "" {
addresses := strings.Split(header, ",")
if len(addresses) > 0 {
address = strings.TrimSpace(addresses[0])
}
}
if address != "" && net.ParseIP(address) != nil {
return address
}
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}
func GetHostnameFromSiteURL(siteURL string) string {
u, err := url.Parse(siteURL)
if err != nil {
return ""
}
return u.Hostname()
}
type RequestCache struct {
Data []byte
Date string
Key string
}
// Fetch JSON data from the notices server
// if skip is passed, does a fetch without touching the cache
func GetURLWithCache(url string, cache *RequestCache, skip bool) ([]byte, error) {
// Build a GET Request, including optional If-None-Match header.
req, err := http.NewRequest("GET", url, nil)
if err != nil {
cache.Data = nil
return nil, err
}
if !skip && cache.Data != nil {
req.Header.Add("If-None-Match", cache.Key)
req.Header.Add("If-Modified-Since", cache.Date)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
cache.Data = nil
return nil, err
}
defer resp.Body.Close()
// No change from latest known Etag?
if resp.StatusCode == http.StatusNotModified {
return cache.Data, nil
}
if resp.StatusCode != 200 {
cache.Data = nil
return nil, errors.Errorf("Fetching notices failed with status code %d", resp.StatusCode)
}
cache.Data, err = io.ReadAll(resp.Body)
if err != nil {
cache.Data = nil
return nil, err
}
// If etags headers are missing, ignore.
cache.Key = resp.Header.Get("ETag")
cache.Date = resp.Header.Get("Date")
return cache.Data, err
}
// Append tokens to passed baseURL as query params
func AppendQueryParamsToURL(baseURL string, params map[string]string) string {
u, err := url.Parse(baseURL)
if err != nil {
return ""
}
q, err := url.ParseQuery(u.RawQuery)
if err != nil {
return ""
}
for key, value := range params {
q.Add(key, value)
}
u.RawQuery = q.Encode()
return u.String()
}
// ValidateWebAuthRedirectUrl validates a RedirectURL passed during OAuth or SAML.
func ValidateWebAuthRedirectUrl(config *model.Config, redirectURL string) error {
u, err := url.Parse(redirectURL)
if err != nil {
return errors.Wrap(err, "failed to parse redirect URL")
}
if config.ServiceSettings.SiteURL == nil {
return errors.New("SiteURL is not configured")
}
// Allow relative URLs (no scheme/host) - they're internal paths
if u.Scheme == "" && u.Host == "" {
return nil
}
siteURL, err := url.Parse(*config.ServiceSettings.SiteURL)
if err != nil {
return errors.Wrap(err, "failed to parse SiteURL from config")
}
if u.Scheme != siteURL.Scheme {
return errors.Errorf("redirect URL scheme %q does not match site URL scheme %q", u.Scheme, siteURL.Scheme)
}
if u.Host != siteURL.Host {
return errors.Errorf("redirect URL host %q does not match site URL host %q", u.Host, siteURL.Host)
}
return nil
}
// Validates Mobile Custom URL Scheme passed during OAuth or SAML
func IsValidMobileAuthRedirectURL(config *model.Config, redirectURL string) bool {
for _, URLScheme := range config.NativeAppSettings.AppCustomURLSchemes {
if strings.Index(strings.ToLower(redirectURL), strings.ToLower(URLScheme)) == 0 {
return true
}
}
return false
}
// This will only return TRUE if the request comes from a mobile running the Mobile App.
// If the request comes from a mobile using the browser, it will return FALSE.
func IsMobileRequest(r *http.Request) bool {
userAgent := r.UserAgent()
if userAgent == "" {
return false
}
// Check if the User-Agent contain keywords found in mobile devices running the mobile App
mobileKeywords := []string{"Mobile", "Android", "iOS", "iPhone", "iPad"}
for _, keyword := range mobileKeywords {
if strings.Contains(userAgent, keyword) {
return true
}
}
return false
}
// RoundOffToZeroes converts all digits to 0 except the 1st one.
// Special case: If there is only 1 digit, then returns 0.
func RoundOffToZeroes(n float64) int64 {
if n >= -9 && n <= 9 {
return 0
}
zeroes := int(math.Log10(math.Abs(n)))
tens := int64(math.Pow10(zeroes))
firstDigit := int64(n) / tens
return firstDigit * tens
}
// RoundOffToZeroesResolution truncates off at most minResolution zero places.
// It implicitly sets the lowest minResolution to 0.
// e.g. 0 reports 1s, 1 reports 10s, 2 reports 100s, 3 reports 1000s
func RoundOffToZeroesResolution(n float64, minResolution int) int64 {
resolution := max(0, minResolution)
if n >= -9 && n <= 9 {
if resolution == 0 {
return int64(n)
}
return 0
}
zeroes := int(math.Log10(math.Abs(n)))
resolution = min(zeroes, resolution)
tens := int64(math.Pow10(resolution))
significantDigits := int64(n) / tens
return significantDigits * tens
}
// SliceEqualUnordered returns true if both slices contain the same set of elements,
// regardless of order.
func SliceEqualUnordered[K comparable](a, b []K) bool {
if len(a) != len(b) {
return false
}
set := make(map[K]int, len(a))
for _, id := range a {
set[id]++
}
for _, id := range b {
set[id]--
if set[id] < 0 {
return false
}
}
return true
}

View File

@@ -0,0 +1,627 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestStringArrayIntersection(t *testing.T) {
a := []string{
"abc",
"def",
"ghi",
}
b := []string{
"jkl",
}
c := []string{
"def",
}
assert.Empty(t, StringArrayIntersection(a, b))
assert.Len(t, StringArrayIntersection(a, c), 1)
}
func TestRemoveDuplicatesFromStringArray(t *testing.T) {
a := []string{
"a",
"b",
"a",
"a",
"b",
"c",
"a",
}
assert.Len(t, RemoveDuplicatesFromStringArray(a), 3)
}
func TestStringSliceDiff(t *testing.T) {
a := []string{"one", "two", "three", "four", "five", "six"}
b := []string{"two", "seven", "four", "six"}
expected := []string{"one", "three", "five"}
assert.Equal(t, expected, StringSliceDiff(a, b))
}
func TestGetIPAddress(t *testing.T) {
t.Run("Single IP in the X-Forwarded-For", func(t *testing.T) {
httpRequest1 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"10.0.0.1"},
"X-Real-Ip": []string{"10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.0.0.1", GetIPAddress(&httpRequest1, []string{"X-Forwarded-For"}))
})
t.Run("Multiple IPs in the X-Forwarded-For", func(t *testing.T) {
httpRequest2 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"10.0.0.1, 10.0.0.2, 10.0.0.3"},
"X-Real-Ip": []string{"10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.0.0.1", GetIPAddress(&httpRequest2, []string{"X-Forwarded-For"}))
})
t.Run("Empty X-Forwarded-For", func(t *testing.T) {
httpRequest3 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{""},
"X-Real-Ip": []string{"10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.1.0.1", GetIPAddress(&httpRequest3, []string{"X-Forwarded-For", "X-Real-Ip"}))
})
t.Run("Without an X-Forwarded-For", func(t *testing.T) {
httpRequest4 := http.Request{
Header: http.Header{
"X-Real-Ip": []string{"10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.1.0.1", GetIPAddress(&httpRequest4, []string{"X-Forwarded-For", "X-Real-Ip"}))
})
t.Run("Without any headers", func(t *testing.T) {
httpRequest5 := http.Request{
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.2.0.1", GetIPAddress(&httpRequest5, []string{"X-Forwarded-For", "X-Real-Ip"}))
})
t.Run("Two headers, but both untrusted", func(t *testing.T) {
httpRequest6 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"10.3.0.1"},
"X-Real-Ip": []string{"10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.2.0.1", GetIPAddress(&httpRequest6, nil))
})
t.Run("Two headers, but only X-Real-Ip trusted", func(t *testing.T) {
httpRequest7 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"10.3.0.1"},
"X-Real-Ip": []string{"10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.1.0.1", GetIPAddress(&httpRequest7, []string{"X-Real-Ip"}))
})
t.Run("X-Forwarded-For, comma separated, untrusted", func(t *testing.T) {
httpRequest8 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"10.3.0.1, 10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.2.0.1", GetIPAddress(&httpRequest8, nil))
})
t.Run("X-Forwarded-For, comma separated, untrusted", func(t *testing.T) {
httpRequest9 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"10.3.0.1, 10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.3.0.1", GetIPAddress(&httpRequest9, []string{"X-Forwarded-For"}))
})
t.Run("Two headers, both allowed, first one in trusted used", func(t *testing.T) {
httpRequest10 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"10.3.0.1"},
"X-Real-Ip": []string{"10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.1.0.1", GetIPAddress(&httpRequest10, []string{"X-Real-Ip", "X-Forwarded-For"}))
})
t.Run("Multiple IPs in the X-Forwarded-For with no spaces", func(t *testing.T) {
httpRequest11 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"10.0.0.1,10.0.0.2,10.0.0.3"},
"X-Real-Ip": []string{"10.1.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.0.0.1", GetIPAddress(&httpRequest11, []string{"X-Forwarded-For"}))
})
t.Run("Make sure that the parsed value from headers only accept IP Addresses", func(t *testing.T) {
httpRequest12 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"127.0.0.1"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "127.0.0.1", GetIPAddress(&httpRequest12, []string{"X-Forwarded-For"}))
httpRequest13 := http.Request{
Header: http.Header{
"X-Forwarded-For": []string{"localhost"},
},
RemoteAddr: "10.2.0.1:12345",
}
assert.Equal(t, "10.2.0.1", GetIPAddress(&httpRequest13, []string{"X-Forwarded-For"}))
})
}
func TestRemoveStringFromSlice(t *testing.T) {
a := []string{"one", "two", "three", "four", "five", "six"}
expected := []string{"one", "two", "three", "five", "six"}
assert.Equal(t, RemoveStringFromSlice("four", a), expected)
}
func TestAppendQueryParamsToURL(t *testing.T) {
url := "mattermost://callback"
redirectURL := AppendQueryParamsToURL(url, map[string]string{
"key1": "value1",
"key2": "value2",
})
expected := url + "?key1=value1&key2=value2"
assert.Equal(t, redirectURL, expected)
}
func TestRoundOffToZeroes(t *testing.T) {
testCases := []struct {
desc string
n float64
expected int64
}{
{
desc: "returns 0 when n is 0",
n: 0,
expected: 0,
},
{
desc: "returns 0 when n is 9",
n: 9,
expected: 0,
},
{
desc: "returns 10 when n is 10",
n: 10,
expected: 10,
},
{
desc: "returns 90 when n is 99",
n: 99,
expected: 90,
},
{
desc: "returns 100 when n is 100",
n: 100,
expected: 100,
},
{
desc: "returns 100 when n is 101",
n: 101,
expected: 100,
},
{
desc: "returns 4000 when n is 4321",
n: 4321,
expected: 4000,
},
{
desc: "returns 0 when n is -9",
n: -9,
expected: 0,
},
{
desc: "returns -4000 when n is -4321",
n: -4321,
expected: -4000,
},
{
desc: "returns 4000 when n is 4321.235",
n: 4321.235,
expected: 4000,
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
res := RoundOffToZeroes(tc.n)
assert.Equal(t, tc.expected, res)
})
}
}
func TestIsMobileRequest(t *testing.T) {
testCases := []struct {
userAgent string
expected bool
}{
// Test cases with mobile devices
{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)", true},
{"Mozilla/5.0 (Android 12; Mobile)", true},
{"Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9999.99 Mobile Safari/537.36", true},
// Test cases with NO mobile devices
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36", false},
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36", false},
}
for _, tc := range testCases {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("User-Agent", tc.userAgent)
result := IsMobileRequest(req)
if result != tc.expected {
t.Errorf("User-Agent: %s, expected: %v, got: %v", tc.userAgent, tc.expected, result)
}
}
}
func TestRoundOffToZeroesResolution(t *testing.T) {
messageGranularity := 3
storageGranularity := 8
testCases := []struct {
desc string
n float64
minResolution int
expected int64
}{
{
desc: "returns 0 when n is 0",
n: 0,
expected: 0,
},
{
desc: "resolution of 0 does not round",
n: 12345,
expected: 12345,
minResolution: 0,
},
{
desc: "resolution of 1 truncates to 10s: 9 -> 0",
n: 9,
expected: 0,
minResolution: 1,
},
{
desc: "resolution of 1 truncates to 10s: 10 -> 10",
n: 10,
expected: 10,
minResolution: 1,
},
{
desc: "resolution of 1 truncates to 10s: 11 -> 10",
n: 11,
expected: 10,
minResolution: 1,
},
{
desc: "resolution of 1 truncates to 10s: 19 -> 10",
n: 19,
expected: 10,
minResolution: 1,
},
{
desc: "supports message usage granularity (1000s): 9 -> 0",
n: 9,
expected: 0,
minResolution: messageGranularity,
},
{
desc: "supports message usage granularity (1000s): 123 -> 100",
n: 9,
expected: 0,
minResolution: messageGranularity,
},
{
desc: "supports message usage granularity (1000s): 1234 -> 1000",
n: 1234,
expected: 1000,
minResolution: messageGranularity,
},
{
desc: "supports message usage granularity (1000s): 1500 -> 1000",
n: 1500,
expected: 1000,
minResolution: messageGranularity,
},
{
desc: "supports file storage usage granularity (~100s of MiB): 13GiB -> 12.94GiB",
n: 13 * 1024 * 1024 * 1024,
expected: 13_900_000_000,
minResolution: storageGranularity,
},
{
desc: "supports file storage usage granularity (~100s of MiB): 10GiB -> 9.965GiB",
n: 10 * 1024 * 1024 * 1024,
expected: 10_700_000_000,
minResolution: storageGranularity,
},
{
// first number at which usage reports as in excess of 10GiB.
// Evaluates to 10299.5992MiB
// Should be close enough for notifying of being over limit.
desc: "supports file storage usage granularity (~100s of MiB): 10.0583GiB -> 10.0583GiB",
n: 10.0583 * 1024 * 1024 * 1024,
expected: 10_800_000_000,
minResolution: storageGranularity,
},
{
desc: "supports file storage usage granularity (~100s of MiB): 953.67MiB -> 953.67MiB",
n: 1_000_000_000,
expected: 1_000_000_000,
minResolution: storageGranularity,
},
{
desc: "supports file storage usage granularity (~100s of MiB): 1GiB -> 953.67MiB",
n: 1 * 1024 * 1024 * 1024,
expected: 1_000_000_000,
minResolution: storageGranularity,
},
{
desc: "supports file storage usage granularity (~100s of MiB): 1049.04MiB -> 1049.04MiB",
n: 1_100_000_000,
expected: 1_100_000_000,
minResolution: storageGranularity,
},
{
desc: "supports file storage usage granularity (smaller amounts): 104.904MiB -> 104.904MiB",
n: 100_000_000,
expected: 100_000_000,
minResolution: storageGranularity,
},
{
desc: "supports file storage usage granularity (smaller amounts): 10.4904MiB -> 10.4904MiB",
n: 10_000_000,
expected: 10_000_000,
minResolution: storageGranularity,
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
res := RoundOffToZeroesResolution(tc.n, tc.minResolution)
assert.Equal(t, tc.expected, res)
})
}
}
func TestIsValidWebAuthRedirectURL(t *testing.T) {
t.Run("Valid redirect URL with matching scheme and host", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com"),
},
}
redirectURL := "https://example.com/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.NoError(t, err)
})
t.Run("Valid redirect URL with matching scheme and host with port", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com:8080"),
},
}
redirectURL := "https://example.com:8080/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.NoError(t, err)
})
t.Run("Invalid redirect URL with different scheme", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com"),
},
}
redirectURL := "http://example.com/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "scheme")
})
t.Run("Invalid redirect URL with different host", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com"),
},
}
redirectURL := "https://malicious.com/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "host")
})
t.Run("Invalid redirect URL with different port", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com:8080"),
},
}
redirectURL := "https://example.com:9090/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "host")
})
t.Run("Invalid redirect URL - malformed URL", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com"),
},
}
redirectURL := "://not-a-valid-url"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse redirect URL")
})
t.Run("Invalid config - nil SiteURL", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: nil,
},
}
redirectURL := "https://example.com/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "SiteURL is not configured")
})
t.Run("Invalid config - malformed SiteURL", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("://not-a-valid-url"),
},
}
redirectURL := "https://example.com/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse SiteURL")
})
t.Run("Valid redirect URL with subdomain", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://app.example.com"),
},
}
redirectURL := "https://app.example.com/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.NoError(t, err)
})
t.Run("Invalid redirect URL with different subdomain", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://app.example.com"),
},
}
redirectURL := "https://api.example.com/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "host")
})
t.Run("Valid redirect URL with path", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com/mattermost"),
},
}
redirectURL := "https://example.com/mattermost/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.NoError(t, err)
})
t.Run("Valid redirect URL with query parameters", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com"),
},
}
redirectURL := "https://example.com/oauth/callback?state=abc123&code=def456"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.NoError(t, err)
})
t.Run("Valid redirect URL with fragment", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com"),
},
}
redirectURL := "https://example.com/oauth/callback#token=abc123"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.NoError(t, err)
})
t.Run("Invalid redirect URL with @ symbol in host", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://qa-release.test.mattermost.cloud"),
},
}
redirectURL := "https://qa-release.test.mattermost.cloud@example.com/oauth/callback"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "host")
})
t.Run("Valid relative URL path", func(t *testing.T) {
config := &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://example.com"),
},
}
redirectURL := "/test/channels/town-square"
err := ValidateWebAuthRedirectUrl(config, redirectURL)
require.NoError(t, err)
})
}