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,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package configservice
import (
"github.com/mattermost/mattermost/server/public/model"
)
// An interface representing something that contains a Config, such as the app.App struct
type ConfigService interface {
Config() *model.Config
AddConfigListener(func(old, current *model.Config)) string
RemoveConfigListener(string)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"golang.org/x/net/http/httpproxy"
)
const (
ConnectTimeout = 3 * time.Second
RequestTimeout = 30 * time.Second
)
var reservedIPRanges []*net.IPNet
// IsReservedIP checks whether the target IP belongs to reserved IP address ranges to avoid SSRF attacks to the internal
// network of the Mattermost server
func IsReservedIP(ip net.IP) bool {
// Canonicalize IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1) to their
// native IPv4 form so that IPv4 CIDR ranges match correctly.
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
for _, ipRange := range reservedIPRanges {
if ipRange.Contains(ip) {
return true
}
}
return false
}
// IsOwnIP handles the special case that a request might be made to the public IP of the host which on Linux is routed
// directly via the loopback IP to any listening sockets, effectively bypassing host-based firewalls such as firewalld
func IsOwnIP(ip net.IP) (bool, error) {
interfaces, err := net.Interfaces()
if err != nil {
return false, err
}
for _, interf := range interfaces {
addresses, err := interf.Addrs()
if err != nil {
return false, err
}
for _, addr := range addresses {
var selfIP net.IP
switch v := addr.(type) {
case *net.IPNet:
selfIP = v.IP
case *net.IPAddr:
selfIP = v.IP
}
if ip.Equal(selfIP) {
return true, nil
}
}
}
return false, nil
}
var defaultUserAgent string
func init() {
for _, cidr := range []string{
// Strings taken from https://github.com/doyensec/safeurl/blob/main/ip.go
"10.0.0.0/8", /* Private network - RFC 1918 */
"172.16.0.0/12", /* Private network - RFC 1918 */
"192.168.0.0/16", /* Private network - RFC 1918 */
"127.0.0.0/8", /* Loopback - RFC 1122, Section 3.2.1.3 */
"0.0.0.0/8", /* Current network (only valid as source address) - RFC 1122, Section 3.2.1.3 */
"169.254.0.0/16", /* Link-local - RFC 3927 */
"192.0.0.0/24", /* IETF Protocol Assignments - RFC 5736 */
"192.0.2.0/24", /* TEST-NET-1, documentation and examples - RFC 5737 */
"198.51.100.0/24", /* TEST-NET-2, documentation and examples - RFC 5737 */
"203.0.113.0/24", /* TEST-NET-3, documentation and examples - RFC 5737 */
"192.88.99.0/24", /* IPv6 to IPv4 relay (includes 2002::/16) - RFC 3068 */
"198.18.0.0/15", /* Network benchmark tests - RFC 2544 */
"224.0.0.0/4", /* IP multicast (former Class D network) - RFC 3171 */
"240.0.0.0/4", /* Reserved (former Class E network) - RFC 1112, Section 4 */
"255.255.255.255/32", /* Broadcast - RFC 919, Section 7 */
"100.64.0.0/10", /* Shared Address Space - RFC 6598 */
// ipv6 sourced from https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
"::/128", /* Unspecified Address - RFC 4291 */
"::1/128", /* Loopback - RFC 4291 */
"100::/64", /* Discard prefix - RFC 6666 */
"2001::/23", /* IETF Protocol Assignments - RFC 2928 */
"2001:2::/48", /* Benchmarking - RFC5180 */
"2001:db8::/32", /* Addresses used in documentation and example source code - RFC 3849 */
"2001::/32", /* Teredo tunneling - RFC4380 - RFC8190 */
"fc00::/7", /* Unique local address - RFC 4193 - RFC 8190 */
"fe80::/10", /* Link-local address - RFC 4291 */
"ff00::/8", /* Multicast - RFC 3513 */
"2002::/16", /* 6to4 - RFC 3056 */
"64:ff9b::/96", /* IPv4/IPv6 translation - RFC 6052 */
"2001:10::/28", /* Deprecated (previously ORCHID) - RFC 4843 */
"2001:20::/28", /* ORCHIDv2 - RFC7343 */
} {
_, parsed, err := net.ParseCIDR(cidr)
if err != nil {
panic(err)
}
reservedIPRanges = append(reservedIPRanges, parsed)
}
defaultUserAgent = "Mattermost-Bot/1.1"
}
type DialContextFunction func(ctx context.Context, network, addr string) (net.Conn, error)
var ErrAddressForbidden = errors.New("address forbidden, you may need to set AllowedUntrustedInternalConnections to allow an integration access to your internal network")
// dialContextFilter wraps a dial function to filter connections based on host and IP validation.
// It first checks if the host is allowed, then resolves the hostname to IPs and validates each one.
// Returns detailed error messages when connections are rejected for security reasons.
func dialContextFilter(dial DialContextFunction, allowHost func(host string) bool, allowIP func(ip net.IP) error) DialContextFunction {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
if allowHost != nil && allowHost(host) {
return dial(ctx, network, addr)
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, err
}
var firstDialErr error
var forbiddenReasons []string
for _, ip := range ips {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if allowIP == nil {
forbiddenReasons = append(forbiddenReasons, fmt.Sprintf("IP %s is not allowed", ip))
continue
}
if err := allowIP(ip); err != nil {
forbiddenReasons = append(forbiddenReasons, err.Error())
continue
}
conn, err := dial(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
if firstDialErr == nil {
firstDialErr = err
}
}
if firstDialErr == nil {
// If we didn't find an allowed IP address, return an error explaining why
if len(forbiddenReasons) > 0 {
return nil, fmt.Errorf("%s: %s", ErrAddressForbidden.Error(), strings.Join(forbiddenReasons, "; "))
}
return nil, ErrAddressForbidden
}
return nil, firstDialErr
}
}
func getProxyFn() func(r *http.Request) (*url.URL, error) {
proxyFromEnvFn := httpproxy.FromEnvironment().ProxyFunc()
return func(r *http.Request) (*url.URL, error) {
// TODO: Consider removing this code once MM-61938 is fixed upstream.
if r.URL != nil {
if addr, err := netip.ParseAddr(r.URL.Hostname()); err == nil && addr.Is6() && addr.Zone() != "" {
return nil, fmt.Errorf("invalid IPv6 address in URL: %q", addr)
}
}
return proxyFromEnvFn(r.URL)
}
}
// NewTransport creates a new MattermostTransport with detailed error messages for IP check failures
func NewTransport(enableInsecureConnections bool, allowHost func(host string) bool, allowIP func(ip net.IP) error) *MattermostTransport {
dialContext := (&net.Dialer{
Timeout: ConnectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext
if allowHost != nil || allowIP != nil {
dialContext = dialContextFilter(dialContext, allowHost, allowIP)
}
return &MattermostTransport{
&http.Transport{
Proxy: getProxyFn(),
DialContext: dialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: ConnectTimeout,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: enableInsecureConnections,
},
},
}
}

View File

@@ -0,0 +1,416 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPClient(t *testing.T) {
mockHTTP := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer mockHTTP.Close()
mockSelfSignedHTTPS := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer mockSelfSignedHTTPS.Close()
t.Run("insecure connections", func(t *testing.T) {
disableInsecureConnections := false
enableInsecureConnections := true
testCases := []struct {
description string
enableInsecureConnections bool
url string
expectedAllowed bool
}{
{"allow HTTP even when insecure disabled", disableInsecureConnections, mockHTTP.URL, true},
{"allow HTTP when insecure enabled", enableInsecureConnections, mockHTTP.URL, true},
{"reject self-signed HTTPS even when insecure disabled", disableInsecureConnections, mockSelfSignedHTTPS.URL, false},
{"allow self-signed HTTPS when insecure enabled", enableInsecureConnections, mockSelfSignedHTTPS.URL, true},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
c := NewHTTPClient(NewTransport(testCase.enableInsecureConnections, nil, nil))
if _, err := c.Get(testCase.url); testCase.expectedAllowed {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
})
t.Run("checks", func(t *testing.T) {
allowHost := func(_ string) bool { return true }
rejectHost := func(_ string) bool { return false }
allowIP := func(_ net.IP) error { return nil }
rejectIP := func(_ net.IP) error { return fmt.Errorf("IP not allowed") }
testCases := []struct {
description string
allowHost func(string) bool
allowIP func(net.IP) error
expectedAllowed bool
}{
{"allow with no checks", nil, nil, true},
{"reject without host check when ip rejected", nil, rejectIP, false},
{"allow without host check when ip allowed", nil, allowIP, true},
{"reject when host rejected since no ip check", rejectHost, nil, false},
{"reject when host and ip rejected", rejectHost, rejectIP, false},
{"allow when host rejected since ip allowed", rejectHost, allowIP, true},
{"allow when host allowed even without ip check", allowHost, nil, true},
{"allow when host allowed even if ip rejected", allowHost, rejectIP, true},
{"allow when host and ip allowed", allowHost, allowIP, true},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
c := NewHTTPClient(NewTransport(false, testCase.allowHost, testCase.allowIP))
if _, err := c.Get(mockHTTP.URL); testCase.expectedAllowed {
require.NoError(t, err)
} else {
require.IsType(t, &url.Error{}, err)
require.Contains(t, err.(*url.Error).Err.Error(), "address forbidden")
}
})
}
})
}
func TestNewTransportForInternalConnections(t *testing.T) {
// httptest servers listen on a loopback address, which is a reserved range.
mockHTTP := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer mockHTTP.Close()
u, err := url.Parse(mockHTTP.URL)
require.NoError(t, err)
host := u.Hostname()
t.Run("blocks reserved IP ranges when the allowlist is empty", func(t *testing.T) {
c := NewHTTPClient(NewTransportForInternalConnections(false, ""))
_, err := c.Get(mockHTTP.URL)
require.IsType(t, &url.Error{}, err)
require.Contains(t, err.(*url.Error).Err.Error(), "address forbidden")
})
t.Run("allows a host named in the allowlist", func(t *testing.T) {
c := NewHTTPClient(NewTransportForInternalConnections(false, host))
resp, err := c.Get(mockHTTP.URL)
require.NoError(t, err)
resp.Body.Close()
})
t.Run("allows a CIDR range named in the allowlist", func(t *testing.T) {
c := NewHTTPClient(NewTransportForInternalConnections(false, "127.0.0.0/8 ::1/128"))
resp, err := c.Get(mockHTTP.URL)
require.NoError(t, err)
resp.Body.Close()
})
}
func TestHTTPClientWithProxy(t *testing.T) {
proxy := createProxyServer()
defer proxy.Close()
c := NewHTTPClient(NewTransport(true, nil, nil))
purl, _ := url.Parse(proxy.URL)
c.Transport.(*MattermostTransport).Transport.(*http.Transport).Proxy = http.ProxyURL(purl)
resp, err := c.Get("http://acme.com")
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "proxy", string(body))
t.Run("invalid IPv6 address in proxied URL", func(t *testing.T) {
c := NewHTTPClient(NewTransport(true, nil, nil))
t.Setenv("HTTP_PROXY", "http://proxy.example.org")
t.Setenv("HTTPS_PROXY", "https://proxy.example.org")
t.Setenv("NO_PROXY", ".example.com")
_, err := c.Get("http://[fe80::8e87:5021:6f6c:605e%25eth0]")
require.EqualError(t, err, `Get "http://[fe80::8e87:5021:6f6c:605e%25eth0]": invalid IPv6 address in URL: "fe80::8e87:5021:6f6c:605e%eth0"`)
})
}
func createProxyServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Header().Set("Content-Type", "text/plain; charset=us-ascii")
fmt.Fprint(w, "proxy")
}))
}
func TestDialContextFilter(t *testing.T) {
for _, tc := range []struct {
Addr string
IsValid bool
}{
{
Addr: "google.com:80",
IsValid: true,
},
{
Addr: "8.8.8.8:53",
IsValid: true,
},
{
Addr: "127.0.0.1:80",
},
{
Addr: "10.0.0.1:80",
IsValid: true,
},
} {
didDial := false
filter := dialContextFilter(func(ctx context.Context, network, addr string) (net.Conn, error) {
didDial = true
return nil, nil
}, func(host string) bool { return host == "10.0.0.1" }, func(ip net.IP) error {
if IsReservedIP(ip) {
return fmt.Errorf("IP %s is reserved", ip)
}
return nil
})
_, err := filter(context.Background(), "", tc.Addr)
if tc.IsValid {
require.NoError(t, err)
require.True(t, didDial)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), "address forbidden")
require.False(t, didDial)
}
}
}
func TestUserAgentIsSet(t *testing.T) {
testUserAgent := "test-user-agent"
defaultUserAgent = testUserAgent
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ua := req.UserAgent()
assert.NotEqual(t, "", ua, "expected user-agent to be non-empty")
assert.Equalf(t, testUserAgent, ua, "expected user-agent to be %q but was %q", testUserAgent, ua)
}))
defer ts.Close()
client := NewHTTPClient(NewTransport(true, nil, nil))
req, err := http.NewRequest("GET", ts.URL, nil)
require.NoError(t, err, "NewRequest failed", err)
_, err = client.Do(req)
require.NoError(t, err, "Do failed", err)
}
func NewHTTPClient(transport http.RoundTripper) *http.Client {
return &http.Client{
Transport: transport,
}
}
func TestIsReservedIP(t *testing.T) {
tests := []struct {
name string
ip net.IP
want bool
}{
{"127.8.3.5", net.IPv4(127, 8, 3, 5), true},
{"192.168.0.1", net.IPv4(192, 168, 0, 1), true},
{"169.254.0.6", net.IPv4(169, 254, 0, 6), true},
{"127.120.6.3", net.IPv4(127, 120, 6, 3), true},
{"8.8.8.8", net.IPv4(8, 8, 8, 8), false},
{"9.9.9.9", net.IPv4(9, 9, 9, 8), false},
// IPv4-mapped IPv6 addresses should be detected as reserved
{"::ffff:127.0.0.1", net.ParseIP("::ffff:127.0.0.1"), true},
{"::ffff:192.168.1.1", net.ParseIP("::ffff:192.168.1.1"), true},
{"::ffff:10.0.0.1", net.ParseIP("::ffff:10.0.0.1"), true},
{"::ffff:169.254.169.254", net.ParseIP("::ffff:169.254.169.254"), true},
{"::ffff:8.8.8.8", net.ParseIP("::ffff:8.8.8.8"), false},
// Pure IPv6 reserved addresses
{"::1", net.ParseIP("::1"), true},
{"fe80::1", net.ParseIP("fe80::1"), true},
// Public IPv6
{"2607:f8b0:4004:800::200e", net.ParseIP("2607:f8b0:4004:800::200e"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsReservedIP(tt.ip)
assert.Equalf(t, tt.want, got, "IsReservedIP() = %v, want %v", got, tt.want)
})
}
}
func TestIsOwnIP(t *testing.T) {
tests := []struct {
name string
ip net.IP
want bool
}{
{"127.0.0.1", net.IPv4(127, 0, 0, 1), true},
{"8.8.8.8", net.IPv4(8, 0, 0, 8), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := IsOwnIP(tt.ip)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestSplitHostnames(t *testing.T) {
var config string
var hostnames []string
config = ""
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{}, hostnames)
config = "127.0.0.1 localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1,localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1,,localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1 localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1 , localhost"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1 localhost "
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = " 127.0.0.1 ,,localhost , , ,,"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost"}, hostnames)
config = "127.0.0.1 localhost, 192.168.1.0"
hostnames = strings.FieldsFunc(config, splitFields)
require.Equal(t, []string{"127.0.0.1", "localhost", "192.168.1.0"}, hostnames)
}
func TestGetProxyFn(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://proxy.example.org")
t.Setenv("HTTPS_PROXY", "https://proxy.example.org")
t.Setenv("NO_PROXY", ".example.com")
for _, tc := range []struct {
name string
input string
output string
err string
}{
{
name: "empty",
},
{
name: "no proxy",
input: "http://test.example.com",
},
{
name: "localhost",
input: "http://localhost",
},
{
name: "hostname",
input: "http://example.org",
output: "http://proxy.example.org",
},
{
name: "hostname with port",
input: "http://example.org:4545",
output: "http://proxy.example.org",
},
{
name: "https",
input: "https://example.org",
output: "https://proxy.example.org",
},
{
name: "ipv4",
input: "http://10.0.0.45",
output: "http://proxy.example.org",
},
{
name: "ipv4 with port",
input: "http://10.0.0.45:4545",
output: "http://proxy.example.org",
},
{
name: "ipv6",
input: "http://[fe80::8e87:5021:6f6c:605e]",
output: "http://proxy.example.org",
},
{
name: "ipv6 with port",
input: "http://[fe80::8e87:5021:6f6c:605e]:4545",
output: "http://proxy.example.org",
},
{
name: "ipv6 with zone",
input: "http://[fe80::8e87:5021:6f6c:605e%25eth0]",
output: "http://proxy.example.org",
err: `invalid IPv6 address in URL: "fe80::8e87:5021:6f6c:605e%eth0"`,
},
{
name: "ipv6 with zone and port",
input: "http://[fe80::8e87:5021:6f6c:605e%25eth0]:4545",
output: "http://proxy.example.org",
err: `invalid IPv6 address in URL: "fe80::8e87:5021:6f6c:605e%eth0"`,
},
} {
t.Run(tc.name, func(t *testing.T) {
inURL, err := url.Parse(tc.input)
require.NoError(t, err)
outURL, err := getProxyFn()(&http.Request{
URL: inURL,
})
if tc.err != "" { // error case
require.EqualError(t, err, tc.err)
return
}
require.NoError(t, err)
if tc.output == "" { // not proxied case
require.Nil(t, outURL)
} else { // proxied case
require.NotNil(t, outURL)
require.Equal(t, tc.output, outURL.String())
}
})
}
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"fmt"
"net"
"net/http"
"slices"
"strings"
"time"
"unicode"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
// HTTPService wraps the functionality for making http requests to provide some improvements to the default client
// behaviour.
type HTTPService interface {
// MakeClient returns an http client constructed with a RoundTripper as returned by MakeTransport.
MakeClient(trustURLs bool) *http.Client
// MakeTransport returns a RoundTripper that is suitable for making requests to external resources. The default
// implementation provides:
// - A shorter timeout for dial and TLS handshake (defined as constant "ConnectTimeout")
// - A timeout for end-to-end requests
// - A Mattermost-specific user agent header
// - Additional security for untrusted and insecure connections
MakeTransport(trustURLs bool) *MattermostTransport
}
type getConfig interface {
Config() *model.Config
}
type HTTPServiceImpl struct {
configService getConfig
RequestTimeout time.Duration
}
func splitFields(c rune) bool {
return unicode.IsSpace(c) || c == ','
}
func MakeHTTPService(configService getConfig) HTTPService {
return &HTTPServiceImpl{
configService,
RequestTimeout,
}
}
type pluginAPIConfigServiceAdapter struct {
pluginAPIConfigService plugin.API
}
func (p *pluginAPIConfigServiceAdapter) Config() *model.Config {
return p.pluginAPIConfigService.GetConfig()
}
func MakeHTTPServicePlugin(configService plugin.API) HTTPService {
return MakeHTTPService(&pluginAPIConfigServiceAdapter{configService})
}
func (h *HTTPServiceImpl) MakeClient(trustURLs bool) *http.Client {
return &http.Client{
Transport: h.MakeTransport(trustURLs),
Timeout: h.RequestTimeout,
}
}
// isAllowedInternalHost reports whether host appears verbatim in a
// space/comma-separated AllowedUntrustedInternalConnections value.
func isAllowedInternalHost(host, allowedUntrustedInternalConnections string) bool {
return slices.Contains(strings.FieldsFunc(allowedUntrustedInternalConnections, splitFields), host)
}
// checkInternalIP returns nil if ip may be dialed, or an error if it is a
// reserved-range or self-assigned IP not covered by a CIDR entry in the
// space/comma-separated AllowedUntrustedInternalConnections value.
func checkInternalIP(ip net.IP, allowedUntrustedInternalConnections string) error {
reservedIP := IsReservedIP(ip)
ownIP, err := IsOwnIP(ip)
if err != nil {
// If there is an error getting the self-assigned IPs, default to the secure option
return fmt.Errorf("unable to determine if IP is own IP: %w", err)
}
// If it's not a reserved IP and it's not self-assigned IP, accept the IP
if !reservedIP && !ownIP {
return nil
}
// Otherwise it needs to be explicitly added to AllowedUntrustedInternalConnections
for _, allowed := range strings.FieldsFunc(allowedUntrustedInternalConnections, splitFields) {
if _, ipRange, err := net.ParseCIDR(allowed); err == nil && ipRange.Contains(ip) {
return nil
}
}
if reservedIP {
return fmt.Errorf("IP %s is in a reserved range and not in AllowedUntrustedInternalConnections", ip)
}
return fmt.Errorf("IP %s is a self-assigned IP and not in AllowedUntrustedInternalConnections", ip)
}
// NewTransportForInternalConnections returns a transport that applies the same
// AllowedUntrustedInternalConnections filtering as MakeTransport, but takes the
// allowlist as a static value instead of reading it from a config service. It is
// for callers that have the allowlist string but not an HTTPService.
func NewTransportForInternalConnections(insecure bool, allowedUntrustedInternalConnections string) *MattermostTransport {
allowHost := func(host string) bool {
return isAllowedInternalHost(host, allowedUntrustedInternalConnections)
}
allowIP := func(ip net.IP) error {
return checkInternalIP(ip, allowedUntrustedInternalConnections)
}
return NewTransport(insecure, allowHost, allowIP)
}
func (h *HTTPServiceImpl) MakeTransport(trustURLs bool) *MattermostTransport {
insecure := h.configService.Config().ServiceSettings.EnableInsecureOutgoingConnections != nil && *h.configService.Config().ServiceSettings.EnableInsecureOutgoingConnections
if trustURLs {
return NewTransport(insecure, nil, nil)
}
// The allowlist is read inside the closures so a long-lived client picks up
// config changes on each dial.
allowHost := func(host string) bool {
return isAllowedInternalHost(host, model.SafeDereference(h.configService.Config().ServiceSettings.AllowedUntrustedInternalConnections))
}
allowIP := func(ip net.IP) error {
return checkInternalIP(ip, model.SafeDereference(h.configService.Config().ServiceSettings.AllowedUntrustedInternalConnections))
}
return NewTransport(insecure, allowHost, allowIP)
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
type testConfigService struct {
config *model.Config
}
func (s *testConfigService) Config() *model.Config {
return s.config
}
// newTestHTTPService returns a service with the given default request timeout that is allowed to
// dial the loopback addresses used by httptest.
func newTestHTTPService(requestTimeout time.Duration) *HTTPServiceImpl {
config := &model.Config{}
config.SetDefaults()
config.ServiceSettings.AllowedUntrustedInternalConnections = new("127.0.0.1")
return &HTTPServiceImpl{
configService: &testConfigService{config: config},
RequestTimeout: requestTimeout,
}
}
func newSlowServer(t *testing.T, delay time.Duration) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(delay):
w.WriteHeader(http.StatusOK)
case <-r.Context().Done():
}
}))
t.Cleanup(server.Close)
return server
}
func doGet(t *testing.T, client *http.Client, rawURL string, contextTimeout time.Duration) (*http.Response, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
require.NoError(t, err)
return client.Do(req)
}
func TestMakeClientTimeout(t *testing.T) {
config := &model.Config{}
config.SetDefaults()
assert.Equal(t, RequestTimeout, MakeHTTPService(&testConfigService{config: config}).MakeClient(false).Timeout,
"the production constructor applies the package default")
service := newTestHTTPService(RequestTimeout)
assert.Equal(t, RequestTimeout, service.MakeClient(false).Timeout)
// Requests go through the transport applying the Mattermost user agent and the
// AllowedUntrustedInternalConnections checks regardless of trustURLs.
for _, trustURLs := range []bool{true, false} {
assert.IsType(t, &MattermostTransport{}, service.MakeClient(trustURLs).Transport)
}
}
// MM-68319 in miniature: a caller whose deadline is longer than the default request timeout only
// reaches a slow endpoint if its client's own timeout is cleared, as the outgoing integration
// call sites do.
func TestClientWithoutTimeoutOutlivesDefaultTimeout(t *testing.T) {
const defaultTimeout = 100 * time.Millisecond
const handlerDelay = 5 * defaultTimeout
server := newSlowServer(t, handlerDelay)
service := newTestHTTPService(defaultTimeout)
clientWithoutTimeout := func() *http.Client {
c := service.MakeClient(false)
c.Timeout = 0
return c
}
t.Run("the default request timeout caps a longer context deadline", func(t *testing.T) {
_, err := doGet(t, service.MakeClient(false), server.URL, 100*defaultTimeout)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
t.Run("a client without a timeout runs to the context deadline", func(t *testing.T) {
resp, err := doGet(t, clientWithoutTimeout(), server.URL, 100*defaultTimeout)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("a client without a timeout still honours a shorter context deadline", func(t *testing.T) {
_, err := doGet(t, clientWithoutTimeout(), server.URL, defaultTimeout)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package httpservice
import (
"net/http"
)
// MattermostTransport is an implementation of http.RoundTripper that ensures each request contains a custom user agent
// string to indicate that the request is coming from a Mattermost instance.
type MattermostTransport struct {
// Transport is the underlying http.RoundTripper that is actually used to make the request
Transport http.RoundTripper
}
func (t *MattermostTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", defaultUserAgent)
return t.Transport.RoundTrip(req)
}

View File

@@ -0,0 +1,354 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package i18n
import (
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"sync"
"github.com/mattermost/go-i18n/i18n"
"github.com/mattermost/go-i18n/i18n/bundle"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// mut is used to protect other global variables from concurrent access.
// This should only be a concern in parallel tests.
var mut sync.Mutex
const defaultLocale = "en"
// TranslateFunc is the type of the translate functions
type TranslateFunc func(translationID string, args ...any) string
// TranslationFuncByLocal is the type of function that takes local as a string and returns the translation function
type TranslationFuncByLocal func(locale string) TranslateFunc
var (
t TranslateFunc
tDefault TranslateFunc
)
// T is the translate function using the default server language as fallback language
var T TranslateFunc = func(translationID string, args ...any) string {
mut.Lock()
defer mut.Unlock()
if t == nil {
return translationID
}
return t(translationID, args...)
}
// TranslationId is a no-op translation implementation for ensuring the string is retained in translation files
var TranslationId TranslateFunc = func(translationID string, args ...any) string {
return translationID
}
// TDefault is the translate function using english as fallback language
var TDefault TranslateFunc = func(translationID string, args ...any) string {
mut.Lock()
defer mut.Unlock()
if tDefault == nil {
return translationID
}
return t(translationID, args...)
}
var locales = make(map[string]string)
// supportedLocales is a hard-coded list of locales considered ready for production use. It must
// be kept in sync with ../../../../webapp/channels/src/i18n/i18n.ts.
var supportedLocales = []string{
"de",
"en",
"en-AU",
"es",
"fr",
"it",
"hu",
"nl",
"pl",
"pt-BR",
"ro",
"sv",
"vi",
"tr",
"bg",
"ru",
"uk",
"fa",
"ko",
"zh-CN",
"zh-TW",
"ja",
}
var (
defaultServerLocale string
defaultClientLocale string
)
// TranslationsPreInit loads translations from filesystem if they are not
// loaded already and assigns english while loading server config
func TranslationsPreInit(translationsDir string) error {
mut.Lock()
defer mut.Unlock()
if t != nil {
return nil
}
// Set T even if we fail to load the translations. Lots of shutdown handling code will
// segfault trying to handle the error, and the untranslated IDs are strictly better.
t = tfuncWithFallback(defaultLocale)
tDefault = tfuncWithFallback(defaultLocale)
return initTranslationsWithDir(translationsDir)
}
// TranslationsPreInitFromFileBytes loads translations from a buffer -- useful if
// we need to initialize i18n from an embedded i18n file (e.g., from a CLI tool)
func TranslationsPreInitFromFileBytes(filename string, buf []byte) error {
mut.Lock()
defer mut.Unlock()
if t != nil {
return nil
}
// Set T even if we fail to load the translations. Lots of shutdown handling code will
// segfault trying to handle the error, and the untranslated IDs are strictly better.
t = tfuncWithFallback(defaultLocale)
tDefault = tfuncWithFallback(defaultLocale)
locale := strings.Split(filename, ".")[0]
if !isSupportedLocale(locale) {
return fmt.Errorf("locale not supported: %s", locale)
}
locales[locale] = filename
return i18n.ParseTranslationFileBytes(filename, buf)
}
// InitTranslations set the defaults configured in the server and initialize
// the T function using the server default as fallback language
func InitTranslations(serverLocale, clientLocale string) error {
mut.Lock()
defaultServerLocale = serverLocale
defaultClientLocale = clientLocale
mut.Unlock()
tfn, err := GetTranslationsBySystemLocale()
mut.Lock()
t = tfn
mut.Unlock()
return err
}
func initTranslationsWithDir(dir string) error {
files, _ := os.ReadDir(dir)
for _, f := range files {
if filepath.Ext(f.Name()) == ".json" {
filename := f.Name()
locale := strings.Split(filename, ".")[0]
if !isSupportedLocale(locale) {
continue
}
locales[locale] = filepath.Join(dir, filename)
if err := i18n.LoadTranslationFile(filepath.Join(dir, filename)); err != nil {
return err
}
}
}
return nil
}
// GetTranslationFuncForDir loads translations from the filesystem into a new instance of the bundle.
// It returns a function to access loaded translations.
func GetTranslationFuncForDir(dir string) (TranslationFuncByLocal, error) {
availableLocals := make(map[string]string)
bundle := bundle.New()
files, _ := os.ReadDir(dir)
for _, f := range files {
if filepath.Ext(f.Name()) != ".json" {
continue
}
locale := strings.Split(f.Name(), ".")[0]
if !isSupportedLocale(locale) {
continue
}
filename := f.Name()
availableLocals[locale] = filepath.Join(dir, filename)
if err := bundle.LoadTranslationFile(filepath.Join(dir, filename)); err != nil {
return nil, err
}
}
return func(locale string) TranslateFunc {
if _, ok := availableLocals[locale]; !ok {
locale = defaultLocale
}
t, _ := bundle.Tfunc(locale)
return func(translationID string, args ...any) string {
if translated := t(translationID, args...); translated != translationID {
return translated
}
t, _ := bundle.Tfunc(defaultLocale)
return t(translationID, args...)
}
}, nil
}
func GetTranslationsBySystemLocale() (TranslateFunc, error) {
mut.Lock()
defer mut.Unlock()
locale := defaultServerLocale
if _, ok := locales[locale]; !ok {
mlog.Warn("Failed to load system translations for selected locale, attempting to fall back to default", mlog.String("locale", locale), mlog.String("default_locale", defaultLocale))
locale = defaultLocale
}
if !isSupportedLocale(locale) {
mlog.Warn("Selected locale is unsupported, attempting to fall back to default", mlog.String("locale", locale), mlog.String("default_locale", defaultLocale))
locale = defaultLocale
}
if locales[locale] == "" {
return nil, fmt.Errorf("failed to load system translations for '%v'", defaultLocale)
}
translations := tfuncWithFallback(locale)
if translations == nil {
return nil, fmt.Errorf("failed to load system translations")
}
mlog.Info("Loaded system translations", mlog.String("for locale", locale), mlog.String("from locale", locales[locale]))
return translations, nil
}
// GetUserTranslations get the translation function for an specific locale
func GetUserTranslations(locale string) TranslateFunc {
mut.Lock()
defer mut.Unlock()
if _, ok := locales[locale]; !ok {
locale = defaultLocale
}
translations := tfuncWithFallback(locale)
return translations
}
// GetTranslationsAndLocaleFromRequest return the translation function and the
// locale based on a request headers
func GetTranslationsAndLocaleFromRequest(r *http.Request) (TranslateFunc, string) {
mut.Lock()
defer mut.Unlock()
// This is for checking against locales like pt_BR or zn_CN
headerLocaleFull := strings.Split(r.Header.Get("Accept-Language"), ",")[0]
// This is for checking against locales like en, es
headerLocale := strings.Split(strings.Split(r.Header.Get("Accept-Language"), ",")[0], "-")[0]
defaultLocale := defaultClientLocale
if locales[headerLocaleFull] != "" {
translations := tfuncWithFallback(headerLocaleFull)
return translations, headerLocaleFull
} else if locales[headerLocale] != "" {
translations := tfuncWithFallback(headerLocale)
return translations, headerLocale
} else if locales[defaultLocale] != "" {
translations := tfuncWithFallback(defaultLocale)
return translations, headerLocale
}
translations := tfuncWithFallback(defaultLocale)
return translations, defaultLocale
}
// GetSupportedLocales return a map of locale code and the file path with the
// translations
func GetSupportedLocales() map[string]string {
mut.Lock()
defer mut.Unlock()
return locales
}
func tfuncWithFallback(pref string) TranslateFunc {
t, _ := i18n.Tfunc(pref)
return func(translationID string, args ...any) string {
if translated := t(translationID, args...); translated != translationID {
return translated
}
t, _ := i18n.Tfunc(defaultLocale)
return t(translationID, args...)
}
}
// TranslateAsHTML translates the translationID provided and return a
// template.HTML object
func TranslateAsHTML(t TranslateFunc, translationID string, args map[string]any) template.HTML {
message := t(translationID, escapeForHTML(args))
message = strings.Replace(message, "[[", "<strong>", -1)
message = strings.Replace(message, "]]", "</strong>", -1)
return template.HTML(message)
}
func escapeForHTML(arg any) any {
switch typedArg := arg.(type) {
case string:
return template.HTMLEscapeString(typedArg)
case *string:
return template.HTMLEscapeString(*typedArg)
case int:
return typedArg
case int64:
return typedArg
case float64:
return typedArg
case map[string]any:
safeArg := make(map[string]any, len(typedArg))
for key, value := range typedArg {
safeArg[key] = escapeForHTML(value)
}
return safeArg
default:
mlog.Warn(
"Unable to escape value for HTML template",
mlog.Any("html_template", arg),
mlog.String("template_type", reflect.ValueOf(arg).Type().String()),
)
return ""
}
}
// IdentityTfunc returns a translation function that don't translate, only
// returns the same id
func IdentityTfunc() TranslateFunc {
return func(translationID string, args ...any) string {
return translationID
}
}
func isSupportedLocale(locale string) bool {
return slices.Contains(supportedLocales, locale)
}

View File

@@ -0,0 +1,298 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package i18n
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/mattermost/go-i18n/i18n/bundle"
"github.com/mattermost/go-i18n/i18n/language"
"github.com/mattermost/go-i18n/i18n/translation"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var htmlTestTranslationBundle *bundle.Bundle
func init() {
htmlTestTranslationBundle = bundle.New()
fooBold, _ := translation.NewTranslation(map[string]any{
"id": "foo.bold",
"translation": "<p>[[{{ .Foo }}]]</p>",
})
htmlTestTranslationBundle.AddTranslation(&language.Language{Tag: "en"}, fooBold)
}
func TestTranslateAsHTML(t *testing.T) {
assert.EqualValues(t, "<p><strong>&lt;i&gt;foo&lt;/i&gt;</strong></p>", TranslateAsHTML(TranslateFunc(htmlTestTranslationBundle.MustTfunc("en")), "foo.bold", map[string]any{
"Foo": "<i>foo</i>",
}))
}
func TestEscapeForHTML(t *testing.T) {
stringForPointer := "<b>abc</b>"
for name, tc := range map[string]struct {
In any
Expected any
}{
"NoHTML": {
In: "abc",
Expected: "abc",
},
"String": {
In: "<b>abc</b>",
Expected: "&lt;b&gt;abc&lt;/b&gt;",
},
"StringPointer": {
In: &stringForPointer,
Expected: "&lt;b&gt;abc&lt;/b&gt;",
},
"Map": {
In: map[string]any{
"abc": "abc",
"123": "<b>123</b>",
},
Expected: map[string]any{
"abc": "abc",
"123": "&lt;b&gt;123&lt;/b&gt;",
},
},
"Int": {
In: 59,
Expected: 59,
},
"Int64": {
In: int64(59),
Expected: int64(59),
},
"Float64": {
In: 3.14,
Expected: 3.14,
},
"Unsupported": {
In: struct{ string }{"<b>abc</b>"},
Expected: "",
},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.Expected, escapeForHTML(tc.In))
})
}
}
func TestInitTranslationsWithDir(t *testing.T) {
i18nDir, found := utils.FindDir("server/i18n")
require.True(t, found, "unable to find i18n dir")
setup := func(t *testing.T, localesToCopy map[string]string) string {
tempDir, err := os.MkdirTemp(os.TempDir(), "TestGetTranslationFuncForDir")
require.NoError(t, err, "unable to create temporary directory")
t.Cleanup(func() {
err = os.RemoveAll(tempDir)
require.NoError(t, err)
})
for locale, fromLocale := range localesToCopy {
err = utils.CopyFile(
filepath.Join(i18nDir, fmt.Sprintf("%s.json", fromLocale)),
filepath.Join(tempDir, fmt.Sprintf("%s.json", locale)),
)
require.NoError(t, err)
}
return tempDir
}
t.Run("unsupported locale ignored", func(t *testing.T) {
tempDir := setup(t, map[string]string{"en": "en", "fr": "fr", "zz": "en"})
err := initTranslationsWithDir(tempDir)
require.NoError(t, err)
_, found := locales["zz"]
require.False(t, found, "should have ignored unsupported locale")
})
t.Run("malformed, unsupported locale ignored", func(t *testing.T) {
tempDir := setup(t, map[string]string{"en": "en", "fr": "fr", "zz": "en"})
err := os.WriteFile(filepath.Join(tempDir, "xx.json"), []byte{'{'}, os.ModePerm)
require.NoError(t, err)
err = initTranslationsWithDir(tempDir)
require.NoError(t, err)
_, found := locales["xx"]
require.False(t, found, "should have ignored malformed, unsupported locale")
})
t.Run("malformed, supported locale causes error", func(t *testing.T) {
tempDir := setup(t, map[string]string{"fr": "fr", "zz": "en"})
err := os.WriteFile(filepath.Join(tempDir, "en.json"), []byte{'{'}, os.ModePerm)
require.NoError(t, err)
err = initTranslationsWithDir(tempDir)
require.Error(t, err, "should have failed to load malformed, supported locale")
})
t.Run("known locales loaded ", func(t *testing.T) {
tempDir := setup(t, map[string]string{"en": "en", "fr": "fr"})
err := initTranslationsWithDir(tempDir)
require.NoError(t, err)
_, found := locales["en"]
require.True(t, found, "should have found en locale")
_, found = locales["fr"]
require.True(t, found, "should have found fr locale")
_, found = locales["es"]
require.False(t, found, "should not have found unloaded es locale")
})
}
func TestGetTranslationFuncForDir(t *testing.T) {
i18nDir, found := utils.FindDir("server/i18n")
require.True(t, found, "unable to find i18n dir")
setup := func(t *testing.T, localesToCopy map[string]string) string {
tempDir, err := os.MkdirTemp(os.TempDir(), "TestGetTranslationFuncForDir")
require.NoError(t, err, "unable to create temporary directory")
t.Cleanup(func() {
err = os.RemoveAll(tempDir)
require.NoError(t, err)
})
for locale, fromLocale := range localesToCopy {
err = utils.CopyFile(
filepath.Join(i18nDir, fmt.Sprintf("%s.json", fromLocale)),
filepath.Join(tempDir, fmt.Sprintf("%s.json", locale)),
)
require.NoError(t, err)
}
return tempDir
}
t.Run("unknown locale falls back to english", func(t *testing.T) {
tempDir := setup(t, map[string]string{"en": "en", "fr": "fr", "zz": "en"})
translationFunc, err := GetTranslationFuncForDir(tempDir)
require.NoError(t, err)
require.NotNil(t, translationFunc)
require.Equal(t, "December", translationFunc("unknown")("December"))
})
t.Run("unsupported locale falls back to english", func(t *testing.T) {
tempDir := setup(t, map[string]string{"en": "en", "fr": "fr", "zz": "en"})
translationFunc, err := GetTranslationFuncForDir(tempDir)
require.NoError(t, err)
require.NotNil(t, translationFunc)
require.Equal(t, "December", translationFunc("zz")("December"))
})
t.Run("malformed, unsupported locale ignored and falls back to english", func(t *testing.T) {
tempDir := setup(t, map[string]string{"en": "en", "fr": "fr", "zz": "en"})
err := os.WriteFile(filepath.Join(tempDir, "xx.json"), []byte{'{'}, os.ModePerm)
require.NoError(t, err)
translationFunc, err := GetTranslationFuncForDir(tempDir)
require.NoError(t, err)
require.NotNil(t, translationFunc)
require.Equal(t, "December", translationFunc("xx")("December"))
})
t.Run("malformed, supported locale causes error", func(t *testing.T) {
tempDir := setup(t, map[string]string{"fr": "fr", "zz": "en"})
err := os.WriteFile(filepath.Join(tempDir, "en.json"), []byte{'{'}, os.ModePerm)
require.NoError(t, err)
translationFunc, err := GetTranslationFuncForDir(tempDir)
require.Error(t, err)
require.Nil(t, translationFunc)
})
t.Run("known locale matches", func(t *testing.T) {
tempDir := setup(t, map[string]string{"en": "en", "fr": "fr"})
translationFunc, err := GetTranslationFuncForDir(tempDir)
require.NoError(t, err)
require.NotNil(t, translationFunc)
require.Equal(t, "Décembre", translationFunc("fr")("December"))
require.Equal(t, "December", translationFunc("en")("December"))
})
}
// localeFilesIn returns the locale codes of the *.json catalogs in dir.
func localeFilesIn(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
require.NoError(t, err)
var locales []string
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
locales = append(locales, strings.TrimSuffix(entry.Name(), ".json"))
}
return locales
}
// webappLanguageValues returns the locale codes in the webapp's languages map.
func webappLanguageValues(t *testing.T, path string) []string {
t.Helper()
contents, err := os.ReadFile(path)
require.NoError(t, err)
block := regexp.MustCompile(`(?s)export const languages = \{.*?\n\};`).Find(contents)
require.NotNil(t, block, "could not locate the languages map in %s", path)
var locales []string
for _, match := range regexp.MustCompile(`value: '([^']+)'`).FindAllSubmatch(block, -1) {
locales = append(locales, string(match[1]))
}
return locales
}
// supportedLocales is hand-maintained in three other places: the catalogs
// shipped by the server, the catalogs shipped by the webapp, and the webapp's
// own languages map. Nothing but this test keeps the four in step, and when
// they drift the symptom is a language that is offered but cannot load, or a
// catalog that is translated but never reachable.
func TestSupportedLocalesAreInSync(t *testing.T) {
t.Run("server translation files", func(t *testing.T) {
assert.ElementsMatch(t, supportedLocales, localeFilesIn(t, "../../../i18n"),
"supportedLocales and server/i18n/*.json disagree")
})
t.Run("webapp translation files", func(t *testing.T) {
assert.ElementsMatch(t, supportedLocales, localeFilesIn(t, "../../../../webapp/channels/src/i18n"),
"supportedLocales and webapp/channels/src/i18n/*.json disagree")
})
t.Run("webapp languages map", func(t *testing.T) {
assert.ElementsMatch(t, supportedLocales, webappLanguageValues(t, "../../../../webapp/channels/src/i18n/i18n.ts"),
"supportedLocales and the webapp languages map disagree")
})
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBlockQuoteStart(t *testing.T) {
t.Run("depth at the nesting limit is rejected even for a well-formed block quote", func(t *testing.T) {
markdown := "> foo"
blocks := blockQuoteStart(markdown, 0, Range{0, len(markdown)}, maxNestingDepth)
assert.Nil(t, blocks)
})
t.Run("nesting from a single line is capped at maxNestingDepth", func(t *testing.T) {
n := maxNestingDepth + 8
markdown := strings.Repeat("> ", n) + "x"
blocks := blockQuoteStart(markdown, 0, Range{0, len(markdown)}, 0)
require.NotEmpty(t, blocks)
counts := countBlocksByType(blocks)
assert.Equal(t, maxNestingDepth, counts["*markdown.BlockQuote"])
assert.Equal(t, 1, counts["*markdown.Paragraph"])
assert.IsType(t, &Paragraph{}, blocks[len(blocks)-1])
})
t.Run("nesting is capped relative to the depth already accrued by open ancestor blocks", func(t *testing.T) {
markdown := "> > > x"
blocks := blockQuoteStart(markdown, 0, Range{0, len(markdown)}, maxNestingDepth-2)
require.NotEmpty(t, blocks)
counts := countBlocksByType(blocks)
assert.Equal(t, 2, counts["*markdown.BlockQuote"])
assert.Equal(t, 1, counts["*markdown.Paragraph"])
})
}

View File

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

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// countBlocksByType tallies the concrete type of each block in a flat block chain, such as the one
// returned by blockQuoteStart/listStart/blockStart, which append each nesting level's block(s)
// directly onto the same slice as its descendants.
func countBlocksByType(blocks []Block) map[string]int {
counts := map[string]int{}
for _, block := range blocks {
counts[fmt.Sprintf("%T", block)]++
}
return counts
}
func TestBlockStart(t *testing.T) {
t.Run("block quote nesting is capped at maxNestingDepth", func(t *testing.T) {
markdown := "> foo"
blocks := blockStart(markdown, 0, Range{0, len(markdown)}, nil, nil, maxNestingDepth)
assert.Nil(t, blocks)
})
t.Run("list nesting is capped at maxNestingDepth", func(t *testing.T) {
markdown := "- foo"
blocks := blockStart(markdown, 0, Range{0, len(markdown)}, nil, nil, maxNestingDepth)
assert.Nil(t, blocks)
})
t.Run("a single line cannot recurse past maxNestingDepth nested block quotes", func(t *testing.T) {
n := maxNestingDepth + 8
markdown := strings.Repeat("> ", n) + "x"
blocks := blockStart(markdown, 0, Range{0, len(markdown)}, nil, nil, 0)
require.NotEmpty(t, blocks)
counts := countBlocksByType(blocks)
assert.Equal(t, maxNestingDepth, counts["*markdown.BlockQuote"])
assert.Equal(t, 1, counts["*markdown.Paragraph"])
})
t.Run("a single line cannot recurse past maxNestingDepth nested list items", func(t *testing.T) {
n := maxNestingDepth + 8
markdown := strings.Repeat("- ", n) + "x"
blocks := blockStart(markdown, 0, Range{0, len(markdown)}, nil, nil, 0)
require.NotEmpty(t, blocks)
counts := countBlocksByType(blocks)
assert.Equal(t, maxNestingDepth, counts["*markdown.List"])
assert.Equal(t, maxNestingDepth, counts["*markdown.ListItem"])
assert.Equal(t, 1, counts["*markdown.Paragraph"])
})
t.Run("nesting already accrued by open ancestor blocks counts toward the cap", func(t *testing.T) {
markdown := "> > > x"
blocks := blockStart(markdown, 0, Range{0, len(markdown)}, nil, nil, maxNestingDepth-2)
require.NotEmpty(t, blocks)
counts := countBlocksByType(blocks)
assert.Equal(t, 2, counts["*markdown.BlockQuote"])
assert.Equal(t, 1, counts["*markdown.Paragraph"])
})
}
func TestBlockStartOrParagraph(t *testing.T) {
t.Run("falls back to a paragraph once the nesting depth is exhausted", func(t *testing.T) {
markdown := "> foo"
blocks := blockStartOrParagraph(markdown, 0, Range{0, len(markdown)}, nil, nil, maxNestingDepth)
require.Len(t, blocks, 1)
assert.IsType(t, &Paragraph{}, blocks[0])
})
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"regexp"
)
// Based off the mobile app's emoji parsing from https://github.com/mattermost/commonmark.js
var (
emojiRegex = regexp.MustCompile(`^:([a-z0-9_\-+]+):\B`)
)
// parseEmoji attempts to parse a named emoji (eg. :taco:) starting at the current parser position. If an emoji is
// found, it adds that to p.inlines and returns true. Otherwise, it returns false.
func (p *inlineParser) parseEmoji() bool {
// Only allow emojis after non-word characters
if p.position > 1 {
prevChar := p.raw[p.position-1]
if isWordByte(prevChar) {
return false
}
}
remaining := p.raw[p.position:]
loc := emojiRegex.FindStringIndex(remaining)
if loc == nil {
return false
}
// Note that there may not be a system or custom emoji that exists with this name
p.inlines = append(p.inlines, &Emoji{
Name: remaining[loc[0]+1 : loc[1]-1],
})
p.position += loc[1] - loc[0]
return true
}

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseEmoji(t *testing.T) {
for name, tc := range map[string]struct {
Input string
Position int
ExpectedOk bool
ExpectedPosition int
ExpectedEmojiName string
}{
"just a colon": {
Input: ":",
Position: 0,
ExpectedOk: false,
ExpectedPosition: 0,
},
"no closing colon": {
Input: ":emoji",
Position: 0,
ExpectedOk: false,
ExpectedPosition: 0,
},
"no closing colon before whitespace": {
Input: ":emoji example",
Position: 0,
ExpectedOk: false,
ExpectedPosition: 0,
},
"valid emoji": {
Input: ":emoji:",
Position: 0,
ExpectedOk: true,
ExpectedPosition: 7,
ExpectedEmojiName: "emoji",
},
"valid emoji with punctuation": {
Input: ":valid-emoji:",
Position: 0,
ExpectedOk: true,
ExpectedPosition: 13,
ExpectedEmojiName: "valid-emoji",
},
"valid emoji with text before": {
Input: "this is an :emoji:",
Position: 11,
ExpectedOk: true,
ExpectedPosition: 18,
ExpectedEmojiName: "emoji",
},
"invalid emoji with text before": {
Input: "this is not an :emoji",
Position: 15,
ExpectedOk: false,
ExpectedPosition: 15,
},
"valid emoji with text after": {
Input: ":emoji: before some text",
Position: 0,
ExpectedOk: true,
ExpectedPosition: 7,
ExpectedEmojiName: "emoji",
},
"valid emoji with text before and after": {
Input: "this is an :emoji: in a sentence",
Position: 11,
ExpectedOk: true,
ExpectedPosition: 18,
ExpectedEmojiName: "emoji",
},
"multiple emojis 1": {
Input: ":multiple: :emojis:",
Position: 0,
ExpectedOk: true,
ExpectedPosition: 10,
ExpectedEmojiName: "multiple",
},
"multiple emojis 2": {
Input: ":multiple: :emojis:",
Position: 11,
ExpectedOk: true,
ExpectedPosition: 19,
ExpectedEmojiName: "emojis",
},
} {
t.Run(name, func(t *testing.T) {
p := newInlineParser(tc.Input, []Range{}, []*ReferenceDefinition{})
p.raw = tc.Input
p.position = tc.Position
ok := p.parseEmoji()
assert.Equal(t, tc.ExpectedOk, ok)
assert.Equal(t, tc.ExpectedPosition, p.position)
if tc.ExpectedOk {
require.True(t, len(p.inlines) > 0)
require.IsType(t, &Emoji{}, p.inlines[len(p.inlines)-1])
assert.Equal(t, tc.ExpectedEmojiName, p.inlines[len(p.inlines)-1].(*Emoji).Name)
}
})
}
}
func TestParseEmojiFull(t *testing.T) {
// These tests are based on https://github.com/mattermost/commonmark.js/blob/master/test/mattermost.txt
for name, tc := range map[string]struct {
Markdown string
ExpectedHTML string
}{
// Valid emojis
"emoji": {
Markdown: "This is an :emoji:",
ExpectedHTML: `<p>This is an <span data-emoji-name="emoji" data-literal=":emoji:" /></p>`,
},
"emoji with underscore": {
Markdown: "This is an :emo_ji:",
ExpectedHTML: `<p>This is an <span data-emoji-name="emo_ji" data-literal=":emo_ji:" /></p>`,
},
"emoji with hyphen": {
Markdown: "This is an :emo-ji:",
ExpectedHTML: `<p>This is an <span data-emoji-name="emo-ji" data-literal=":emo-ji:" /></p>`,
},
"emoji with numbers": {
Markdown: "This is an :emoji123:",
ExpectedHTML: `<p>This is an <span data-emoji-name="emoji123" data-literal=":emoji123:" /></p>`,
},
"emoji in brackets": {
Markdown: "This is an (:emoji:)",
ExpectedHTML: `<p>This is an (<span data-emoji-name="emoji" data-literal=":emoji:" />)</p>`,
},
"two emojis without space between": {
Markdown: "These are some :emoji1::emoji2:",
ExpectedHTML: `<p>These are some <span data-emoji-name="emoji1" data-literal=":emoji1:" /><span data-emoji-name="emoji2" data-literal=":emoji2:" /></p>`,
},
"two emojis separated by a slash": {
Markdown: "These are some :emoji1:/:emoji2:",
ExpectedHTML: `<p>These are some <span data-emoji-name="emoji1" data-literal=":emoji1:" />/<span data-emoji-name="emoji2" data-literal=":emoji2:" /></p>`,
},
"+1 emoji": {
Markdown: "This is an :+1:",
ExpectedHTML: `<p>This is an <span data-emoji-name="+1" data-literal=":+1:" /></p>`,
},
"-1 emoji": {
Markdown: "This is an :-1:",
ExpectedHTML: `<p>This is an <span data-emoji-name="-1" data-literal=":-1:" /></p>`,
},
"emoji with surrounding words": {
Markdown: "This is an :emoji: in a sentence.",
ExpectedHTML: `<p>This is an <span data-emoji-name="emoji" data-literal=":emoji:" /> in a sentence.</p>`,
},
// Invalid emojis
"incomplete emoji 1": {
Markdown: "This is not an :emoji",
ExpectedHTML: `<p>This is not an :emoji</p>`,
},
"incomplete emoji 2": {
Markdown: "This is not an emoji:",
ExpectedHTML: `<p>This is not an emoji:</p>`,
},
"invalid emoji with whitespace": {
Markdown: "This is not an :emo ji:",
ExpectedHTML: `<p>This is not an :emo ji:</p>`,
},
"invalid emoji with other punctuation": {
Markdown: "This is not an :emo'ji:",
ExpectedHTML: `<p>This is not an :emo'ji:</p>`,
},
"invalid emoji due to adjacent text 1": {
Markdown: "Thisisnotan:emoji:",
// This differs slightly from our commonmark.js implementation because it doesn't require :// when autolinking
ExpectedHTML: `<p>Thisisnotan:emoji:</p>`,
},
"invalid emoji due to adjacent text 2": {
Markdown: "This is not an :emoji:isit",
// This differs slightly from our commonmark.js implementation because it doesn't require :// when autolinking
ExpectedHTML: `<p>This is not an :emoji:isit</p>`,
},
"invalid emoji due to adjacent text 3": {
Markdown: "This is not an:emoji:isit",
// This differs slightly from our commonmark.js implementation because it doesn't require :// when autolinking
ExpectedHTML: `<p>This is not an:emoji:isit</p>`,
},
} {
t.Run(name, func(t *testing.T) {
actual := RenderHTML(tc.Markdown)
assert.Equal(t, tc.ExpectedHTML, actual)
})
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"slices"
"sync/atomic"
)
// defaultMaxPostRunes is used only if no value has been registered via
// SetMaxPostRunes, which should not happen in practice. It assumes a post is at
// most 64KiB in DB. Assuming four bytes per rune, it gives us 16*1024 runes.
// If we allow scanning up to twice (arbitrary value) that value, we get:
const defaultMaxPostRunes = 2 * 16 * 1024
var maxPostRunes atomic.Int64
func init() {
maxPostRunes.Store(defaultMaxPostRunes)
}
// SetMaxPostRunes registers the real configured maximum post size, in runes, that MaxLen uses to
// compute the maximum markdown input length. This lets MaxLen reflect the real limit regardless
// of which code path first parses markdown, rather than depending on that path having already
// pushed the value in. Safe to call concurrently with MaxLen.
func SetMaxPostRunes(size int) {
maxPostRunes.Store(int64(size))
}
// MaxLen returns the current maximum markdown input length, in bytes, enforced by Parse and
// Inspect: four times the maximum post size, in runes, registered via SetMaxPostRunes (or
// defaultMaxPostRunes if it hasn't been called yet), assuming a worst case of four bytes per
// rune.
func MaxLen() int {
return 4 * int(maxPostRunes.Load())
}
// Inspect traverses the markdown tree in depth-first order. If f returns true, Inspect invokes f
// recursively for each child of the block or inline, followed by a call of f(nil).
func Inspect(markdown string, f func(any) bool) {
if len(markdown) > MaxLen() {
return
}
document, referenceDefinitions := Parse(markdown)
InspectBlock(document, func(block Block) bool {
if !f(block) {
return false
}
switch v := block.(type) {
case *Paragraph:
for _, inline := range MergeInlineText(v.ParseInlines(referenceDefinitions)) {
InspectInline(inline, func(inline Inline) bool {
return f(inline)
})
}
}
return true
})
}
// InspectBlock traverses the blocks in depth-first order, starting with block. If f returns true,
// InspectBlock invokes f recursively for each child of the block, followed by a call of f(nil).
func InspectBlock(block Block, f func(Block) bool) {
stack := []Block{block}
// Using seen for backtracking
seen := map[Block]bool{}
for len(stack) > 0 {
// "peek" the node from the stack
block := stack[len(stack)-1]
if seen[block] {
// "pop" the node only when backtracking(seen)
stack = stack[:len(stack)-1]
f(nil)
continue
}
seen[block] = true
// Process the node
if !f(block) {
continue
}
switch v := block.(type) {
case *Document:
for _, v0 := range slices.Backward(v.Children) {
stack = append(stack, v0)
}
case *List:
for _, v0 := range slices.Backward(v.Children) {
stack = append(stack, v0)
}
case *ListItem:
for _, v0 := range slices.Backward(v.Children) {
stack = append(stack, v0)
}
case *BlockQuote:
for _, v0 := range slices.Backward(v.Children) {
stack = append(stack, v0)
}
}
}
}
// InspectInline traverses the blocks in depth-first order, starting with block. If f returns true,
// InspectInline invokes f recursively for each child of the block, followed by a call of f(nil).
func InspectInline(inline Inline, f func(Inline) bool) {
stack := []Inline{inline}
// Using seen for backtracking
seen := map[Inline]bool{}
for len(stack) > 0 {
// "peek" the node from the stack
inline := stack[len(stack)-1]
if seen[inline] {
// "pop" the node only when backtracking(seen)
stack = stack[:len(stack)-1]
f(nil)
continue
}
seen[inline] = true
// Process the node
if !f(inline) {
continue
}
switch v := inline.(type) {
case *InlineImage:
for _, v0 := range slices.Backward(v.Children) {
stack = append(stack, v0)
}
case *InlineLink:
for _, v0 := range slices.Backward(v.Children) {
stack = append(stack, v0)
}
case *ReferenceImage:
for _, v0 := range slices.Backward(v.Children) {
stack = append(stack, v0)
}
case *ReferenceLink:
for _, v0 := range slices.Backward(v.Children) {
stack = append(stack, v0)
}
}
}
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestInspect(t *testing.T) {
t.Run("base", func(t *testing.T) {
markdown := `
[foo]: bar
- a
> [![]()]()
> [![foo]][foo]
- d
`
visited := []string{}
level := 0
Inspect(markdown, func(blockOrInline any) bool {
if blockOrInline == nil {
level--
} else {
visited = append(visited, strings.Repeat(" ", level*4)+strings.TrimPrefix(fmt.Sprintf("%T", blockOrInline), "*markdown."))
level++
}
return true
})
assert.Equal(t, []string{
"Document",
" Paragraph",
" List",
" ListItem",
" Paragraph",
" Text",
" BlockQuote",
" Paragraph",
" InlineLink",
" InlineImage",
" SoftLineBreak",
" ReferenceLink",
" ReferenceImage",
" Text",
" ListItem",
" Paragraph",
" Text",
}, visited)
})
t.Run("visit nodes when len is smaller than maxLen", func(t *testing.T) {
n := MaxLen() / 5
markdown := strings.Repeat(`![`, n) + strings.Repeat(`]()`, n)
visited := []string{}
level := 0
Inspect(markdown, func(blockOrInline any) bool {
if blockOrInline == nil {
level--
} else {
visited = append(visited, strings.Repeat(" ", level*4)+strings.TrimPrefix(fmt.Sprintf("%T", blockOrInline), "*markdown."))
level++
}
return true
})
assert.NotEmpty(t, visited)
})
t.Run("do not visit any nodes when len is greater than maxLen", func(t *testing.T) {
n := (MaxLen() / 5) + 1
markdown := strings.Repeat(`![`, n) + strings.Repeat(`]()`, n)
visited := []string{}
level := 0
Inspect(markdown, func(blockOrInline any) bool {
if blockOrInline == nil {
level--
} else {
visited = append(visited, strings.Repeat(" ", level*4)+strings.TrimPrefix(fmt.Sprintf("%T", blockOrInline), "*markdown."))
level++
}
return true
})
assert.Empty(t, visited)
})
t.Run("a deeply nested single-line block quote is bounded rather than fully descended", func(t *testing.T) {
n := 20000
markdown := strings.Repeat("> ", n) + "x"
blockQuoteCount := 0
Inspect(markdown, func(blockOrInline any) bool {
if _, ok := blockOrInline.(*BlockQuote); ok {
blockQuoteCount++
}
return true
})
assert.Equal(t, maxNestingDepth-1, blockQuoteCount)
})
t.Run("a deeply nested single-line list is bounded rather than fully descended", func(t *testing.T) {
n := 20000
markdown := strings.Repeat("- ", n) + "x"
listCount := 0
Inspect(markdown, func(blockOrInline any) bool {
if _, ok := blockOrInline.(*List); ok {
listCount++
}
return true
})
assert.Equal(t, maxNestingDepth-1, listCount)
})
}
var counterSink int
func BenchmarkInspect(b *testing.B) {
text := `Some standard piece of text.
Has a link [post](https://github.com) and also has a blockquote.
> This is a famous quote.
Some bold text **Text for markdown?** to go with it.
At the end, some more lines`
for b.Loop() {
Inspect(text, func(_ any) bool {
counterSink++
return true
})
}
}
// BenchmarkInspectNestedBlockQuote and BenchmarkInspectNestedList measure the cost of parsing a
// single line made up of repeated block-quote/list markers. These benchmarks should scale roughly
// linearly with input size (i.e. per-op time should stay flat as the marker count grows), since the
// number of nested blocks actually created is capped at maxNestingDepth regardless of input size.
func BenchmarkInspectNestedBlockQuote(b *testing.B) {
for _, n := range []int{1_000, 10_000, 60_000} {
markdown := strings.Repeat("> ", n) + "x"
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
for b.Loop() {
Inspect(markdown, func(_ any) bool {
counterSink++
return true
})
}
})
}
}
func BenchmarkInspectNestedList(b *testing.B) {
for _, n := range []int{1_000, 10_000, 60_000} {
markdown := strings.Repeat("- ", n) + "x"
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
for b.Loop() {
Inspect(markdown, func(_ any) bool {
counterSink++
return true
})
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListStart(t *testing.T) {
t.Run("depth at the nesting limit is rejected even for a well-formed list item", func(t *testing.T) {
markdown := "- foo"
blocks := listStart(markdown, 0, Range{0, len(markdown)}, nil, nil, maxNestingDepth)
assert.Nil(t, blocks)
})
t.Run("nesting from a single line is capped at maxNestingDepth", func(t *testing.T) {
n := maxNestingDepth + 8
markdown := strings.Repeat("- ", n) + "x"
blocks := listStart(markdown, 0, Range{0, len(markdown)}, nil, nil, 0)
require.NotEmpty(t, blocks)
counts := countBlocksByType(blocks)
assert.Equal(t, maxNestingDepth, counts["*markdown.List"])
assert.Equal(t, maxNestingDepth, counts["*markdown.ListItem"])
assert.Equal(t, 1, counts["*markdown.Paragraph"])
assert.IsType(t, &Paragraph{}, blocks[len(blocks)-1])
})
t.Run("nesting is capped relative to the depth already accrued by open ancestor blocks", func(t *testing.T) {
markdown := "- - - x"
blocks := listStart(markdown, 0, Range{0, len(markdown)}, nil, nil, maxNestingDepth-2)
require.NotEmpty(t, blocks)
counts := countBlocksByType(blocks)
assert.Equal(t, 2, counts["*markdown.List"])
assert.Equal(t, 2, counts["*markdown.ListItem"])
assert.Equal(t, 1, counts["*markdown.Paragraph"])
})
}

View File

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

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package markdown
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParse(t *testing.T) {
t.Run("rejects input longer than maxLen bytes without parsing it", func(t *testing.T) {
markdown := strings.Repeat("a", MaxLen()+1)
document, referenceDefinitions := Parse(markdown)
assert.Empty(t, document.Children)
assert.Empty(t, referenceDefinitions)
})
t.Run("SetMaxPostSize raises the cap so a previously rejected input is parsed", func(t *testing.T) {
SetMaxPostRunes(defaultMaxPostRunes)
defer SetMaxPostRunes(defaultMaxPostRunes)
markdown := strings.Repeat("a", MaxLen()+1)
document, _ := Parse(markdown)
assert.Empty(t, document.Children)
SetMaxPostRunes(defaultMaxPostRunes + 1)
document, _ = Parse(markdown)
assert.NotEmpty(t, document.Children)
SetMaxPostRunes(defaultMaxPostRunes)
})
t.Run("nesting depth is bounded regardless of how deeply a single line nests", func(t *testing.T) {
// Without the depth cap in blockStart/blockQuoteStart/listStart, this would force parse
// work that grows with the nesting depth rather than staying bounded.
n := 20000
markdown := strings.Repeat("> ", n) + "x"
document, _ := Parse(markdown)
require.Len(t, document.Children, 1)
depth := 0
block := document.Children[0]
for {
blockQuote, ok := block.(*BlockQuote)
if !ok {
break
}
depth++
require.NotEmpty(t, blockQuote.Children)
block = blockQuote.Children[0]
}
// ParseBlocks counts the Document itself as the first ancestor, so a full parse tops out
// one level below the maxNestingDepth threshold used by the block-start functions directly.
assert.Equal(t, maxNestingDepth-1, depth)
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import (
"slices"
"github.com/mattermost/logr/v2"
)
// Standard levels.
var (
LvlPanic = logr.Panic // ID = 0
LvlFatal = logr.Fatal // ID = 1
LvlError = logr.Error // ID = 2
LvlWarn = logr.Warn // ID = 3
LvlInfo = logr.Info // ID = 4
LvlDebug = logr.Debug // ID = 5
LvlTrace = logr.Trace // ID = 6
// non-standard "critical" level
LvlCritical = Level{ID: 7, Name: "critical"}
// used by redirected standard logger
LvlStdLog = Level{ID: 10, Name: "stdlog"}
// used only by the logger
LvlLogError = Level{ID: 11, Name: "logerror", Stacktrace: true}
StdAll = []Level{LvlPanic, LvlFatal, LvlError, LvlWarn, LvlInfo, LvlDebug, LvlTrace, LvlStdLog}
)
// Register custom (discrete) levels here.
// !!!!! Custom ID's must be between 20 and 32,768 !!!!!!
var (
/* Audit system */
// LvlAuditAPI is used for auditing REST API endpoint access. This is the most commonly used
// audit level and should be applied whenever a REST API endpoint is accessed. It provides
// a record of API usage patterns and access.
//
// Example usage: Logging when a user accesses the /api/v4/posts endpoint.
LvlAuditAPI = Level{ID: 100, Name: "audit-api"}
// LvlAuditContent is used for auditing content-generating operations such as creating posts,
// adding reactions, or other actions that create new content in the system. Note that this
// level can generate a large volume of logs and is typically disabled by default in production
// environments.
//
// Example usage: Logging when a user creates a new post or adds a reaction.
LvlAuditContent = Level{ID: 101, Name: "audit-content"}
// LvlAuditPerms is used for auditing permissions checks. This level is automatically applied
// when permission-related errors occur, helping to track authorization failures and access
// control issues.
//
// Example usage: Logging when a user attempts to access a resource they don't have permission for.
LvlAuditPerms = Level{ID: 102, Name: "audit-permissions"}
// LvlAuditCLI is intended for auditing command-line interface operations. This level was
// originally designed for the legacy CLI. It's mostly unused now.
LvlAuditCLI = Level{ID: 103, Name: "audit-cli"}
// LvlAuditDelivery is used for auditing the delivery of post content to users and
// integrations. This level can generate an extremely large volume of records — one per
// post per recipient — so it is deliberately not bound to the basic
// ExperimentalAuditSettings file target. An admin opts in by configuring an advanced
// audit logging target for it.
//
// Example usage: Logging that a post was included in a page of posts returned to a user.
LvlAuditDelivery = Level{ID: 104, Name: "audit-delivery"}
MLvlAuditAll = []Level{
LvlAuditAPI,
LvlAuditContent,
LvlAuditPerms,
LvlAuditCLI,
LvlAuditDelivery,
}
LvlRemoteClusterServiceDebug = Level{ID: 130, Name: "RemoteClusterServiceDebug"}
LvlRemoteClusterServiceError = Level{ID: 131, Name: "RemoteClusterServiceError"}
LvlRemoteClusterServiceWarn = Level{ID: 132, Name: "RemoteClusterServiceWarn"}
MLvlRemoteClusterServiceAll = []Level{
LvlRemoteClusterServiceDebug,
LvlRemoteClusterServiceError,
LvlRemoteClusterServiceWarn,
}
/* LDAP sync job */
LvlLDAPError = Level{ID: 140, Name: "LDAPError"}
LvlLDAPWarn = Level{ID: 141, Name: "LDAPWarn"}
LvlLDAPInfo = Level{ID: 142, Name: "LDAPInfo"}
LvlLDAPDebug = Level{ID: 143, Name: "LDAPDebug"}
LvlLDAPTrace = Level{ID: 144, Name: "LDAPTrace"}
MLvlLDAPAll = []Level{LvlLDAPError, LvlLDAPWarn, LvlLDAPInfo, LvlLDAPDebug, LvlLDAPTrace}
/* Shared Channel Sync Service */
LvlSharedChannelServiceDebug = Level{ID: 200, Name: "SharedChannelServiceDebug"}
LvlSharedChannelServiceError = Level{ID: 201, Name: "SharedChannelServiceError"}
LvlSharedChannelServiceWarn = Level{ID: 202, Name: "SharedChannelServiceWarn"}
LvlSharedChannelServiceMessagesInbound = Level{ID: 203, Name: "SharedChannelServiceMsgInbound"}
LvlSharedChannelServiceMessagesOutbound = Level{ID: 204, Name: "SharedChannelServiceMsgOutbound"}
MlvlSharedChannelServiceAll = []Level{
LvlSharedChannelServiceDebug,
LvlSharedChannelServiceError,
LvlSharedChannelServiceWarn,
LvlSharedChannelServiceMessagesInbound,
LvlSharedChannelServiceMessagesOutbound,
}
/* Notification Service */
LvlNotificationError = Level{ID: 300, Name: "NotificationError"}
LvlNotificationWarn = Level{ID: 301, Name: "NotificationWarn"}
LvlNotificationInfo = Level{ID: 302, Name: "NotificationInfo"}
LvlNotificationDebug = Level{ID: 303, Name: "NotificationDebug"}
LvlNotificationTrace = Level{ID: 304, Name: "NotificationTrace"}
MlvlNotificationAll = []Level{
LvlNotificationError,
LvlNotificationWarn,
LvlNotificationInfo,
LvlNotificationDebug,
LvlNotificationTrace,
}
)
// Combinations for LogM (log multi).
var (
MlvlRemoteClusterServiceError = []Level{LvlError, LvlRemoteClusterServiceError}
MlvlRemoteClusterServiceWarn = []Level{LvlWarn, LvlRemoteClusterServiceWarn}
MlvlSharedChannelServiceError = []Level{LvlError, LvlSharedChannelServiceError}
MlvlSharedChannelServiceWarn = []Level{LvlWarn, LvlSharedChannelServiceWarn}
MlvlLDAPError = []Level{LvlError, LvlLDAPError}
MlvlLDAPWarn = []Level{LvlWarn, LvlLDAPWarn}
MlvlLDAPInfo = []Level{LvlInfo, LvlLDAPInfo}
MlvlLDAPDebug = []Level{LvlDebug, LvlLDAPDebug}
MlvlNotificationError = []Level{LvlError, LvlNotificationError}
MlvlNotificationWarn = []Level{LvlWarn, LvlNotificationWarn}
MlvlNotificationInfo = []Level{LvlInfo, LvlNotificationInfo}
MlvlNotificationDebug = []Level{LvlDebug, LvlNotificationDebug}
MlvlNotificationTrace = []Level{LvlTrace, LvlNotificationTrace}
)
var (
// MlvlAll contains all log levels except the audit ones
MlvlAll = slices.Concat(
StdAll,
MLvlRemoteClusterServiceAll,
MLvlLDAPAll,
MlvlSharedChannelServiceAll,
MlvlNotificationAll,
)
)

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import (
"github.com/mattermost/logr/v2"
)
type Gauge = logr.Gauge
type Counter = logr.Counter

View File

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

View File

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

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package mlog
import (
"bytes"
"io"
"os"
"sync"
"testing"
"github.com/mattermost/logr/v2"
"github.com/mattermost/logr/v2/formatters"
"github.com/mattermost/logr/v2/targets"
)
// AddWriterTarget adds a simple io.Writer target to an existing Logger.
// The `io.Writer` can be a buffer which is useful for testing.
// When adding a buffer to collect logs make sure to use `mlog.Buffer` which is
// a thread safe version of `bytes.Buffer`.
func AddWriterTarget(logger *Logger, w io.Writer, useJSON bool, levels ...Level) error {
filter := logr.NewCustomFilter(levels...)
var formatter logr.Formatter
if useJSON {
formatter = &formatters.JSON{EnableCaller: true}
} else {
formatter = &formatters.Plain{EnableCaller: true}
}
target := targets.NewWriterTarget(w)
return logger.log.Logr().AddTarget(target, "_testWriter", filter, formatter, 1000)
}
// CreateConsole createa a logger that outputs to [os.Stdout].
// It's useful in places where no log configuration is accessible.
func CreateConsoleLogger() *Logger {
logger, err := NewLogger()
if err != nil {
panic("failed create logger " + err.Error())
}
filter := logr.StdFilter{
Lvl: LvlTrace,
Stacktrace: LvlPanic,
}
formatter := &formatters.Plain{
EnableCaller: true,
EnableColor: true,
}
target := targets.NewWriterTarget(os.Stdout)
if err := logger.log.Logr().AddTarget(target, "_testcon", filter, formatter, 1000); err != nil {
panic("failed to add target " + err.Error())
}
return logger
}
// CreateConsoleTestLogger creates a logger for unit tests. Log records are output to `os.Stdout`.
// All log messages with level trace or lower are logged.
// The returned logger get Shutdown() when the tests completes. The caller should not shut it down.
func CreateConsoleTestLogger(tb testing.TB) *Logger {
tb.Helper()
logger, err := NewLogger()
if err != nil {
tb.Fatalf("failed create logger %v", err)
}
filter := logr.StdFilter{
Lvl: LvlTrace,
Stacktrace: LvlPanic,
}
formatter := &formatters.Plain{EnableCaller: true}
target := targets.NewWriterTarget(os.Stdout)
if err := logger.log.Logr().AddTarget(target, "_testcon", filter, formatter, 1000); err != nil {
tb.Fatalf("failed to add target %v", err)
}
tb.Cleanup(func() {
err := logger.Shutdown()
if err != nil {
tb.Fatalf("failed to shut down test logger %v", err)
}
})
return logger
}
// CreateTestLogger creates a logger for unit tests. Log records are output via `t.Log`.
// All log messages with level trace or lower are logged.
// The returned logger get Shutdown() when the tests completes. The caller should not shut it down.
func CreateTestLogger(t *testing.T) *Logger {
t.Helper()
logger, err := NewLogger()
if err != nil {
t.Fatalf("failed create logger %v", err)
}
filter := logr.StdFilter{
Lvl: LvlTrace,
Stacktrace: LvlPanic,
}
formatter := &formatters.Plain{EnableCaller: true}
target := targets.NewTestingTarget(t)
if err := logger.log.Logr().AddTarget(target, "test", filter, formatter, 1000); err != nil {
t.Fatalf("failed to add target %v", err)
}
t.Cleanup(func() {
err := logger.Shutdown()
if err != nil {
t.Errorf("failed to shut down test logger %v", err)
}
})
return logger
}
// Buffer provides a thread-safe buffer useful for logging to memory in unit tests.
type Buffer struct {
buf bytes.Buffer
mux sync.Mutex
}
func (b *Buffer) Read(p []byte) (n int, err error) {
b.mux.Lock()
defer b.mux.Unlock()
return b.buf.Read(p)
}
func (b *Buffer) Write(p []byte) (n int, err error) {
b.mux.Lock()
defer b.mux.Unlock()
return b.buf.Write(p)
}
func (b *Buffer) String() string {
b.mux.Lock()
defer b.mux.Unlock()
return b.buf.String()
}

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package request
import (
"context"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// Context should be abbreviated as `rctx`.
type Context struct {
t i18n.TranslateFunc
session model.Session
requestId string
ipAddress string
xForwardedFor string
path string
userAgent string
acceptLanguage string
connectionId string
logger mlog.LoggerIFace
context context.Context
}
func NewContext(ctx context.Context, requestId, ipAddress, xForwardedFor, path, userAgent, acceptLanguage string, t i18n.TranslateFunc) *Context {
return &Context{
t: t,
requestId: requestId,
ipAddress: ipAddress,
xForwardedFor: xForwardedFor,
path: path,
userAgent: userAgent,
acceptLanguage: acceptLanguage,
context: ctx,
}
}
func EmptyContext(logger mlog.LoggerIFace) *Context {
return &Context{
t: i18n.T,
logger: logger,
context: context.Background(),
}
}
// TestContext creates an empty context with a new logger to use in testing where a test helper is
// not required.
func TestContext(tb testing.TB) *Context {
logger := mlog.CreateConsoleTestLogger(tb)
return EmptyContext(logger)
}
// clone creates a shallow copy of Context, allowing clones to apply per-request changes.
func (c *Context) clone() *Context {
cCopy := *c
return &cCopy
}
func (c *Context) T(translationID string, args ...any) string {
return c.t(translationID, args...)
}
func (c *Context) GetT() i18n.TranslateFunc {
return c.t
}
func (c *Context) Session() *model.Session {
return &c.session
}
func (c *Context) RequestId() string {
return c.requestId
}
func (c *Context) IPAddress() string {
return c.ipAddress
}
func (c *Context) XForwardedFor() string {
return c.xForwardedFor
}
func (c *Context) Path() string {
return c.path
}
func (c *Context) UserAgent() string {
return c.userAgent
}
func (c *Context) AcceptLanguage() string {
return c.acceptLanguage
}
// ConnectionId returns the identifier of the WebSocket connection associated
// with the request, when present. It is populated from the "Connection-Id"
// HTTP header that authenticated clients set when they have an active
// WebSocket connection, allowing handlers and plugins to correlate an HTTP
// request with its originating WebSocket connection. Returns an empty string
// when the header is absent (e.g., requests from clients without an active
// WebSocket connection or from non-WebSocket integrations).
func (c *Context) ConnectionId() string {
return c.connectionId
}
func (c *Context) Logger() mlog.LoggerIFace {
return c.logger
}
func (c *Context) Context() context.Context {
return c.context
}
func (c *Context) WithT(t i18n.TranslateFunc) CTX {
rctx := c.clone()
rctx.t = t
return rctx
}
func (c *Context) WithSession(s *model.Session) CTX {
rctx := c.clone()
if s == nil {
rctx.session = model.Session{}
} else {
rctx.session = *s
}
return rctx
}
func (c *Context) WithRequestId(s string) CTX {
rctx := c.clone()
rctx.requestId = s
return rctx
}
func (c *Context) WithIPAddress(s string) CTX {
rctx := c.clone()
rctx.ipAddress = s
return rctx
}
func (c *Context) WithXForwardedFor(s string) CTX {
rctx := c.clone()
rctx.xForwardedFor = s
return rctx
}
func (c *Context) WithPath(s string) CTX {
rctx := c.clone()
rctx.path = s
return rctx
}
func (c *Context) WithUserAgent(s string) CTX {
rctx := c.clone()
rctx.userAgent = s
return rctx
}
func (c *Context) WithAcceptLanguage(s string) CTX {
rctx := c.clone()
rctx.acceptLanguage = s
return rctx
}
func (c *Context) WithConnectionId(s string) CTX {
rctx := c.clone()
rctx.connectionId = s
return rctx
}
func (c *Context) WithContext(ctx context.Context) CTX {
rctx := c.clone()
rctx.context = ctx
return rctx
}
func (c *Context) WithLogger(logger mlog.LoggerIFace) CTX {
rctx := c.clone()
rctx.logger = logger
return rctx
}
// WithLogFields returns a new context with the given fields added to the logger.
func (c *Context) WithLogFields(fields ...mlog.Field) CTX {
return c.WithLogger(c.logger.With(fields...))
}
func (c *Context) With(f func(ctx CTX) CTX) CTX {
return f(c)
}
// CTX should be abbreviated as `rctx`.
type CTX interface {
T(string, ...any) string
GetT() i18n.TranslateFunc
Session() *model.Session
RequestId() string
IPAddress() string
XForwardedFor() string
Path() string
UserAgent() string
AcceptLanguage() string
ConnectionId() string
Logger() mlog.LoggerIFace
Context() context.Context
WithT(i18n.TranslateFunc) CTX
WithSession(s *model.Session) CTX
WithRequestId(string) CTX
WithIPAddress(string) CTX
WithXForwardedFor(string) CTX
WithPath(string) CTX
WithUserAgent(string) CTX
WithAcceptLanguage(string) CTX
WithConnectionId(string) CTX
WithLogger(mlog.LoggerIFace) CTX
WithLogFields(fields ...mlog.Field) CTX
WithContext(ctx context.Context) CTX
With(func(ctx CTX) CTX) CTX
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package request
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestContext_WithConnectionId(t *testing.T) {
t.Run("returns new context with connection id", func(t *testing.T) {
originalCtx := TestContext(t)
connectionId := "test-connection-id-123"
newCtx := originalCtx.WithConnectionId(connectionId)
require.NotNil(t, newCtx)
assert.NotSame(t, originalCtx, newCtx, "should return a new context instance")
assert.Equal(t, connectionId, newCtx.ConnectionId())
assert.Empty(t, originalCtx.ConnectionId(), "original context should remain unchanged")
})
t.Run("returns new context with empty connection id", func(t *testing.T) {
originalCtx := TestContext(t)
originalCtx = originalCtx.WithConnectionId("existing-id").(*Context)
newCtx := originalCtx.WithConnectionId("")
require.NotNil(t, newCtx)
assert.NotSame(t, originalCtx, newCtx, "should return a new context instance")
assert.Empty(t, newCtx.ConnectionId())
assert.Equal(t, "existing-id", originalCtx.ConnectionId(), "original context should remain unchanged")
})
}
func TestContext_WithSession(t *testing.T) {
t.Run("returns new context with empty session when session is nil", func(t *testing.T) {
originalCtx := TestContext(t)
newCtx := originalCtx.WithSession(nil)
require.NotNil(t, newCtx)
assert.NotSame(t, originalCtx, newCtx, "should return a new context instance")
assert.Empty(t, newCtx.Session().Id)
assert.Empty(t, newCtx.Session().UserId)
assert.Empty(t, newCtx.Session().Token)
})
t.Run("returns new context when session is provided", func(t *testing.T) {
originalCtx := TestContext(t)
session := &model.Session{
Id: "session-id",
UserId: "user-id",
Token: "token",
}
newCtx := originalCtx.WithSession(session)
require.NotNil(t, newCtx)
assert.NotSame(t, originalCtx, newCtx, "should return a new context instance")
assert.Equal(t, "session-id", newCtx.Session().Id)
assert.Equal(t, "user-id", newCtx.Session().UserId)
assert.Equal(t, "token", newCtx.Session().Token)
assert.Empty(t, originalCtx.Session().Id)
})
}

View File

@@ -0,0 +1,599 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package timezones
var DefaultSupportedTimezones = []string{
"Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmara",
"Africa/Asmera",
"Africa/Bamako",
"Africa/Bangui",
"Africa/Banjul",
"Africa/Bissau",
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/Conakry",
"Africa/Dakar",
"Africa/Dar_es_Salaam",
"Africa/Djibouti",
"Africa/Douala",
"Africa/El_Aaiun",
"Africa/Freetown",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Kampala",
"Africa/Khartoum",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Lagos",
"Africa/Libreville",
"Africa/Lome",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Malabo",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Mogadishu",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Niamey",
"Africa/Nouakchott",
"Africa/Ouagadougou",
"Africa/Porto-Novo",
"Africa/Sao_Tome",
"Africa/Timbuktu",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/ComodRivadavia",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Atikokan",
"America/Atka",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Buenos_Aires",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Catamarca",
"America/Cayenne",
"America/Cayman",
"America/Chicago",
"America/Chihuahua",
"America/Coral_Harbour",
"America/Cordoba",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Dominica",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Ensenada",
"America/Fort_Nelson",
"America/Fort_Wayne",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Grenada",
"America/Guadeloupe",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Indianapolis",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Jujuy",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/Knox_IN",
"America/Kralendijk",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Louisville",
"America/Lower_Princes",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Marigot",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Mendoza",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Montreal",
"America/Montserrat",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Acre",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Rosario",
"America/Santa_Isabel",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Shiprock",
"America/Sitka",
"America/St_Barthelemy",
"America/St_Johns",
"America/St_Kitts",
"America/St_Lucia",
"America/St_Thomas",
"America/St_Vincent",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Tortola",
"America/Vancouver",
"America/Virgin",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/South_Pole",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Ashkhabad",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Bahrain",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Calcutta",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Chongqing",
"Asia/Chungking",
"Asia/Colombo",
"Asia/Dacca",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Harbin",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Istanbul",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kashgar",
"Asia/Kathmandu",
"Asia/Katmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Kuwait",
"Asia/Macao",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Muscat",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Phnom_Penh",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qyzylorda",
"Asia/Rangoon",
"Asia/Riyadh",
"Asia/Saigon",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Tel_Aviv",
"Asia/Thimbu",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ujung_Pandang",
"Asia/Ulaanbaatar",
"Asia/Ulan_Bator",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faeroe",
"Atlantic/Faroe",
"Atlantic/Jan_Mayen",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/ACT",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Canberra",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/LHI",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/NSW",
"Australia/North",
"Australia/Perth",
"Australia/Queensland",
"Australia/South",
"Australia/Sydney",
"Australia/Tasmania",
"Australia/Victoria",
"Australia/West",
"Australia/Yancowinna",
"Brazil/Acre",
"Brazil/DeNoronha",
"Brazil/East",
"Brazil/West",
"CET",
"CST6CDT",
"Canada/Atlantic",
"Canada/Central",
"Canada/Eastern",
"Canada/Mountain",
"Canada/Newfoundland",
"Canada/Pacific",
"Canada/Saskatchewan",
"Canada/Yukon",
"Chile/Continental",
"Chile/EasterIsland",
"Cuba",
"EET",
"EST",
"EST5EDT",
"Egypt",
"Eire",
"Etc/GMT",
"Etc/GMT+0",
"Etc/GMT+1",
"Etc/GMT+10",
"Etc/GMT+11",
"Etc/GMT+12",
"Etc/GMT+2",
"Etc/GMT+3",
"Etc/GMT+4",
"Etc/GMT+5",
"Etc/GMT+6",
"Etc/GMT+7",
"Etc/GMT+8",
"Etc/GMT+9",
"Etc/GMT-0",
"Etc/GMT-1",
"Etc/GMT-10",
"Etc/GMT-11",
"Etc/GMT-12",
"Etc/GMT-13",
"Etc/GMT-14",
"Etc/GMT-2",
"Etc/GMT-3",
"Etc/GMT-4",
"Etc/GMT-5",
"Etc/GMT-6",
"Etc/GMT-7",
"Etc/GMT-8",
"Etc/GMT-9",
"Etc/GMT0",
"Etc/Greenwich",
"Etc/UCT",
"Etc/UTC",
"Etc/Universal",
"Etc/Zulu",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belfast",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Bratislava",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Busingen",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Guernsey",
"Europe/Helsinki",
"Europe/Isle_of_Man",
"Europe/Istanbul",
"Europe/Jersey",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Mariehamn",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Nicosia",
"Europe/Oslo",
"Europe/Paris",
"Europe/Podgorica",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/San_Marino",
"Europe/Sarajevo",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Skopje",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Tiraspol",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vaduz",
"Europe/Vatican",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zagreb",
"Europe/Zaporozhye",
"Europe/Zurich",
"GB",
"GB-Eire",
"GMT",
"GMT+0",
"GMT-0",
"GMT0",
"Greenwich",
"HST",
"Hongkong",
"Iceland",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Iran",
"Israel",
"Jamaica",
"Japan",
"Kwajalein",
"Libya",
"MET",
"MST",
"MST7MDT",
"Mexico/BajaNorte",
"Mexico/BajaSur",
"Mexico/General",
"NZ",
"NZ-CHAT",
"Navajo",
"PRC",
"PST8PDT",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Johnston",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Midway",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Ponape",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",
"Pacific/Samoa",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Truk",
"Pacific/Wake",
"Pacific/Wallis",
"Pacific/Yap",
"Poland",
"Portugal",
"ROC",
"ROK",
"Singapore",
"Turkey",
"UCT",
"US/Alaska",
"US/Aleutian",
"US/Arizona",
"US/Central",
"US/East-Indiana",
"US/Eastern",
"US/Hawaii",
"US/Indiana-Starke",
"US/Michigan",
"US/Mountain",
"US/Pacific",
"US/Pacific-New",
"US/Samoa",
"UTC",
"Universal",
"W-SU",
"WET",
"Zulu",
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package timezones
type Timezones struct {
supportedZones []string
}
func New() *Timezones {
timezones := Timezones{}
timezones.supportedZones = DefaultSupportedTimezones
return &timezones
}
func (t *Timezones) GetSupported() []string {
return t.supportedZones
}
func DefaultUserTimezone() map[string]string {
defaultTimezone := make(map[string]string)
defaultTimezone["useAutomaticTimezone"] = "true"
defaultTimezone["automaticTimezone"] = ""
defaultTimezone["manualTimezone"] = ""
return defaultTimezone
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package timezones
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTimezoneConfig(t *testing.T) {
tz1 := New()
assert.NotEmpty(t, tz1.GetSupported())
}
func TestDefaultUserTimezone(t *testing.T) {
defaultTimezone := DefaultUserTimezone()
require.Equal(t, "true", defaultTimezone["useAutomaticTimezone"])
require.Empty(t, defaultTimezone["automaticTimezone"])
require.Empty(t, defaultTimezone["manualTimezone"])
defaultTimezone["useAutomaticTimezone"] = "false"
defaultTimezone["automaticTimezone"] = "EST"
defaultTimezone["manualTimezone"] = "AST"
defaultTimezone2 := DefaultUserTimezone()
require.Equal(t, "true", defaultTimezone2["useAutomaticTimezone"])
require.Empty(t, defaultTimezone2["automaticTimezone"])
require.Empty(t, defaultTimezone2["manualTimezone"])
}