├── .nvmrc ├── .gitignore ├── server ├── .gitignore ├── main.go ├── autolink │ ├── lib_productboard_test.go │ ├── lib_social_security_number_test.go │ ├── autolink.go │ ├── lib_credit_card_test.go │ ├── lib_jira_test.go │ └── autolink_test.go ├── autolinkclient │ ├── client.go │ └── client_test.go ├── autolinkplugin │ ├── config_test.go │ ├── plugin.go │ ├── config.go │ ├── command.go │ └── plugin_test.go └── api │ ├── api.go │ └── api_test.go ├── .gitattributes ├── .gitpod.yml ├── CHANGELOG.md ├── .github ├── dependabot.yml ├── workflows │ ├── cd.yml │ ├── ci.yml │ └── codeql-analysis.yml └── codeql │ └── codeql-config.yml ├── .golangci.yml ├── plugin.json ├── go.mod ├── assets └── icon.svg ├── Makefile ├── LICENSE ├── README.md └── go.sum /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13.1 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | server/manifest.go 3 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | coverage.txt 2 | .idea 3 | *.iml 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | server/manifest.go linguist-generated=true 2 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The changelog can be found at https://github.com/mattermost-community/mattermost-plugin-autolink/releases. 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for GOLang dependencies 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | plugin-cd: 9 | uses: mattermost/actions-workflows/.github/workflows/community-plugin-cd.yml@d9defa3e455bdbf889573e112ad8d05b91d66b4c 10 | secrets: inherit 11 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL config" 2 | 3 | query-filters: 4 | - exclude: 5 | problem.severity: 6 | - warning 7 | - recommendation 8 | - exclude: 9 | id: go/log-injection 10 | 11 | paths-ignore: 12 | - '**/*_test.go' 13 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mattermost/mattermost/server/public/plugin" 5 | 6 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolinkplugin" 7 | ) 8 | 9 | func main() { 10 | plugin.ClientMain(autolinkplugin.New()) 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | 9 | jobs: 10 | plugin-ci: 11 | uses: mattermost/actions-workflows/.github/workflows/community-plugin-ci.yml@139a051e8651e6246e3764fe342297b73120e590 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | modules-download-mode: readonly 4 | 5 | linters-settings: 6 | goconst: 7 | min-len: 2 8 | min-occurrences: 2 9 | gofmt: 10 | simplify: true 11 | goimports: 12 | local-prefixes: github.com/mattermost-community/mattermost-plugin-autolink 13 | govet: 14 | check-shadowing: true 15 | enable-all: true 16 | disable: 17 | - fieldalignment 18 | misspell: 19 | locale: US 20 | 21 | linters: 22 | disable-all: true 23 | enable: 24 | - bodyclose 25 | - errcheck 26 | - gocritic 27 | - gofmt 28 | - goimports 29 | - gosec 30 | - gosimple 31 | - govet 32 | - ineffassign 33 | - misspell 34 | - revive 35 | - nakedret 36 | - staticcheck 37 | - stylecheck 38 | - typecheck 39 | - unconvert 40 | - unused 41 | - whitespace 42 | 43 | issues: 44 | exclude-rules: 45 | - path: server/manifest.go 46 | linters: 47 | - deadcode 48 | - unused 49 | - varcheck 50 | - path: server/configuration.go 51 | linters: 52 | - unused 53 | - path: _test\.go 54 | linters: 55 | - bodyclose 56 | - goconst 57 | - scopelint # https://github.com/kyoh86/scopelint/issues/4 58 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ master ] 9 | schedule: 10 | - cron: '35 0 * * 0' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | analyze: 17 | permissions: 18 | security-events: write # for github/codeql-action/autobuild to send a status report 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | language: [ 'go' ] 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v3 30 | 31 | # Initializes the CodeQL tools for scanning. 32 | - name: Initialize CodeQL 33 | uses: github/codeql-action/init@v2 34 | with: 35 | languages: ${{ matrix.language }} 36 | debug: false 37 | config-file: ./.github/codeql/codeql-config.yml 38 | 39 | # Autobuild attempts to build any compiled languages 40 | - name: Autobuild 41 | uses: github/codeql-action/autobuild@v2 42 | 43 | # Perform Analysis 44 | - name: Perform CodeQL Analysis 45 | uses: github/codeql-action/analyze@v2 46 | -------------------------------------------------------------------------------- /server/autolink/lib_productboard_test.go: -------------------------------------------------------------------------------- 1 | package autolink_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 7 | ) 8 | 9 | var productboardLink = autolink.Autolink{ 10 | Pattern: `(?Phttps://mattermost\.productboard\.com/.+)`, 11 | Template: "[ProductBoard link]($url)", 12 | WordMatch: true, 13 | } 14 | 15 | var productboardTests = []linkTest{ 16 | { 17 | "Url replacement", 18 | productboardLink, 19 | "Welcome to https://mattermost.productboard.com/somepage should link!", 20 | "Welcome to [ProductBoard link](https://mattermost.productboard.com/somepage) should link!", 21 | }, { 22 | "Not relinking", 23 | productboardLink, 24 | "Welcome to [other link](https://mattermost.productboard.com/somepage) should not re-link!", 25 | "Welcome to [other link](https://mattermost.productboard.com/somepage) should not re-link!", 26 | }, { 27 | "Word boundary happy", 28 | productboardLink, 29 | "Welcome to (https://mattermost.productboard.com/somepage) should link!", 30 | "Welcome to ([ProductBoard link](https://mattermost.productboard.com/somepage)) should link!", 31 | }, { 32 | "Word boundary un-happy", 33 | productboardLink, 34 | "Welcome to (BADhttps://mattermost.productboard.com/somepage) should not link!", 35 | "Welcome to (BADhttps://mattermost.productboard.com/somepage) should not link!", 36 | }, 37 | } 38 | 39 | func TestProductBoard(t *testing.T) { 40 | testLinks(t, productboardTests...) 41 | } 42 | -------------------------------------------------------------------------------- /server/autolink/lib_social_security_number_test.go: -------------------------------------------------------------------------------- 1 | package autolink_test 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | const ( 11 | reSSN = `(?P(?P\d{3})[ -]?(?P\d{2})[ -]?(?P[0-9]{4}))` 12 | replaceSSN = `XXX-XX-$LastFour` 13 | ) 14 | 15 | func TestSocialSecurityNumberRegex(t *testing.T) { 16 | for _, tc := range []struct { 17 | Name string 18 | RE string 19 | Replace string 20 | In string 21 | Out string 22 | }{ 23 | {"SSN happy spaces", reSSN, replaceSSN, " abc 652 47 3356 def", " abc XXX-XX-3356 def"}, 24 | {"SSN happy dashes", reSSN, replaceSSN, " abc 652-47-3356 def", " abc XXX-XX-3356 def"}, 25 | {"SSN happy digits", reSSN, replaceSSN, " abc 652473356 def", " abc XXX-XX-3356 def"}, 26 | {"SSN happy mixed1", reSSN, replaceSSN, " abc 65247-3356 def", " abc XXX-XX-3356 def"}, 27 | {"SSN happy mixed2", reSSN, replaceSSN, " abc 652 47-3356 def", " abc XXX-XX-3356 def"}, 28 | {"SSN non-match 19-09-9999", reSSN, replaceSSN, " abc 19-09-9999 def", " abc 19-09-9999 def"}, 29 | {"SSN non-match 652_47-3356", reSSN, replaceSSN, " abc 652_47-3356 def", " abc 652_47-3356 def"}, 30 | } { 31 | t.Run(tc.Name, func(t *testing.T) { 32 | re := regexp.MustCompile(tc.RE) 33 | result := re.ReplaceAllString(tc.In, tc.Replace) 34 | if tc.Out != "" { 35 | assert.Equal(t, tc.Out, result) 36 | } else { 37 | assert.Equal(t, tc.In, result) 38 | } 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/autolinkclient/client.go: -------------------------------------------------------------------------------- 1 | package autolinkclient 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 11 | ) 12 | 13 | const autolinkPluginID = "mattermost-autolink" 14 | 15 | type PluginAPI interface { 16 | PluginHTTP(*http.Request) *http.Response 17 | } 18 | 19 | type Client struct { 20 | http.Client 21 | } 22 | 23 | type pluginAPIRoundTripper struct { 24 | api PluginAPI 25 | } 26 | 27 | func (p *pluginAPIRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 28 | resp := p.api.PluginHTTP(req) 29 | if resp == nil { 30 | return nil, fmt.Errorf("failed to make interplugin request") 31 | } 32 | return resp, nil 33 | } 34 | 35 | func NewClientPlugin(api PluginAPI) *Client { 36 | client := &Client{} 37 | client.Transport = &pluginAPIRoundTripper{api} 38 | return client 39 | } 40 | 41 | func (c *Client) Add(links ...autolink.Autolink) error { 42 | for _, link := range links { 43 | linkBytes, err := json.Marshal(link) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | req, err := http.NewRequest("POST", "/"+autolinkPluginID+"/api/v1/link", bytes.NewReader(linkBytes)) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | resp, err := c.Do(req) 54 | if err != nil { 55 | return err 56 | } 57 | defer resp.Body.Close() 58 | 59 | if resp.StatusCode != http.StatusOK { 60 | respBody, _ := io.ReadAll(resp.Body) 61 | return fmt.Errorf("unable to install autolink. Error: %v, %v", resp.StatusCode, string(respBody)) 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /server/autolinkclient/client_test.go: -------------------------------------------------------------------------------- 1 | package autolinkclient 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/mattermost/mattermost/server/public/plugin/plugintest" 8 | "github.com/stretchr/testify/mock" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 12 | ) 13 | 14 | func TestRoundTripper(t *testing.T) { 15 | mockPluginAPI := &plugintest.API{} 16 | 17 | mockPluginAPI.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return(&http.Response{StatusCode: http.StatusOK}) 18 | 19 | roundTripper := pluginAPIRoundTripper{api: mockPluginAPI} 20 | req, err := http.NewRequest("POST", "url", nil) 21 | require.Nil(t, err) 22 | resp, err := roundTripper.RoundTrip(req) 23 | require.Nil(t, err) 24 | require.Equal(t, http.StatusOK, resp.StatusCode) 25 | 26 | mockPluginAPI2 := &plugintest.API{} 27 | mockPluginAPI2.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return(nil) 28 | 29 | roundTripper2 := pluginAPIRoundTripper{api: mockPluginAPI2} 30 | req2, err := http.NewRequest("POST", "url", nil) 31 | require.Nil(t, err) 32 | resp2, err := roundTripper2.RoundTrip(req2) 33 | require.Nil(t, resp2) 34 | require.Error(t, err) 35 | } 36 | 37 | func TestAddAutolinks(t *testing.T) { 38 | mockPluginAPI := &plugintest.API{} 39 | 40 | mockPluginAPI.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return(&http.Response{StatusCode: http.StatusOK, Body: http.NoBody}) 41 | 42 | client := NewClientPlugin(mockPluginAPI) 43 | err := client.Add(autolink.Autolink{}) 44 | require.Nil(t, err) 45 | } 46 | 47 | func TestAddAutolinksErr(t *testing.T) { 48 | mockPluginAPI := &plugintest.API{} 49 | 50 | mockPluginAPI.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return(nil) 51 | 52 | client := NewClientPlugin(mockPluginAPI) 53 | err := client.Add(autolink.Autolink{}) 54 | require.Error(t, err) 55 | } 56 | -------------------------------------------------------------------------------- /server/autolinkplugin/config_test.go: -------------------------------------------------------------------------------- 1 | package autolinkplugin 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/mattermost/mattermost/server/public/model" 8 | "github.com/mattermost/mattermost/server/public/plugin/plugintest" 9 | "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 14 | ) 15 | 16 | func TestOnConfigurationChange(t *testing.T) { 17 | t.Run("Invalid Configuration", func(t *testing.T) { 18 | api := &plugintest.API{} 19 | api.On("LoadPluginConfiguration", 20 | mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 21 | return errors.New("loadPluginConfiguration Error") 22 | }) 23 | 24 | p := Plugin{} 25 | p.SetAPI(api) 26 | 27 | err := p.OnConfigurationChange() 28 | assert.Error(t, err) 29 | }) 30 | 31 | t.Run("Invalid Autolink", func(t *testing.T) { 32 | conf := Config{ 33 | Links: []autolink.Autolink{{ 34 | Name: "existing", 35 | Pattern: ")", 36 | Template: "otherthing", 37 | }}, 38 | } 39 | 40 | api := &plugintest.API{} 41 | api.On("LoadPluginConfiguration", 42 | mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 43 | *dest.(*Config) = conf 44 | return nil 45 | }) 46 | 47 | api.On("LogError", 48 | mock.AnythingOfType("string"), 49 | mock.AnythingOfType("string"), 50 | mock.AnythingOfType("autolink.Autolink"), 51 | mock.AnythingOfType("string"), 52 | mock.AnythingOfType("string")).Return(nil) 53 | 54 | api.On("UnregisterCommand", mock.AnythingOfType("string"), 55 | mock.AnythingOfType("string")).Return((*model.AppError)(nil)) 56 | 57 | p := New() 58 | p.SetAPI(api) 59 | err := p.OnConfigurationChange() 60 | require.NoError(t, err) 61 | 62 | api.AssertNumberOfCalls(t, "LogError", 1) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mattermost-autolink", 3 | "name": "Autolink", 4 | "description": "Automatically rewrite text matching a regular expression into a Markdown link.", 5 | "homepage_url": "https://github.com/mattermost-community/mattermost-plugin-autolink", 6 | "support_url": "https://github.com/mattermost-community/mattermost-plugin-autolink/issues", 7 | "icon_path": "assets/icon.svg", 8 | "min_server_version": "5.16.0", 9 | "server": { 10 | "executables": { 11 | "darwin-amd64": "server/dist/plugin-darwin-amd64", 12 | "darwin-arm64": "server/dist/plugin-darwin-arm64", 13 | "linux-amd64": "server/dist/plugin-linux-amd64", 14 | "linux-arm64": "server/dist/plugin-linux-arm64", 15 | "windows-amd64": "server/dist/plugin-windows-amd64.exe" 16 | }, 17 | "executable": "" 18 | }, 19 | "settings_schema": { 20 | "header": "Configure this plugin directly in the config.json file, or using the /autolink command. Learn more [in our documentation](https://github.com/mattermost-community/mattermost-plugin-autolink/blob/master/README.md).\n\n To report an issue, make a suggestion, or contribute, [check the plugin repository](https://github.com/mattermost-community/mattermost-plugin-autolink).", 21 | "footer": "", 22 | "settings": [ 23 | { 24 | "key": "enableadmincommand", 25 | "display_name": "Enable administration with /autolink command:", 26 | "type": "bool", 27 | "help_text": "", 28 | "placeholder": "", 29 | "default": true 30 | }, 31 | { 32 | "key": "enableonupdate", 33 | "display_name": "Apply plugin to updated posts as well as new posts:", 34 | "type": "bool", 35 | "help_text": "", 36 | "placeholder": "", 37 | "default": false 38 | }, 39 | { 40 | "key": "pluginadmins", 41 | "display_name": "Admin User IDs:", 42 | "type": "text", 43 | "help_text": "Comma-separated list of user IDs authorized to administer the plugin in addition to the System Admins.\n \n User IDs can be found by navigating to **System Console \u003e User Management \u003e Users**.", 44 | "placeholder": "", 45 | "default": null 46 | } 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mattermost-community/mattermost-plugin-autolink 2 | 3 | go 1.22 4 | 5 | toolchain go1.22.8 6 | 7 | require ( 8 | github.com/gorilla/mux v1.8.1 9 | github.com/mattermost/mattermost/server/public v0.1.9 10 | github.com/pkg/errors v0.9.1 11 | github.com/stretchr/testify v1.10.0 12 | ) 13 | 14 | require ( 15 | filippo.io/edwards25519 v1.1.0 // indirect 16 | github.com/blang/semver/v4 v4.0.0 // indirect 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 18 | github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect 19 | github.com/fatih/color v1.17.0 // indirect 20 | github.com/francoispqt/gojay v1.2.13 // indirect 21 | github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect 22 | github.com/go-sql-driver/mysql v1.8.1 // indirect 23 | github.com/golang/protobuf v1.5.4 // indirect 24 | github.com/google/uuid v1.6.0 // indirect 25 | github.com/gorilla/websocket v1.5.3 // indirect 26 | github.com/hashicorp/errwrap v1.1.0 // indirect 27 | github.com/hashicorp/go-hclog v1.6.3 // indirect 28 | github.com/hashicorp/go-multierror v1.1.1 // indirect 29 | github.com/hashicorp/go-plugin v1.6.1 // indirect 30 | github.com/hashicorp/yamux v0.1.1 // indirect 31 | github.com/kr/text v0.2.0 // indirect 32 | github.com/lib/pq v1.10.9 // indirect 33 | github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect 34 | github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect 35 | github.com/mattermost/logr/v2 v2.0.21 // indirect 36 | github.com/mattn/go-colorable v0.1.13 // indirect 37 | github.com/mattn/go-isatty v0.0.20 // indirect 38 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 39 | github.com/oklog/run v1.1.0 // indirect 40 | github.com/pborman/uuid v1.2.1 // indirect 41 | github.com/pelletier/go-toml v1.9.5 // indirect 42 | github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986 // indirect 43 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 44 | github.com/stretchr/objx v0.5.2 // indirect 45 | github.com/tinylib/msgp v1.2.0 // indirect 46 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 47 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 48 | github.com/wiggin77/merror v1.0.5 // indirect 49 | github.com/wiggin77/srslog v1.0.1 // indirect 50 | golang.org/x/crypto v0.25.0 // indirect 51 | golang.org/x/net v0.27.0 // indirect 52 | golang.org/x/sys v0.22.0 // indirect 53 | golang.org/x/text v0.16.0 // indirect 54 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect 55 | google.golang.org/grpc v1.65.0 // indirect 56 | google.golang.org/protobuf v1.34.2 // indirect 57 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 58 | gopkg.in/yaml.v2 v2.4.0 // indirect 59 | gopkg.in/yaml.v3 v3.0.1 // indirect 60 | ) 61 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/pkg/errors" 9 | 10 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 11 | ) 12 | 13 | type Store interface { 14 | GetLinks() []autolink.Autolink 15 | SaveLinks([]autolink.Autolink) error 16 | } 17 | 18 | type Authorization interface { 19 | IsAuthorizedAdmin(userID string) (bool, error) 20 | } 21 | 22 | type Handler struct { 23 | root *mux.Router 24 | store Store 25 | authorization Authorization 26 | } 27 | 28 | func NewHandler(store Store, authorization Authorization) *Handler { 29 | h := &Handler{ 30 | store: store, 31 | authorization: authorization, 32 | } 33 | 34 | root := mux.NewRouter() 35 | api := root.PathPrefix("/api/v1").Subrouter() 36 | api.Use(h.adminOrPluginRequired) 37 | api.HandleFunc("/link", h.setLink).Methods("POST") 38 | 39 | api.Handle("{anything:.*}", http.NotFoundHandler()) 40 | 41 | h.root = root 42 | 43 | return h 44 | } 45 | 46 | func (h *Handler) handleError(w http.ResponseWriter, err error) { 47 | w.Header().Set("Content-Type", "application/json") 48 | w.WriteHeader(http.StatusInternalServerError) 49 | b, _ := json.Marshal(struct { 50 | Error string `json:"error"` 51 | Details string `json:"details"` 52 | }{ 53 | Error: "An internal error has occurred. Check app server logs for details.", 54 | Details: err.Error(), 55 | }) 56 | _, _ = w.Write(b) 57 | } 58 | 59 | func (h *Handler) adminOrPluginRequired(next http.Handler) http.Handler { 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 | var err error 62 | authorized := false 63 | pluginID := r.Header.Get("Mattermost-Plugin-ID") 64 | if pluginID != "" { 65 | // All other plugins are allowed 66 | authorized = true 67 | } 68 | 69 | userID := r.Header.Get("Mattermost-User-ID") 70 | if !authorized && userID != "" { 71 | authorized, err = h.authorization.IsAuthorizedAdmin(userID) 72 | if err != nil { 73 | http.Error(w, "Not authorized", http.StatusUnauthorized) 74 | return 75 | } 76 | } 77 | 78 | if !authorized { 79 | http.Error(w, "Not authorized", http.StatusUnauthorized) 80 | return 81 | } 82 | next.ServeHTTP(w, r) 83 | }) 84 | } 85 | 86 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 87 | h.root.ServeHTTP(w, r) 88 | } 89 | 90 | func (h *Handler) setLink(w http.ResponseWriter, r *http.Request) { 91 | var newLink autolink.Autolink 92 | if err := json.NewDecoder(r.Body).Decode(&newLink); err != nil { 93 | h.handleError(w, errors.Wrap(err, "unable to decode body")) 94 | return 95 | } 96 | 97 | links := h.store.GetLinks() 98 | found := false 99 | changed := false 100 | for i := range links { 101 | if links[i].Name == newLink.Name || links[i].Pattern == newLink.Pattern { 102 | if !links[i].Equals(newLink) { 103 | links[i] = newLink 104 | changed = true 105 | } 106 | found = true 107 | break 108 | } 109 | } 110 | if !found { 111 | links = append(h.store.GetLinks(), newLink) 112 | changed = true 113 | } 114 | status := http.StatusNotModified 115 | if changed { 116 | if err := h.store.SaveLinks(links); err != nil { 117 | h.handleError(w, errors.Wrap(err, "unable to save link")) 118 | return 119 | } 120 | status = http.StatusOK 121 | } 122 | 123 | w.Header().Set("Content-Type", "application/json") 124 | w.WriteHeader(status) 125 | _, _ = w.Write([]byte(`{"status": "OK"}`)) 126 | } 127 | -------------------------------------------------------------------------------- /server/api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 13 | ) 14 | 15 | type authorizeAll struct{} 16 | 17 | func (authorizeAll) IsAuthorizedAdmin(string) (bool, error) { 18 | return true, nil 19 | } 20 | 21 | type linkStore struct { 22 | prev []autolink.Autolink 23 | saveCalled *bool 24 | saved *[]autolink.Autolink 25 | } 26 | 27 | func (s *linkStore) GetLinks() []autolink.Autolink { 28 | return s.prev 29 | } 30 | 31 | func (s *linkStore) SaveLinks(links []autolink.Autolink) error { 32 | *s.saved = links 33 | *s.saveCalled = true 34 | return nil 35 | } 36 | 37 | func TestSetLink(t *testing.T) { 38 | for _, tc := range []struct { 39 | name string 40 | method string 41 | prevLinks []autolink.Autolink 42 | link autolink.Autolink 43 | expectSaveCalled bool 44 | expectSaved []autolink.Autolink 45 | expectStatus int 46 | }{ 47 | { 48 | name: "happy simple", 49 | link: autolink.Autolink{ 50 | Name: "test", 51 | }, 52 | expectStatus: http.StatusOK, 53 | expectSaveCalled: true, 54 | expectSaved: []autolink.Autolink{{ 55 | Name: "test", 56 | }}, 57 | }, 58 | { 59 | name: "add new link", 60 | link: autolink.Autolink{ 61 | Name: "test1", 62 | Pattern: ".*1", 63 | Template: "test1", 64 | }, 65 | prevLinks: []autolink.Autolink{{ 66 | Name: "test2", 67 | Pattern: ".*2", 68 | Template: "test2", 69 | }}, 70 | expectStatus: http.StatusOK, 71 | expectSaveCalled: true, 72 | expectSaved: []autolink.Autolink{{ 73 | Name: "test2", 74 | Pattern: ".*2", 75 | Template: "test2", 76 | }, { 77 | Name: "test1", 78 | Pattern: ".*1", 79 | Template: "test1", 80 | }}, 81 | }, { 82 | name: "replace link", 83 | link: autolink.Autolink{ 84 | Name: "test2", 85 | Pattern: ".*2", 86 | Template: "new template", 87 | }, 88 | prevLinks: []autolink.Autolink{{ 89 | Name: "test1", 90 | Pattern: ".*1", 91 | Template: "test1", 92 | }, { 93 | Name: "test2", 94 | Pattern: ".*2", 95 | Template: "test2", 96 | }, { 97 | Name: "test3", 98 | Pattern: ".*3", 99 | Template: "test3", 100 | }}, 101 | expectStatus: http.StatusOK, 102 | expectSaveCalled: true, 103 | expectSaved: []autolink.Autolink{{ 104 | Name: "test1", 105 | Pattern: ".*1", 106 | Template: "test1", 107 | }, { 108 | Name: "test2", 109 | Pattern: ".*2", 110 | Template: "new template", 111 | }, { 112 | Name: "test3", 113 | Pattern: ".*3", 114 | Template: "test3", 115 | }}, 116 | }, 117 | { 118 | name: "no change", 119 | link: autolink.Autolink{ 120 | Name: "test2", 121 | Pattern: ".*2", 122 | Template: "test2", 123 | }, 124 | prevLinks: []autolink.Autolink{{ 125 | Name: "test1", 126 | Pattern: ".*1", 127 | Template: "test1", 128 | }, { 129 | Name: "test2", 130 | Pattern: ".*2", 131 | Template: "test2", 132 | }}, 133 | expectStatus: http.StatusNotModified, 134 | expectSaveCalled: false, 135 | }, 136 | } { 137 | t.Run(tc.name, func(t *testing.T) { 138 | var saved []autolink.Autolink 139 | var saveCalled bool 140 | 141 | h := NewHandler( 142 | &linkStore{ 143 | prev: tc.prevLinks, 144 | saveCalled: &saveCalled, 145 | saved: &saved, 146 | }, 147 | authorizeAll{}, 148 | ) 149 | 150 | body, err := json.Marshal(tc.link) 151 | require.NoError(t, err) 152 | 153 | w := httptest.NewRecorder() 154 | method := "POST" 155 | if tc.method != "" { 156 | method = tc.method 157 | } 158 | r, err := http.NewRequest(method, "/api/v1/link", bytes.NewReader(body)) 159 | require.NoError(t, err) 160 | 161 | r.Header.Set("Mattermost-Plugin-ID", "testfrom") 162 | r.Header.Set("Mattermost-User-ID", "testuser") 163 | 164 | h.ServeHTTP(w, r) 165 | require.Equal(t, tc.expectStatus, w.Code) 166 | require.Equal(t, tc.expectSaveCalled, saveCalled) 167 | require.Equal(t, tc.expectSaved, saved) 168 | }) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /server/autolink/autolink.go: -------------------------------------------------------------------------------- 1 | package autolink 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | // Autolink represents a pattern to autolink. 9 | type Autolink struct { 10 | Name string `json:"Name"` 11 | Disabled bool `json:"Disabled"` 12 | Pattern string `json:"Pattern"` 13 | Template string `json:"Template"` 14 | Scope []string `json:"Scope"` 15 | WordMatch bool `json:"WordMatch"` 16 | DisableNonWordPrefix bool `json:"DisableNonWordPrefix"` 17 | DisableNonWordSuffix bool `json:"DisableNonWordSuffix"` 18 | ProcessBotPosts bool `json:"ProcessBotPosts"` 19 | 20 | template string 21 | re *regexp.Regexp 22 | canReplaceAll bool 23 | } 24 | 25 | func (l Autolink) Equals(x Autolink) bool { 26 | if l.Disabled != x.Disabled || 27 | l.DisableNonWordPrefix != x.DisableNonWordPrefix || 28 | l.DisableNonWordSuffix != x.DisableNonWordSuffix || 29 | l.ProcessBotPosts != x.ProcessBotPosts || 30 | l.Name != x.Name || 31 | l.Pattern != x.Pattern || 32 | len(l.Scope) != len(x.Scope) || 33 | l.Template != x.Template || 34 | l.WordMatch != x.WordMatch { 35 | return false 36 | } 37 | for i, scope := range l.Scope { 38 | if scope != x.Scope[i] { 39 | return false 40 | } 41 | } 42 | return true 43 | } 44 | 45 | // DisplayName returns a display name for the link. 46 | func (l Autolink) DisplayName() string { 47 | if l.Name != "" { 48 | return l.Name 49 | } 50 | return l.Pattern 51 | } 52 | 53 | // Compile compiles the link's regular expression 54 | func (l *Autolink) Compile() error { 55 | if l.Disabled || len(l.Pattern) == 0 || len(l.Template) == 0 { 56 | return nil 57 | } 58 | 59 | // `\b` can be used with ReplaceAll since it does not consume characters, 60 | // custom patterns can not and need to be processed one at a time. 61 | canReplaceAll := false 62 | pattern := l.Pattern 63 | template := l.Template 64 | replacingCharacter := `\b` 65 | if !l.DisableNonWordPrefix { 66 | if l.WordMatch { 67 | pattern = fmt.Sprint(replacingCharacter, pattern) 68 | canReplaceAll = true 69 | } else { 70 | pattern = `(?P(^|\s))` + pattern 71 | template = `${MattermostNonWordPrefix}` + template 72 | } 73 | } 74 | if !l.DisableNonWordSuffix { 75 | if l.WordMatch { 76 | pattern += replacingCharacter 77 | canReplaceAll = true 78 | } else { 79 | pattern += `(?P$|[\s\.\!\?\,\)])` 80 | template += `${MattermostNonWordSuffix}` 81 | } 82 | } 83 | 84 | re, err := regexp.Compile(pattern) 85 | if err != nil { 86 | return err 87 | } 88 | l.re = re 89 | l.template = template 90 | l.canReplaceAll = canReplaceAll 91 | 92 | return nil 93 | } 94 | 95 | // Replace will subsitute the regex's with the supplied links 96 | func (l Autolink) Replace(message string) string { 97 | if l.re == nil { 98 | return message 99 | } 100 | 101 | // Since they don't consume, `\b`s require no special handling, can just ReplaceAll 102 | if l.canReplaceAll { 103 | return l.re.ReplaceAllString(message, l.template) 104 | } 105 | 106 | // Replace one at a time 107 | in := []byte(message) 108 | out := []byte{} 109 | for { 110 | if len(in) == 0 { 111 | break 112 | } 113 | 114 | submatch := l.re.FindSubmatchIndex(in) 115 | if submatch == nil { 116 | break 117 | } 118 | 119 | out = append(out, in[:submatch[0]]...) 120 | out = l.re.Expand(out, []byte(l.template), in, submatch) 121 | in = in[submatch[1]:] 122 | } 123 | out = append(out, in...) 124 | return string(out) 125 | } 126 | 127 | // ToMarkdown prints a Link as a markdown list element 128 | func (l Autolink) ToMarkdown(i int) string { 129 | text := "- " 130 | if i > 0 { 131 | text += fmt.Sprintf("%v: ", i) 132 | } 133 | if l.Name != "" { 134 | if l.Disabled { 135 | text += fmt.Sprintf("~~%s~~", l.Name) 136 | } else { 137 | text += l.Name 138 | } 139 | } 140 | if l.Disabled { 141 | text += " **Disabled**" 142 | } 143 | text += "\n" 144 | 145 | text += fmt.Sprintf(" - Pattern: `%s`\n", l.Pattern) 146 | text += fmt.Sprintf(" - Template: `%s`\n", l.Template) 147 | 148 | if l.DisableNonWordPrefix { 149 | text += fmt.Sprintf(" - DisableNonWordPrefix: `%v`\n", l.DisableNonWordPrefix) 150 | } 151 | if l.DisableNonWordSuffix { 152 | text += fmt.Sprintf(" - DisableNonWordSuffix: `%v`\n", l.DisableNonWordSuffix) 153 | } 154 | if l.ProcessBotPosts { 155 | text += fmt.Sprintf(" - ProcessBotPosts: `%v`\n", l.ProcessBotPosts) 156 | } 157 | if len(l.Scope) != 0 { 158 | text += fmt.Sprintf(" - Scope: `%v`\n", l.Scope) 159 | } 160 | if l.WordMatch { 161 | text += fmt.Sprintf(" - WordMatch: `%v`\n", l.WordMatch) 162 | } 163 | return text 164 | } 165 | -------------------------------------------------------------------------------- /server/autolinkplugin/plugin.go: -------------------------------------------------------------------------------- 1 | package autolinkplugin 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/mattermost/mattermost/server/public/model" 10 | "github.com/mattermost/mattermost/server/public/plugin" 11 | "github.com/mattermost/mattermost/server/public/shared/markdown" 12 | "github.com/pkg/errors" 13 | 14 | "github.com/mattermost-community/mattermost-plugin-autolink/server/api" 15 | ) 16 | 17 | // Plugin the main struct for everything 18 | type Plugin struct { 19 | plugin.MattermostPlugin 20 | 21 | handler *api.Handler 22 | 23 | // configuration and a mutex to control concurrent access 24 | conf *Config 25 | confLock sync.RWMutex 26 | } 27 | 28 | func New() *Plugin { 29 | return &Plugin{ 30 | conf: new(Config), 31 | } 32 | } 33 | 34 | func (p *Plugin) OnActivate() error { 35 | p.handler = api.NewHandler(p, p) 36 | 37 | return nil 38 | } 39 | 40 | func (p *Plugin) IsAuthorizedAdmin(userID string) (bool, error) { 41 | user, err := p.API.GetUser(userID) 42 | if err != nil { 43 | return false, errors.Wrapf(err, "failed to obtain information about user `%s`", userID) 44 | } 45 | if strings.Contains(user.Roles, "system_admin") { 46 | p.API.LogInfo( 47 | fmt.Sprintf("UserID `%s` is authorized basing on the sysadmin role membership", userID)) 48 | return true, nil 49 | } 50 | 51 | conf := p.getConfig() 52 | if _, ok := conf.AdminUserIds[userID]; ok { 53 | p.API.LogInfo( 54 | fmt.Sprintf("UserID `%s` is authorized basing on the list of plugin admins list", userID)) 55 | return true, nil 56 | } 57 | 58 | return false, nil 59 | } 60 | 61 | func (p *Plugin) resolveScope(channelID string) (string, string, *model.AppError) { 62 | channel, cErr := p.API.GetChannel(channelID) 63 | if cErr != nil { 64 | return "", "", cErr 65 | } 66 | 67 | if channel.TeamId == "" { 68 | return channel.Name, "", nil 69 | } 70 | 71 | team, tErr := p.API.GetTeam(channel.TeamId) 72 | if tErr != nil { 73 | return "", "", tErr 74 | } 75 | 76 | return channel.Name, team.Name, nil 77 | } 78 | 79 | func (p *Plugin) inScope(scope []string, channelName string, teamName string) bool { 80 | if len(scope) == 0 { 81 | return true 82 | } 83 | 84 | if teamName == "" { 85 | return false 86 | } 87 | 88 | for _, teamChannel := range scope { 89 | split := strings.Split(teamChannel, "/") 90 | 91 | splitLength := len(split) 92 | 93 | if splitLength == 1 && split[0] == "" { 94 | return false 95 | } 96 | 97 | if splitLength == 1 && strings.EqualFold(split[0], teamName) { 98 | return true 99 | } 100 | 101 | scopeMatch := strings.EqualFold(split[0], teamName) && strings.EqualFold(split[1], channelName) 102 | if splitLength == 2 && scopeMatch { 103 | return true 104 | } 105 | } 106 | 107 | return false 108 | } 109 | 110 | func (p *Plugin) ProcessPost(_ *plugin.Context, post *model.Post) (*model.Post, string) { 111 | conf := p.getConfig() 112 | 113 | message := post.Message 114 | changed := false 115 | offset := 0 116 | 117 | hasOneOrMoreScopes := false 118 | for _, link := range conf.Links { 119 | if len(link.Scope) > 0 { 120 | hasOneOrMoreScopes = true 121 | break 122 | } 123 | } 124 | 125 | channelName := "" 126 | teamName := "" 127 | if hasOneOrMoreScopes { 128 | cn, tn, rsErr := p.resolveScope(post.ChannelId) 129 | channelName = cn 130 | teamName = tn 131 | 132 | if rsErr != nil { 133 | p.API.LogError("Failed to resolve scope", "error", rsErr.Error()) 134 | } 135 | } 136 | 137 | var author *model.User 138 | var authorErr *model.AppError 139 | 140 | markdown.Inspect(post.Message, func(node interface{}) bool { 141 | if node == nil { 142 | return false 143 | } 144 | 145 | toProcess, start, end := "", 0, 0 146 | switch node := node.(type) { 147 | // never descend into the text content of a link/image 148 | case *markdown.InlineLink, *markdown.InlineImage, *markdown.ReferenceLink, *markdown.ReferenceImage: 149 | return false 150 | 151 | case *markdown.Autolink: 152 | start, end = node.RawDestination.Position+offset, node.RawDestination.End+offset 153 | toProcess = message[start:end] 154 | // Do not process escaped links. Not exactly sure why but preserving the previous behavior. 155 | // https://mattermost.atlassian.net/browse/MM-42669 156 | if markdown.Unescape(toProcess) != toProcess { 157 | p.API.LogDebug("skipping escaped autolink", "original", toProcess, "post_id", post.Id) 158 | return true 159 | } 160 | 161 | case *markdown.Text: 162 | start, end = node.Range.Position+offset, node.Range.End+offset 163 | toProcess = message[start:end] 164 | if node.Text != toProcess { 165 | p.API.LogDebug("skipping text: parsed markdown did not match original", "parsed", node.Text, "original", toProcess, "post_id", post.Id) 166 | return true 167 | } 168 | } 169 | 170 | if toProcess == "" { 171 | return true 172 | } 173 | 174 | processed := toProcess 175 | for _, link := range conf.Links { 176 | if !p.inScope(link.Scope, channelName, teamName) { 177 | continue 178 | } 179 | 180 | out := link.Replace(processed) 181 | if out == processed { 182 | continue 183 | } 184 | 185 | if !link.ProcessBotPosts { 186 | if author == nil && authorErr == nil { 187 | author, authorErr = p.API.GetUser(post.UserId) 188 | if authorErr != nil { 189 | // NOTE: Not sure how we want to handle errors here, we can either: 190 | // * assume that occasional rewrites of Bot messges are ok 191 | // * assume that occasional not rewriting of all messages is ok 192 | // Let's assume for now that former is a lesser evil and carry on. 193 | p.API.LogError("failed to check if message for rewriting was send by a bot", "error", authorErr) 194 | } 195 | } 196 | 197 | if author != nil && author.IsBot { 198 | continue 199 | } 200 | } 201 | 202 | processed = out 203 | } 204 | 205 | if toProcess != processed { 206 | message = message[:start] + processed + message[end:] 207 | offset += len(processed) - len(toProcess) 208 | changed = true 209 | } 210 | 211 | return true 212 | }) 213 | 214 | if changed { 215 | post.Message = message 216 | post.Hashtags, _ = model.ParseHashtags(message) 217 | } 218 | return post, "" 219 | } 220 | 221 | func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) { 222 | p.handler.ServeHTTP(w, r) 223 | } 224 | 225 | // MessageWillBePosted is invoked when a message is posted by a user before it is committed 226 | // to the database. 227 | func (p *Plugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { 228 | return p.ProcessPost(c, post) 229 | } 230 | 231 | // MessageWillBeUpdated is invoked when a message is updated by a user before it is committed 232 | // to the database. 233 | func (p *Plugin) MessageWillBeUpdated(c *plugin.Context, post *model.Post, _ *model.Post) (*model.Post, string) { 234 | conf := p.getConfig() 235 | if !conf.EnableOnUpdate { 236 | return post, "" 237 | } 238 | 239 | return p.ProcessPost(c, post) 240 | } 241 | -------------------------------------------------------------------------------- /server/autolinkplugin/config.go: -------------------------------------------------------------------------------- 1 | package autolinkplugin 2 | 3 | import ( 4 | "encoding/json" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/mattermost/mattermost/server/public/model" 9 | "github.com/mattermost/mattermost/server/public/plugin" 10 | "github.com/pkg/errors" 11 | 12 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 13 | ) 14 | 15 | // Config from config.json 16 | type Config struct { 17 | EnableAdminCommand bool `json:"enableadmincommand"` 18 | EnableOnUpdate bool `json:"enableonupdate"` 19 | PluginAdmins string `json:"pluginadmins"` 20 | Links []autolink.Autolink `json:"links"` 21 | 22 | // AdminUserIds is a set of UserIds that are permitted to perform 23 | // administrative operations on the plugin configuration (i.e. plugin 24 | // admins). On each configuration change the contents of PluginAdmins 25 | // config field is parsed into this field. 26 | AdminUserIds map[string]struct{} `json:"-"` 27 | } 28 | 29 | // OnConfigurationChange is invoked when configuration changes may have been made. 30 | func (p *Plugin) OnConfigurationChange() error { 31 | var c Config 32 | if err := p.API.LoadPluginConfiguration(&c); err != nil { 33 | return errors.Wrap(err, "failed to load plugin configuration") 34 | } 35 | 36 | for i := range c.Links { 37 | if err := c.Links[i].Compile(); err != nil { 38 | p.API.LogError("Error creating autolinker", "link", c.Links[i], "error", err.Error()) 39 | } 40 | } 41 | 42 | // Plugin admin UserId parsing and validation errors are 43 | // not fatal, if everything fails only sysadmin will be able to manage the 44 | // config which is still OK 45 | c.parsePluginAdminList(p.API) 46 | 47 | p.UpdateConfig(func(conf *Config) { 48 | *conf = c 49 | }) 50 | 51 | go func() { 52 | if c.EnableAdminCommand { 53 | _ = p.API.RegisterCommand(&model.Command{ 54 | Trigger: "autolink", 55 | DisplayName: "Autolink", 56 | Description: "Autolink administration.", 57 | AutoComplete: true, 58 | AutoCompleteDesc: "Available commands: add, delete, disable, enable, list, set, test", 59 | AutoCompleteHint: "[command]", 60 | AutocompleteData: getAutoCompleteData(), 61 | }) 62 | } else { 63 | _ = p.API.UnregisterCommand("", "autolink") 64 | } 65 | }() 66 | 67 | return nil 68 | } 69 | 70 | func getAutoCompleteData() *model.AutocompleteData { 71 | autolink := model.NewAutocompleteData("autolink", "[command]", 72 | "Available command : add, delete, disable, enable, list, set, test") 73 | 74 | add := model.NewAutocompleteData("add", "", 75 | "Add a new link with a given name") 76 | add.AddTextArgument("Name for a new link", "[name]", "") 77 | autolink.AddCommand(add) 78 | 79 | deleteLink := model.NewAutocompleteData("delete", "", 80 | "Delete a link with a given name") 81 | deleteLink.AddTextArgument("Name of the link to delete", "[name]", "") 82 | autolink.AddCommand(deleteLink) 83 | 84 | disable := model.NewAutocompleteData("disable", "", 85 | "Disable a link with a given name") 86 | disable.AddTextArgument("Name of the link to disable", "[name]", "") 87 | autolink.AddCommand(disable) 88 | 89 | enable := model.NewAutocompleteData("enable", "", 90 | "Enable a link with a given name") 91 | enable.AddTextArgument("Name of the link to enable", "[name]", "") 92 | autolink.AddCommand(enable) 93 | 94 | list := model.NewAutocompleteData("list", "", 95 | "List all configured links") 96 | list.AddStaticListArgument("List the link which match with the given condition", 97 | false, []model.AutocompleteListItem{ 98 | { 99 | HelpText: "If `name` of a link is provided, it will only list a configuration of `name` link ", 100 | Hint: "(optional)", 101 | Item: "[name]", 102 | }, 103 | { 104 | HelpText: "List configuration of link matched with the given template", 105 | Hint: "(optional)", 106 | Item: "Template", 107 | }, 108 | { 109 | HelpText: "List configuration of link matched with the given pattern", 110 | Hint: "(optional)", 111 | Item: "Pattern", 112 | }, 113 | }) 114 | autolink.AddCommand(list) 115 | 116 | set := model.NewAutocompleteData("set", "", 117 | "Set a field of a link with a given value") 118 | set.AddTextArgument("Name of a link to set", "[name]", "") 119 | set.AddStaticListArgument("A name of a field to set a value", false, 120 | []model.AutocompleteListItem{ 121 | { 122 | HelpText: "Set the `Template` field", 123 | Hint: "", 124 | Item: "Template", 125 | }, 126 | { 127 | HelpText: "Set the `Pattern` field", 128 | Hint: "", 129 | Item: "Pattern", 130 | }, 131 | { 132 | HelpText: "If true uses the \\b word boundaries", 133 | Hint: "", 134 | Item: "WordMatch", 135 | }, 136 | { 137 | HelpText: "If true applies changes to posts created by bot accounts.", 138 | Hint: "", 139 | Item: "ProcessBotPosts", 140 | }, 141 | { 142 | HelpText: "team/channel the autolink applies to", 143 | Hint: "", 144 | Item: "Scope", 145 | }, 146 | }) 147 | autolink.AddCommand(set) 148 | 149 | test := model.NewAutocompleteData("test", "", 150 | "Test a link on the text provided") 151 | test.AddTextArgument("Name of a link to test with", "[name]", "") 152 | test.AddTextArgument("Sample text which the link applies", "[sample text]", "") 153 | autolink.AddCommand(test) 154 | 155 | help := model.NewAutocompleteData("help", "", "Autolink plugin slash command help") 156 | autolink.AddCommand(help) 157 | 158 | return autolink 159 | } 160 | 161 | func (p *Plugin) getConfig() *Config { 162 | p.confLock.RLock() 163 | defer p.confLock.RUnlock() 164 | 165 | return p.conf 166 | } 167 | 168 | func (p *Plugin) GetLinks() []autolink.Autolink { 169 | p.confLock.RLock() 170 | defer p.confLock.RUnlock() 171 | 172 | return p.conf.Links 173 | } 174 | 175 | func (p *Plugin) SaveLinks(links []autolink.Autolink) error { 176 | p.UpdateConfig(func(conf *Config) { 177 | conf.Links = links 178 | }) 179 | 180 | configMap, err := p.getConfig().ToMap() 181 | if err != nil { 182 | return errors.Wrap(err, "unable convert config to map") 183 | } 184 | 185 | appErr := p.API.SavePluginConfig(configMap) 186 | if appErr != nil { 187 | return errors.Wrap(appErr, "unable to save links") 188 | } 189 | 190 | return nil 191 | } 192 | 193 | func (p *Plugin) UpdateConfig(f func(conf *Config)) { 194 | p.confLock.Lock() 195 | defer p.confLock.Unlock() 196 | 197 | f(p.conf) 198 | } 199 | 200 | // ToConfig marshals Config into a tree of map[string]interface{} to pass down 201 | // to p.API.SavePluginConfig, otherwise RPC/gob barfs at the unknown type. 202 | func (conf *Config) ToMap() (map[string]interface{}, error) { 203 | var out map[string]interface{} 204 | data, err := json.Marshal(conf) 205 | if err != nil { 206 | return nil, err 207 | } 208 | err = json.Unmarshal(data, &out) 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | return out, nil 214 | } 215 | 216 | // Sorted returns a clone of the Config, with links sorted alphabetically 217 | func (conf *Config) Sorted() *Config { 218 | sorted := conf 219 | sorted.Links = append([]autolink.Autolink{}, conf.Links...) 220 | sort.Slice(conf.Links, func(i, j int) bool { 221 | return strings.Compare(conf.Links[i].DisplayName(), conf.Links[j].DisplayName()) < 0 222 | }) 223 | return conf 224 | } 225 | 226 | // parsePluginAdminList parses the contents of PluginAdmins config field 227 | func (conf *Config) parsePluginAdminList(api plugin.API) { 228 | conf.AdminUserIds = make(map[string]struct{}, len(conf.PluginAdmins)) 229 | 230 | if len(conf.PluginAdmins) == 0 { 231 | // There were no plugin admin users defined 232 | return 233 | } 234 | 235 | userIDs := strings.Split(conf.PluginAdmins, ",") 236 | for _, userID := range userIDs { 237 | userID = strings.TrimSpace(userID) 238 | // Let's verify that the given user really exists 239 | _, appErr := api.GetUser(userID) 240 | if appErr != nil { 241 | api.LogWarn("Error occurred while verifying userID", "userID", userID, "error", appErr) 242 | } else { 243 | conf.AdminUserIds[userID] = struct{}{} 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /server/autolink/lib_credit_card_test.go: -------------------------------------------------------------------------------- 1 | package autolink_test 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 10 | ) 11 | 12 | const ( 13 | reVISA = `(?P(?P4\d{3})[ -]?(?P\d{4})[ -]?(?P\d{4})[ -]?(?P[0-9]{4}))` 14 | reMasterCard = `(?P(?P5[1-5]\d{2})[ -]?(?P\d{4})[ -]?(?P\d{4})[ -]?(?P[0-9]{4}))` 15 | reSwitchSolo = `(?P(?P67\d{2})[ -]?(?P\d{4})[ -]?(?P\d{4})[ -]?(?P[0-9]{4}))` 16 | reDiscover = `(?P(?P6011)[ -]?(?P\d{4})[ -]?(?P\d{4})[ -]?(?P[0-9]{4}))` 17 | reAMEX = `(?P(?P3[47]\d{2})[ -]?(?P\d{6})[ -]?(?P\d)(?P[0-9]{4}))` 18 | 19 | replaceVISA = "VISA XXXX-XXXX-XXXX-$LastFour" 20 | replaceMasterCard = "MasterCard XXXX-XXXX-XXXX-$LastFour" 21 | replaceSwitchSolo = "Switch/Solo XXXX-XXXX-XXXX-$LastFour" 22 | replaceDiscover = "Discover XXXX-XXXX-XXXX-$LastFour" 23 | replaceAMEX = "American Express XXXX-XXXXXX-X$LastFour" 24 | ) 25 | 26 | func TestCreditCardRegex(t *testing.T) { 27 | for _, tc := range []struct { 28 | Name string 29 | RE string 30 | Replace string 31 | In string 32 | Out string 33 | }{ 34 | {"Visa happy spaces", reVISA, replaceVISA, " abc 4111 1111 1111 1234 def", " abc VISA XXXX-XXXX-XXXX-1234 def"}, 35 | {"Visa happy dashes", reVISA, replaceVISA, "4111-1111-1111-1234", "VISA XXXX-XXXX-XXXX-1234"}, 36 | {"Visa happy mixed", reVISA, replaceVISA, "41111111 1111-1234", "VISA XXXX-XXXX-XXXX-1234"}, 37 | {"Visa happy digits", reVISA, replaceVISA, "abc 4111111111111234 def", "abc VISA XXXX-XXXX-XXXX-1234 def"}, 38 | {"Visa non-match start", reVISA, replaceVISA, "3111111111111234", ""}, 39 | {"Visa non-match num digits", reVISA, replaceVISA, " 4111-1111-1111-123", ""}, 40 | {"Visa non-match sep", reVISA, replaceVISA, "4111=1111=1111_1234", ""}, 41 | {"Visa non-match no break before", reVISA, replaceVISA, "abc4111-1111-1111-1234", "abcVISA XXXX-XXXX-XXXX-1234"}, 42 | {"Visa non-match no break after", reVISA, replaceVISA, "4111-1111-1111-1234def", "VISA XXXX-XXXX-XXXX-1234def"}, 43 | 44 | {"MasterCard happy spaces", reMasterCard, replaceMasterCard, " abc 5111 1111 1111 1234 def", " abc MasterCard XXXX-XXXX-XXXX-1234 def"}, 45 | {"MasterCard happy dashes", reMasterCard, replaceMasterCard, "5211-1111-1111-1234", "MasterCard XXXX-XXXX-XXXX-1234"}, 46 | {"MasterCard happy mixed", reMasterCard, replaceMasterCard, "53111111 1111-1234", "MasterCard XXXX-XXXX-XXXX-1234"}, 47 | {"MasterCard happy digits", reMasterCard, replaceMasterCard, "abc 5411111111111234 def", "abc MasterCard XXXX-XXXX-XXXX-1234 def"}, 48 | {"MasterCard non-match start", reMasterCard, replaceMasterCard, "3111111111111234", ""}, 49 | {"MasterCard non-match num digits", reMasterCard, replaceMasterCard, " 5111-1111-1111-123", ""}, 50 | {"MasterCard non-match sep", reMasterCard, replaceMasterCard, "5111=1111=1111_1234", ""}, 51 | {"MasterCard non-match no break before", reMasterCard, replaceMasterCard, "abc5511-1111-1111-1234", "abcMasterCard XXXX-XXXX-XXXX-1234"}, 52 | {"MasterCard non-match no break after", reMasterCard, replaceMasterCard, "5111-1111-1111-1234def", "MasterCard XXXX-XXXX-XXXX-1234def"}, 53 | 54 | {"SwitchSolo happy spaces", reSwitchSolo, replaceSwitchSolo, " abc 6711 1111 1111 1234 def", " abc Switch/Solo XXXX-XXXX-XXXX-1234 def"}, 55 | {"SwitchSolo happy dashes", reSwitchSolo, replaceSwitchSolo, "6711-1111-1111-1234", "Switch/Solo XXXX-XXXX-XXXX-1234"}, 56 | {"SwitchSolo happy mixed", reSwitchSolo, replaceSwitchSolo, "67111111 1111-1234", "Switch/Solo XXXX-XXXX-XXXX-1234"}, 57 | {"SwitchSolo happy digits", reSwitchSolo, replaceSwitchSolo, "abc 6711111111111234 def", "abc Switch/Solo XXXX-XXXX-XXXX-1234 def"}, 58 | {"SwitchSolo non-match start", reSwitchSolo, replaceSwitchSolo, "3111111111111234", ""}, 59 | {"SwitchSolo non-match num digits", reSwitchSolo, replaceSwitchSolo, " 6711-1111-1111-123", ""}, 60 | {"SwitchSolo non-match sep", reSwitchSolo, replaceSwitchSolo, "6711=1111=1111_1234", ""}, 61 | {"SwitchSolo non-match no break before", reSwitchSolo, replaceSwitchSolo, "abc6711-1111-1111-1234", "abcSwitch/Solo XXXX-XXXX-XXXX-1234"}, 62 | {"SwitchSolo non-match no break after", reSwitchSolo, replaceSwitchSolo, "6711-1111-1111-1234def", "Switch/Solo XXXX-XXXX-XXXX-1234def"}, 63 | 64 | {"Discover happy spaces", reDiscover, replaceDiscover, " abc 6011 1111 1111 1234 def", " abc Discover XXXX-XXXX-XXXX-1234 def"}, 65 | {"Discover happy dashes", reDiscover, replaceDiscover, "6011-1111-1111-1234", "Discover XXXX-XXXX-XXXX-1234"}, 66 | {"Discover happy mixed", reDiscover, replaceDiscover, "60111111 1111-1234", "Discover XXXX-XXXX-XXXX-1234"}, 67 | {"Discover happy digits", reDiscover, replaceDiscover, "abc 6011111111111234 def", "abc Discover XXXX-XXXX-XXXX-1234 def"}, 68 | {"Discover non-match start", reDiscover, replaceDiscover, "3111111111111234", ""}, 69 | {"Discover non-match num digits", reDiscover, replaceDiscover, " 6011-1111-1111-123", ""}, 70 | {"Discover non-match sep", reDiscover, replaceDiscover, "6011=1111=1111_1234", ""}, 71 | {"Discover non-match no break before", reDiscover, replaceDiscover, "abc6011-1111-1111-1234", "abcDiscover XXXX-XXXX-XXXX-1234"}, 72 | {"Discover non-match no break after", reDiscover, replaceDiscover, "6011-1111-1111-1234def", "Discover XXXX-XXXX-XXXX-1234def"}, 73 | 74 | {"AMEX happy spaces", reAMEX, replaceAMEX, " abc 3411 123456 12345 def", " abc American Express XXXX-XXXXXX-X2345 def"}, 75 | {"AMEX happy dashes", reAMEX, replaceAMEX, "3711-123456-12345", "American Express XXXX-XXXXXX-X2345"}, 76 | {"AMEX happy mixed", reAMEX, replaceAMEX, "3411-123456 12345", "American Express XXXX-XXXXXX-X2345"}, 77 | {"AMEX happy digits", reAMEX, replaceAMEX, "abc 371112345612345 def", "abc American Express XXXX-XXXXXX-X2345 def"}, 78 | {"AMEX non-match start 41", reAMEX, replaceAMEX, "411112345612345", ""}, 79 | {"AMEX non-match start 31", reAMEX, replaceAMEX, "3111111111111234", ""}, 80 | {"AMEX non-match num digits", reAMEX, replaceAMEX, " 4111-1111-1111-123", ""}, 81 | {"AMEX non-match sep", reAMEX, replaceAMEX, "4111-1111=1111-1234", ""}, 82 | {"AMEX non-match no break before", reAMEX, replaceAMEX, "abc3711-123456-12345", "abcAmerican Express XXXX-XXXXXX-X2345"}, 83 | {"AMEX non-match no break after", reAMEX, replaceAMEX, "3711-123456-12345def", "American Express XXXX-XXXXXX-X2345def"}, 84 | } { 85 | t.Run(tc.Name, func(t *testing.T) { 86 | re := regexp.MustCompile(tc.RE) 87 | result := re.ReplaceAllString(tc.In, tc.Replace) 88 | if tc.Out != "" { 89 | assert.Equal(t, tc.Out, result) 90 | } else { 91 | assert.Equal(t, tc.In, result) 92 | } 93 | }) 94 | } 95 | } 96 | 97 | func TestCreditCard(t *testing.T) { 98 | var tests = []struct { 99 | Name string 100 | Link autolink.Autolink 101 | inputMessage string 102 | expectedMessage string 103 | }{ 104 | { 105 | "VISA happy", 106 | autolink.Autolink{ 107 | Pattern: reVISA, 108 | Template: replaceVISA, 109 | }, 110 | "A credit card 4111-1111-2222-1234 mentioned", 111 | "A credit card VISA XXXX-XXXX-XXXX-1234 mentioned", 112 | }, { 113 | "VISA", 114 | autolink.Autolink{ 115 | Pattern: reVISA, 116 | Template: replaceVISA, 117 | DisableNonWordPrefix: true, 118 | DisableNonWordSuffix: true, 119 | }, 120 | "A credit card4111-1111-2222-3333mentioned", 121 | "A credit cardVISA XXXX-XXXX-XXXX-3333mentioned", 122 | }, { 123 | "Multiple VISA replacements", 124 | autolink.Autolink{ 125 | Pattern: reVISA, 126 | Template: replaceVISA, 127 | }, 128 | "Credit cards 4111-1111-2222-3333 4222-3333-4444-5678 mentioned", 129 | "Credit cards VISA XXXX-XXXX-XXXX-3333 VISA XXXX-XXXX-XXXX-5678 mentioned", 130 | }, 131 | } 132 | 133 | for _, tt := range tests { 134 | t.Run(tt.Name, func(t *testing.T) { 135 | err := tt.Link.Compile() 136 | actual := tt.Link.Replace(tt.inputMessage) 137 | 138 | assert.Equal(t, tt.expectedMessage, actual) 139 | assert.NoError(t, err) 140 | }) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /server/autolink/lib_jira_test.go: -------------------------------------------------------------------------------- 1 | package autolink_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 7 | ) 8 | 9 | var jiraTests = []linkTest{ 10 | { 11 | "Jira example", 12 | autolink.Autolink{ 13 | Pattern: "(MM)(-)(?P\\d+)", 14 | Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 15 | }, 16 | "Welcome MM-12345 should link!", 17 | "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", 18 | }, { 19 | "Jira example 2 (within a ())", 20 | autolink.Autolink{ 21 | Pattern: "(MM)(-)(?P\\d+)", 22 | Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 23 | }, 24 | "Link in brackets should link (see MM-12345)", 25 | "Link in brackets should link (see [MM-12345](https://mattermost.atlassian.net/browse/MM-12345))", 26 | }, { 27 | "Jira example 3 (before ,)", 28 | autolink.Autolink{ 29 | Pattern: "(MM)(-)(?P\\d+)", 30 | Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 31 | }, 32 | "Link a ticket MM-12345, before a comma", 33 | "Link a ticket [MM-12345](https://mattermost.atlassian.net/browse/MM-12345), before a comma", 34 | }, { 35 | "Jira example 3 (at begin of the message)", 36 | autolink.Autolink{ 37 | Pattern: "(MM)(-)(?P\\d+)", 38 | Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 39 | }, 40 | "MM-12345 should link!", 41 | "[MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", 42 | }, { 43 | "Pattern word prefix and suffix disabled", 44 | autolink.Autolink{ 45 | Pattern: "(?P^|\\s)(MM)(-)(?P\\d+)", 46 | Template: "${previous}[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 47 | DisableNonWordPrefix: true, 48 | DisableNonWordSuffix: true, 49 | }, 50 | "Welcome MM-12345 should link!", 51 | "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", 52 | }, { 53 | "Pattern word prefix and suffix disabled (at begin of the message)", 54 | autolink.Autolink{ 55 | Pattern: "(?P^|\\s)(MM)(-)(?P\\d+)", 56 | Template: "${previous}[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 57 | DisableNonWordPrefix: true, 58 | DisableNonWordSuffix: true, 59 | }, 60 | "MM-12345 should link!", 61 | "[MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", 62 | }, { 63 | "Pattern word prefix and suffix enable (in the middle of other text)", 64 | autolink.Autolink{ 65 | Pattern: "(MM)(-)(?P\\d+)", 66 | Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 67 | }, 68 | "WelcomeMM-12345should not link!", 69 | "WelcomeMM-12345should not link!", 70 | }, { 71 | "Pattern word prefix and suffix disabled (in the middle of other text)", 72 | autolink.Autolink{ 73 | Pattern: "(MM)(-)(?P\\d+)", 74 | Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 75 | DisableNonWordPrefix: true, 76 | DisableNonWordSuffix: true, 77 | }, 78 | "WelcomeMM-12345should link!", 79 | "Welcome[MM-12345](https://mattermost.atlassian.net/browse/MM-12345)should link!", 80 | }, { 81 | "Not relinking", 82 | autolink.Autolink{ 83 | Pattern: "(?P\\w+)(-)(?P\\d+)", 84 | Template: "[${project_id}-${jira_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id})", 85 | }, 86 | "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should not re-link!", 87 | "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should not re-link!", 88 | }, { 89 | "Url replacement", 90 | autolink.Autolink{ 91 | Pattern: "(https://mattermost.atlassian.net/browse/)(?P\\w+)(-)(?P\\d+)", 92 | Template: "[${project_id}-${jira_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id})", 93 | }, 94 | "Welcome https://mattermost.atlassian.net/browse/MM-12345 should link!", 95 | "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", 96 | }, { 97 | "Url replacement multiple times", 98 | autolink.Autolink{ 99 | Pattern: "(https://mattermost.atlassian.net/browse/)(?P\\w+)(-)(?P\\d+)", 100 | Template: "[${project_id}-${jira_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id})", 101 | }, 102 | "Welcome https://mattermost.atlassian.net/browse/MM-12345. should link https://mattermost.atlassian.net/browse/MM-12346 !", 103 | "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345). should link [MM-12346](https://mattermost.atlassian.net/browse/MM-12346) !", 104 | }, { 105 | "Url replacement multiple times and at beginning", 106 | autolink.Autolink{ 107 | Pattern: "(https:\\/\\/mattermost.atlassian.net\\/browse\\/)(?P\\w+)(-)(?P\\d+)", 108 | Template: "[${project_id}-${jira_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id})", 109 | }, 110 | "https://mattermost.atlassian.net/browse/MM-12345 https://mattermost.atlassian.net/browse/MM-12345", 111 | "[MM-12345](https://mattermost.atlassian.net/browse/MM-12345) [MM-12345](https://mattermost.atlassian.net/browse/MM-12345)", 112 | }, { 113 | "Url replacement at end", 114 | autolink.Autolink{ 115 | Pattern: "(https://mattermost.atlassian.net/browse/)(?P\\w+)(-)(?P\\d+)", 116 | Template: "[${project_id}-${jira_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id})", 117 | }, 118 | "Welcome https://mattermost.atlassian.net/browse/MM-12345", 119 | "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345)", 120 | }, { 121 | "Comment url replacement", 122 | autolink.Autolink{ 123 | Pattern: "(https://mattermost.atlassian.net/browse/)(?P\\w+)(-)(?P\\d+)[?](focusedCommentId)(=)(?P\\d+)", 124 | Template: "[${project_id}-${jira_id} With Comment #${comment_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id}?focusedCommentId=${comment_id})", 125 | }, 126 | "Welcome https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210 should link!", 127 | "Welcome [MM-12345 With Comment #10210](https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210) should link!", 128 | }, { 129 | "Comment url replacement multiple times", 130 | autolink.Autolink{ 131 | Pattern: "(https://mattermost.atlassian.net/browse/)(?P\\w+)(-)(?P\\d+)[?](focusedCommentId)(=)(?P\\d+)", 132 | Template: "[${project_id}-${jira_id} With Comment #${comment_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id}?focusedCommentId=${comment_id})", 133 | }, 134 | "Welcome https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210. should link https://mattermost.atlassian.net/browse/MM-12346?focusedCommentId=10210 !", 135 | "Welcome [MM-12345 With Comment #10210](https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210). should link [MM-12346 With Comment #10210](https://mattermost.atlassian.net/browse/MM-12346?focusedCommentId=10210) !", 136 | }, { 137 | "Comment url replacement multiple times and at beginning", 138 | autolink.Autolink{ 139 | Pattern: "(https:\\/\\/mattermost.atlassian.net\\/browse\\/)(?P\\w+)(-)(?P\\d+)[?](focusedCommentId)(=)(?P\\d+)", 140 | Template: "[${project_id}-${jira_id} With Comment #${comment_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id}?focusedCommentId=${comment_id})", 141 | }, 142 | "https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210 https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210", 143 | "[MM-12345 With Comment #10210](https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210) [MM-12345 With Comment #10210](https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210)", 144 | }, { 145 | "Comment url replacement at end", 146 | autolink.Autolink{ 147 | Pattern: "(https://mattermost.atlassian.net/browse/)(?P\\w+)(-)(?P\\d+)[?](focusedCommentId)(=)(?P\\d+)", 148 | Template: "[${project_id}-${jira_id} With Comment #${comment_id}](https://mattermost.atlassian.net/browse/${project_id}-${jira_id}?focusedCommentId=${comment_id})", 149 | }, 150 | "Welcome https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210", 151 | "Welcome [MM-12345 With Comment #10210](https://mattermost.atlassian.net/browse/MM-12345?focusedCommentId=10210)", 152 | }, 153 | } 154 | 155 | func TestJira(t *testing.T) { 156 | testLinks(t, jiraTests...) 157 | } 158 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO ?= $(shell command -v go 2> /dev/null) 2 | NPM ?= $(shell command -v npm 2> /dev/null) 3 | CURL ?= $(shell command -v curl 2> /dev/null) 4 | MM_DEBUG ?= 5 | GOPATH ?= $(shell go env GOPATH) 6 | GO_TEST_FLAGS ?= -race 7 | GO_BUILD_FLAGS ?= 8 | MM_UTILITIES_DIR ?= ../mattermost-utilities 9 | DLV_DEBUG_PORT := 2346 10 | DEFAULT_GOOS := $(shell go env GOOS) 11 | DEFAULT_GOARCH := $(shell go env GOARCH) 12 | 13 | export GO111MODULE=on 14 | 15 | # We need to export GOBIN to allow it to be set 16 | # for processes spawned from the Makefile 17 | export GOBIN ?= $(PWD)/bin 18 | 19 | # You can include assets this directory into the bundle. This can be e.g. used to include profile pictures. 20 | ASSETS_DIR ?= assets 21 | 22 | ## Define the default target (make all) 23 | .PHONY: default 24 | default: all 25 | 26 | # Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed. 27 | include build/setup.mk 28 | 29 | BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz 30 | 31 | # Include custom makefile, if present 32 | ifneq ($(wildcard build/custom.mk),) 33 | include build/custom.mk 34 | endif 35 | 36 | ifneq ($(MM_DEBUG),) 37 | GO_BUILD_GCFLAGS = -gcflags "all=-N -l" 38 | else 39 | GO_BUILD_GCFLAGS = 40 | endif 41 | 42 | ## Checks the code style, tests, builds and bundles the plugin. 43 | .PHONY: all 44 | all: check-style test dist 45 | 46 | ## Propagates plugin manifest information into the server/ and webapp/ folders. 47 | .PHONY: apply 48 | apply: 49 | ./build/bin/manifest apply 50 | 51 | ## Install go tools 52 | install-go-tools: 53 | @echo Installing go tools 54 | $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.1 55 | $(GO) install gotest.tools/gotestsum@v1.7.0 56 | 57 | ## Runs eslint and golangci-lint 58 | .PHONY: check-style 59 | check-style: apply webapp/node_modules install-go-tools 60 | @echo Checking for style guide compliance 61 | 62 | ifneq ($(HAS_WEBAPP),) 63 | cd webapp && npm run lint 64 | cd webapp && npm run check-types 65 | endif 66 | 67 | # It's highly recommended to run go-vet first 68 | # to find potential compile errors that could introduce 69 | # weird reports at golangci-lint step 70 | ifneq ($(HAS_SERVER),) 71 | @echo Running golangci-lint 72 | $(GO) vet ./... 73 | $(GOBIN)/golangci-lint run ./... 74 | endif 75 | 76 | ## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set. 77 | .PHONY: server 78 | server: 79 | ifneq ($(HAS_SERVER),) 80 | ifneq ($(MM_DEBUG),) 81 | $(info DEBUG mode is on; to disable, unset MM_DEBUG) 82 | endif 83 | mkdir -p server/dist; 84 | ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),) 85 | @echo Building plugin only for $(DEFAULT_GOOS)-$(DEFAULT_GOARCH) because MM_SERVICESETTINGS_ENABLEDEVELOPER is enabled 86 | cd server && env CGO_ENABLED=0 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH); 87 | else 88 | cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-linux-amd64; 89 | cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-linux-arm64; 90 | cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-darwin-amd64; 91 | cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-darwin-arm64; 92 | cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-windows-amd64.exe; 93 | endif 94 | endif 95 | 96 | ## Ensures NPM dependencies are installed without having to run this all the time. 97 | webapp/node_modules: $(wildcard webapp/package.json) 98 | ifneq ($(HAS_WEBAPP),) 99 | cd webapp && $(NPM) install 100 | touch $@ 101 | endif 102 | 103 | ## Builds the webapp, if it exists. 104 | .PHONY: webapp 105 | webapp: webapp/node_modules 106 | ifneq ($(HAS_WEBAPP),) 107 | ifeq ($(MM_DEBUG),) 108 | cd webapp && $(NPM) run build; 109 | else 110 | cd webapp && $(NPM) run debug; 111 | endif 112 | endif 113 | 114 | ## Generates a tar bundle of the plugin for install. 115 | .PHONY: bundle 116 | bundle: 117 | rm -rf dist/ 118 | mkdir -p dist/$(PLUGIN_ID) 119 | ./build/bin/manifest dist 120 | ifneq ($(wildcard $(ASSETS_DIR)/.),) 121 | cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/ 122 | endif 123 | ifneq ($(HAS_PUBLIC),) 124 | cp -r public dist/$(PLUGIN_ID)/ 125 | endif 126 | ifneq ($(HAS_SERVER),) 127 | mkdir -p dist/$(PLUGIN_ID)/server 128 | cp -r server/dist dist/$(PLUGIN_ID)/server/ 129 | endif 130 | ifneq ($(HAS_WEBAPP),) 131 | mkdir -p dist/$(PLUGIN_ID)/webapp 132 | cp -r webapp/dist dist/$(PLUGIN_ID)/webapp/ 133 | endif 134 | cd dist && tar -cvzf $(BUNDLE_NAME) $(PLUGIN_ID) 135 | 136 | @echo plugin built at: dist/$(BUNDLE_NAME) 137 | 138 | ## Builds and bundles the plugin. 139 | .PHONY: dist 140 | dist: apply server webapp bundle 141 | 142 | ## Builds and installs the plugin to a server. 143 | .PHONY: deploy 144 | deploy: dist 145 | ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) 146 | 147 | ## Builds and installs the plugin to a server, updating the webapp automatically when changed. 148 | .PHONY: watch 149 | watch: apply server bundle 150 | ifeq ($(MM_DEBUG),) 151 | cd webapp && $(NPM) run build:watch 152 | else 153 | cd webapp && $(NPM) run debug:watch 154 | endif 155 | 156 | ## Installs a previous built plugin with updated webpack assets to a server. 157 | .PHONY: deploy-from-watch 158 | deploy-from-watch: bundle 159 | ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) 160 | 161 | ## Setup dlv for attaching, identifying the plugin PID for other targets. 162 | .PHONY: setup-attach 163 | setup-attach: 164 | $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) 165 | $(eval NUM_PID := $(shell echo -n ${PLUGIN_PID} | wc -w)) 166 | 167 | @if [ ${NUM_PID} -gt 2 ]; then \ 168 | echo "** There is more than 1 plugin process running. Run 'make kill reset' to restart just one."; \ 169 | exit 1; \ 170 | fi 171 | 172 | ## Check if setup-attach succeeded. 173 | .PHONY: check-attach 174 | check-attach: 175 | @if [ -z ${PLUGIN_PID} ]; then \ 176 | echo "Could not find plugin PID; the plugin is not running. Exiting."; \ 177 | exit 1; \ 178 | else \ 179 | echo "Located Plugin running with PID: ${PLUGIN_PID}"; \ 180 | fi 181 | 182 | ## Attach dlv to an existing plugin instance. 183 | .PHONY: attach 184 | attach: setup-attach check-attach 185 | dlv attach ${PLUGIN_PID} 186 | 187 | ## Attach dlv to an existing plugin instance, exposing a headless instance on $DLV_DEBUG_PORT. 188 | .PHONY: attach-headless 189 | attach-headless: setup-attach check-attach 190 | dlv attach ${PLUGIN_PID} --listen :$(DLV_DEBUG_PORT) --headless=true --api-version=2 --accept-multiclient 191 | 192 | ## Detach dlv from an existing plugin instance, if previously attached. 193 | .PHONY: detach 194 | detach: setup-attach 195 | @DELVE_PID=$(shell ps aux | grep "dlv attach ${PLUGIN_PID}" | grep -v "grep" | awk -F " " '{print $$2}') && \ 196 | if [ "$$DELVE_PID" -gt 0 ] > /dev/null 2>&1 ; then \ 197 | echo "Located existing delve process running with PID: $$DELVE_PID. Killing." ; \ 198 | kill -9 $$DELVE_PID ; \ 199 | fi 200 | 201 | ## Runs any lints and unit tests defined for the server and webapp, if they exist. 202 | .PHONY: test 203 | test: apply webapp/node_modules install-go-tools 204 | ifneq ($(HAS_SERVER),) 205 | $(GOBIN)/gotestsum -- -v ./... 206 | endif 207 | ifneq ($(HAS_WEBAPP),) 208 | cd webapp && $(NPM) run test; 209 | endif 210 | 211 | ## Runs any lints and unit tests defined for the server and webapp, if they exist, optimized 212 | ## for a CI environment. 213 | .PHONY: test-ci 214 | test-ci: apply webapp/node_modules install-go-tools 215 | ifneq ($(HAS_SERVER),) 216 | $(GOBIN)/gotestsum --format standard-verbose --junitfile report.xml -- ./... 217 | endif 218 | ifneq ($(HAS_WEBAPP),) 219 | cd webapp && $(NPM) run test; 220 | endif 221 | 222 | ## Creates a coverage report for the server code. 223 | .PHONY: coverage 224 | coverage: apply webapp/node_modules 225 | ifneq ($(HAS_SERVER),) 226 | $(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt ./server/... 227 | $(GO) tool cover -html=server/coverage.txt 228 | endif 229 | 230 | ## Extract strings for translation from the source code. 231 | .PHONY: i18n-extract 232 | i18n-extract: 233 | ifneq ($(HAS_WEBAPP),) 234 | ifeq ($(HAS_MM_UTILITIES),) 235 | @echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command" 236 | else 237 | cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp --webapp-dir $(PWD)/webapp 238 | endif 239 | endif 240 | 241 | ## Disable the plugin. 242 | .PHONY: disable 243 | disable: detach 244 | ./build/bin/pluginctl disable $(PLUGIN_ID) 245 | 246 | ## Enable the plugin. 247 | .PHONY: enable 248 | enable: 249 | ./build/bin/pluginctl enable $(PLUGIN_ID) 250 | 251 | ## Reset the plugin, effectively disabling and re-enabling it on the server. 252 | .PHONY: reset 253 | reset: detach 254 | ./build/bin/pluginctl reset $(PLUGIN_ID) 255 | 256 | ## Kill all instances of the plugin, detaching any existing dlv instance. 257 | .PHONY: kill 258 | kill: detach 259 | $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) 260 | 261 | @for PID in ${PLUGIN_PID}; do \ 262 | echo "Killing plugin pid $$PID"; \ 263 | kill -9 $$PID; \ 264 | done; \ 265 | 266 | ## Clean removes all build artifacts. 267 | .PHONY: clean 268 | clean: 269 | rm -fr dist/ 270 | ifneq ($(HAS_SERVER),) 271 | rm -fr server/coverage.txt 272 | rm -fr server/dist 273 | endif 274 | ifneq ($(HAS_WEBAPP),) 275 | rm -fr webapp/junit.xml 276 | rm -fr webapp/dist 277 | rm -fr webapp/node_modules 278 | endif 279 | rm -fr build/bin/ 280 | 281 | .PHONY: logs 282 | logs: 283 | ./build/bin/pluginctl logs $(PLUGIN_ID) 284 | 285 | .PHONY: logs-watch 286 | logs-watch: 287 | ./build/bin/pluginctl logs-watch $(PLUGIN_ID) 288 | 289 | # Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 290 | help: 291 | @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//" | sed -e "s/^## //" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort 292 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Disclaimer 2 | 3 | **This repository is community supported and not maintained by Mattermost. Mattermost disclaims liability for integrations, including Third Party Integrations and Mattermost Integrations. Integrations may be modified or discontinued at any time.** 4 | 5 | # Autolink Plugin 6 | 7 | [![Build Status](https://img.shields.io/circleci/project/github/mattermost/mattermost-plugin-autolink/master.svg)](https://circleci.com/gh/mattermost/mattermost-plugin-autolink) 8 | [![Code Coverage](https://img.shields.io/codecov/c/github/mattermost/mattermost-plugin-autolink/master.svg)](https://codecov.io/gh/mattermost/mattermost-plugin-autolink) 9 | [![Release](https://img.shields.io/github/v/release/mattermost/mattermost-plugin-autolink)](https://github.com/mattermost-community/mattermost-plugin-autolink/releases/latest) 10 | [![HW](https://img.shields.io/github/issues/mattermost/mattermost-plugin-autolink/Up%20For%20Grabs?color=dark%20green&label=Help%20Wanted)](https://github.com/mattermost-community/mattermost-plugin-autolink/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22Up+For+Grabs%22+label%3A%22Help+Wanted%22) 11 | 12 | **Maintainer:** [@levb](https://github.com/levb) 13 | **Co-Maintainer:** [@iomodo](https://github.com/iomodo) 14 | 15 | This plugin creates regular expression (regexp) patterns that are reformatted into a Markdown link before the message is saved into the database. 16 | 17 | Use it to add custom auto-linking on your Mattermost system, such as adding links to your issue tracker based on the regexp patterns. 18 | 19 | ![image](https://user-images.githubusercontent.com/13119842/58675221-479ad680-8321-11e9-9ad1-b9c42238734f.png) 20 | 21 | *Posting a message containing a Jira issue key..* 22 | 23 | ![image](https://user-images.githubusercontent.com/13119842/58675165-11f5ed80-8321-11e9-9d41-91088a79a11b.png) 24 | 25 | *..automatically links to the corresponding issue in the Jira project* 26 | 27 | ## Configuration 28 | 1. Go to **System Console > Plugins > Plugin Management** and click **Enable** to enable the Autolink plugin. 29 | - If you are running Mattermost v5.11 or earlier, you must first go to the [releases page of this GitHub repository](https://github.com/mattermost-community/mattermost-plugin-autolink), download the latest release, and upload it to your Mattermost instance [following this documentation](https://docs.mattermost.com/administration/plugins.html#plugin-uploads). 30 | 31 | 2. Modify your `config.json` file to include the types of regexp patterns you wish to match, under the `PluginSettings`. See below for an example of what this should look like. 32 | 33 | **Tip**: There are useful Regular Expression tools online to help test and validate that your formulas are working as expected. One such tool is [Regex101](https://regex101.com/) . Here is an example Regular Expression to capture a post that includes a [VISA card number](https://regex101.com/r/JGKCTN/1) - which you could then obfuscate with the `Pattern` so people don't accidentally share sensitive info in your channels. 34 | 35 | 3. Go to **System Console > Plugins > Autolink** to configure the following additional plugin options: 36 | - **Enable administration with /autolink command**: Select **true** to enables administration of the plugin using the ``/autolink`` slash command. Select **false** to disable this functionality. 37 | - **Apply plugin to updated posts as well as new posts**: Select **true** to apply the plugin to updated posts as well as new posts. Select **false** to apply the plugin to new posts only 38 | - **Admin user IDs**: Authorize non-System Admin users to administer the plugin when enabled. Find user IDs by going to **System Console > User Management > Users**. Separate multiple user IDs with commas. 39 | 40 | ## Usage 41 | 42 | Autolinks have 3 parts: a **Pattern** which is a regular expression search pattern utilizing the [Golang regexp library](https://golang.org/pkg/regexp/), a **Template** that gets expanded and an optional **Scope** parameter to define which team/channel the autolink applies to. You can create variables in the pattern with the syntax `(?P...)` which will then be expanded by the corresponding template. 43 | 44 | In the template, a variable is denoted by a substring of the form `$name` or `${name}`, where `name` is a non-empty sequence of letters, digits, and underscores. A purely numeric name like $1 refers to the submatch with the corresponding index. In the $name form, name is taken to be as long as possible: $1x is equivalent to ${1x}, not ${1}x, and, $10 is equivalent to ${10}, not ${1}0. To insert a literal $ in the output, use $$ in the template. 45 | 46 | The scope must be either a team (`teamname`) or a team and a channel (`teamname/channelname`). Remember that you must provide the entity name, not the entity display name. Since Direct Messages do not belong to any team, scoped matches will not be autolinked on Direct Messages. If more than one scope is provided, matches in at least one of the scopes will be autolinked. 47 | 48 | Below is an example of regexp patterns used for autolinking at https://community.mattermost.com, modified in the `config.json` file: 49 | 50 | ```json5 51 | "PluginSettings": { 52 | ... 53 | "Plugins": { 54 | "mattermost-autolink": { 55 | "links": [ 56 | { 57 | "Pattern": "(LHS)", 58 | "Template": "[LHS](https://docs.mattermost.com/process/training.html#lhs)", 59 | "Scope": ["team/off-topic"] 60 | }, 61 | { 62 | "Pattern": "(RHS)", 63 | "Template": "[RHS](https://docs.mattermost.com/process/training.html#rhs)", 64 | "Scope": ["team/town-square"] 65 | }, 66 | { 67 | "Pattern": "(?i)(Mana)", 68 | "Template": "[Mana](https://docs.mattermost.com/process/training.html#mana)" 69 | }, 70 | { 71 | "Pattern": "(?i)(ESR)", 72 | "Template": "[ESR](https://docs.mattermost.com/process/training.html#esr)" 73 | }, 74 | { 75 | "Pattern": "((?P0|1|2|3|4|5)/5)", 76 | "Template": "[${level}/5](https://docs.mattermost.com/process/training.html#id8)" 77 | }, 78 | { 79 | "Pattern": "(MM)(-)(?P\\d+)", 80 | "Template": "[MM-${jira_id}](https://mattermost.atlassian.net/browse/MM-${jira_id})" 81 | }, 82 | { 83 | "Pattern": "https://pre-release\\.mattermost\\.com/core/pl/(?P[a-zA-Z0-9]+)", 84 | "Template": "[](https://pre-release.mattermost.com/core/pl/${id})" 85 | }, 86 | { 87 | "Pattern": "(https://mattermost\\.atlassian\\.net/browse/)(MM)(-)(?P\\d+)", 88 | "Template": "[MM-${jira_id}](https://mattermost.atlassian.net/browse/MM-${jira_id})" 89 | }, 90 | { 91 | "Pattern": "https://github\\.com/mattermost/(?P.+)/pull/(?P\\d+)", 92 | "Template": "[pr-${repo}-${id}](https://github.com/mattermost/${repo}/pull/${id})" 93 | }, 94 | { 95 | "Pattern": "https://github\\.com/mattermost/(?P.+)/issues/(?P\\d+)", 96 | "Template": "[issue-${repo}-${id}](https://github.com/mattermost/${repo}/issues/${id})" 97 | }, 98 | { 99 | "Pattern": "(PLT)(-)(?P\\d+)", 100 | "Template": "[PLT-${jira_id}](https://mattermost.atlassian.net/browse/PLT-${jira_id})" 101 | }, 102 | { 103 | "Pattern": "(https://mattermost\\.atlassian\\.net/browse/)(PLT)(-)(?P\\d+)", 104 | "Template": "[PLT-${jira_id}](https://mattermost.atlassian.net/browse/PLT-${jira_id})" 105 | } 106 | ] 107 | }, 108 | }, 109 | ... 110 | "PluginStates": { 111 | ... 112 | "mattermost-autolink": { 113 | "Enable": true 114 | }, 115 | ... 116 | } 117 | }, 118 | ``` 119 | 120 | ## Examples 121 | 122 | 1. Autolinking `Ticket ####:text with alphanumberic characters and spaces` to a ticket link. Use: 123 | - Pattern: `(?i)(ticket )(?P.+)(:)(?P.*)`, or if the ticket_id is a number, then `(?i)(ticket )(?P\d+)(:)(?P.*)` 124 | - Template: `[Ticket ${ticket_id}: ${ticket_info}](https://github.com/mattermost/mattermost-server/issues/${ticket_id})` 125 | - Scope: `["teams/committers"]` (optional) 126 | 127 | 2. Autolinking a link to a GitHub PR to a format "pr-repo-id". Use: 128 | - Pattern: `https://github\\.com/mattermost/(?P.+)/pull/(?P\\d+)` 129 | - Template: `[pr-${repo}-${id}](https://github.com/mattermost/${repo}/pull/${id})` 130 | 131 | 3. Using autolinking to create group mentions. Use (note that clicking the resulting at-mention redirects to a broken page): 132 | - Pattern: `@customgroup*` 133 | - Template: `[@customgroup]( \\* @user1 @user2 @user3 \\* )` 134 | 135 | 4. For servers with multiple domains (like community and community-daily on the [public Mattermost Server](https://community.mattermost)), a substitution of absolute conversation links to relative links is recommended to prevent issues in the mobile app. Add one pattern for each domain used: 136 | - Pattern: `https://community\\.mattermost\\.com/(?P\u003cteamname\u003e(?a-zA-Z0-9]+)/(?P\u003cid\u003e[a-zA-Z0-9]+)` 137 | - Template: `[](/${teamname}/pl/${id})/${id})` 138 | 139 | Autolink the word Handbook to a internal URL on the private team (called `office`), and a private channel (`staff`) in the public team (called `everyone`). 140 | - Pattern: `(Handbook)` 141 | - Template: `[Handbook](http://www.mywebsite.com/private/handbook)` 142 | - Scope: `["office", "everyone/staff"]` 143 | 144 | **You can check your pattern with those Regex Testers:** 145 | - https://regex-golang.appspot.com/, 146 | - https://regex101.com/, 147 | - https://www.regextester.com/. 148 | 149 | ## Configuration Management 150 | The `/autolink` commands allow the users to easily edit the configurations. 151 | 152 | Commands | Description | Usage 153 | ---|---|---| 154 | list | Lists all configured links | `/autolink list` 155 | list \<*linkref*> | List a specific link which matched the link reference | `/autolink list test` 156 | test \<*linkref*> test-text | Test a link on the text provided | `/autolink test Visa 4356-7891-2345-1111 -- (4111222233334444)` 157 | enable \<*linkref*> | Enables the link | `/autolink enable Visa` 158 | disable \<*linkref*> | Disable the link | `/autolink disable Visa` 159 | add \<*linkref*> | Creates a new link with the name specified in the command | `/autolink add Visa` 160 | delete \<*linkref*> | Delete the link | `/autolink delete Visa` 161 | set \<*linkref*> \<*field*> *value* | Sets a link's field to a value
*Fields* -
  • Template - Sets the Template field
  • Pattern - Sets the Pattern field
  • WordMatch - If true uses the [\b word boundaries](https://www.regular-expressions.info/wordboundaries.html)
  • ProcessBotPosts - If true applies changes to posts made by bot accounts.
  • Scope - Sets the Scope field (`team` or `team/channel` or a whitespace-separated list thereof)
  • |
    `/autolink set Visa Pattern (?P(?P4\d{3})[ -]?(?P\d{4})[ -]?(?P\d{4})[ -]?(?P[0-9]{4}))`

    `/autolink set Visa Template VISA XXXX-XXXX-XXXX-$LastFour`

    `/autolink set Visa WordMatch true`

    `/autolink set Visa ProcessBotPosts true`

    `/autolink set Visa Scope team/townsquare`

    162 | 163 | 164 | ## Development 165 | 166 | This plugin contains a server portion. Read our documentation about the [Developer Workflow](https://developers.mattermost.com/integrate/plugins/developer-workflow/) and [Developer Setup](https://developers.mattermost.com/integrate/plugins/developer-setup/) for more information about developing and extending plugins. 167 | 168 | ### Releasing new versions 169 | 170 | The version of a plugin is determined at compile time, automatically populating a `version` field in the [plugin manifest](plugin.json): 171 | * If the current commit matches a tag, the version will match after stripping any leading `v`, e.g. `1.3.1`. 172 | * Otherwise, the version will combine the nearest tag with `git rev-parse --short HEAD`, e.g. `1.3.1+d06e53e1`. 173 | * If there is no version tag, an empty version will be combined with the short hash, e.g. `0.0.0+76081421`. 174 | 175 | To disable this behaviour, manually populate and maintain the `version` field. 176 | -------------------------------------------------------------------------------- /server/autolink/autolink_test.go: -------------------------------------------------------------------------------- 1 | package autolink_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/mattermost/mattermost/server/public/model" 9 | "github.com/mattermost/mattermost/server/public/plugin/plugintest" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/mock" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 15 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolinkplugin" 16 | ) 17 | 18 | func setupTestPlugin(t *testing.T, l autolink.Autolink) *autolinkplugin.Plugin { 19 | p := autolinkplugin.New() 20 | api := &plugintest.API{} 21 | 22 | api.On("GetChannel", mock.AnythingOfType("string")).Run(func(args mock.Arguments) { 23 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&model.Team{}, (*model.AppError)(nil)) 24 | }).Return(&model.Channel{ 25 | Id: "thechannel_id0123456789012", 26 | TeamId: "theteam_id0123456789012345", 27 | Name: "thechannel_name", 28 | }, (*model.AppError)(nil)) 29 | 30 | testUser := model.User{ 31 | IsBot: false, 32 | } 33 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil) 34 | 35 | p.SetAPI(api) 36 | 37 | err := l.Compile() 38 | require.Nil(t, err) 39 | p.UpdateConfig(func(conf *autolinkplugin.Config) { 40 | conf.Links = []autolink.Autolink{l} 41 | }) 42 | return p 43 | } 44 | 45 | type linkTest struct { 46 | Name string 47 | Link autolink.Autolink 48 | Message string 49 | ExpectedMessage string 50 | } 51 | 52 | var commonLinkTests = []linkTest{ 53 | { 54 | "Simple pattern", 55 | autolink.Autolink{ 56 | Pattern: "(Mattermost)", 57 | Template: "[Mattermost](https://mattermost.com)", 58 | }, 59 | "Welcome to Mattermost!", 60 | "Welcome to [Mattermost](https://mattermost.com)!", 61 | }, { 62 | "Pattern with variable name accessed using $variable", 63 | autolink.Autolink{ 64 | Pattern: "(?PMattermost)", 65 | Template: "[$key](https://mattermost.com)", 66 | }, 67 | "Welcome to Mattermost!", 68 | "Welcome to [Mattermost](https://mattermost.com)!", 69 | }, { 70 | "Multiple replacments", 71 | autolink.Autolink{ 72 | Pattern: "(?PMattermost)", 73 | Template: "[$key](https://mattermost.com)", 74 | }, 75 | "Welcome to Mattermost and have fun with Mattermost!", 76 | "Welcome to [Mattermost](https://mattermost.com) and have fun with [Mattermost](https://mattermost.com)!", 77 | }, { 78 | "Pattern with variable name accessed using ${variable}", 79 | autolink.Autolink{ 80 | Pattern: "(?PMattermost)", 81 | Template: "[${key}](https://mattermost.com)", 82 | }, 83 | "Welcome to Mattermost!", 84 | "Welcome to [Mattermost](https://mattermost.com)!", 85 | }, 86 | } 87 | 88 | func testLinks(t *testing.T, tcs ...linkTest) { 89 | for _, tc := range tcs { 90 | t.Run(tc.Name, func(t *testing.T) { 91 | p := setupTestPlugin(t, tc.Link) 92 | post, _ := p.MessageWillBePosted(nil, &model.Post{ 93 | Message: tc.Message, 94 | }) 95 | 96 | assert.Equal(t, tc.ExpectedMessage, post.Message) 97 | }) 98 | } 99 | } 100 | 101 | func TestCommonLinks(t *testing.T) { 102 | testLinks(t, commonLinkTests...) 103 | } 104 | 105 | func TestLegacyWordBoundaries(t *testing.T) { 106 | const pattern = "(KEY)(-)(?P\\d+)" 107 | const template = "[KEY-$ID](someurl/KEY-$ID)" 108 | const ref = "KEY-12345" 109 | const markdown = "[KEY-12345](someurl/KEY-12345)" 110 | 111 | var defaultLink = autolink.Autolink{ 112 | Pattern: pattern, 113 | Template: template, 114 | } 115 | 116 | linkNoPrefix := defaultLink 117 | linkNoPrefix.DisableNonWordPrefix = true 118 | 119 | linkNoSuffix := defaultLink 120 | linkNoSuffix.DisableNonWordSuffix = true 121 | 122 | linkNoPrefixNoSuffix := defaultLink 123 | linkNoPrefixNoSuffix.DisableNonWordSuffix = true 124 | linkNoPrefixNoSuffix.DisableNonWordPrefix = true 125 | 126 | for _, tc := range []struct { 127 | Name string 128 | Sep string 129 | Link autolink.Autolink 130 | Prefix string 131 | Suffix string 132 | ExpectFail bool 133 | }{ 134 | {Name: "space both sides both breaks required", Prefix: " ", Suffix: " "}, 135 | {Name: "space both sides left break not required", Prefix: " ", Suffix: " ", Link: linkNoPrefix}, 136 | {Name: "space both sides right break not required", Prefix: " ", Suffix: " ", Link: linkNoSuffix}, 137 | {Name: "space both sides neither break required", Prefix: " ", Suffix: " ", Link: linkNoPrefixNoSuffix}, 138 | 139 | {Name: "space left side both breaks required", Prefix: " ", ExpectFail: true}, 140 | {Name: "space left side left break not required", Prefix: " ", Link: linkNoPrefix, ExpectFail: true}, 141 | {Name: "space left side right break not required", Prefix: " ", Link: linkNoSuffix}, 142 | {Name: "space left side neither break required", Prefix: " ", Link: linkNoPrefixNoSuffix}, 143 | 144 | {Name: "space right side both breaks required", Suffix: " ", ExpectFail: true}, 145 | {Name: "space right side left break not required", Suffix: " ", Link: linkNoPrefix}, 146 | {Name: "space right side right break not required", Suffix: " ", Link: linkNoSuffix, ExpectFail: true}, 147 | {Name: "space right side neither break required", Prefix: " ", Link: linkNoPrefixNoSuffix}, 148 | 149 | {Name: "none both breaks required", ExpectFail: true}, 150 | {Name: "none left break not required", Link: linkNoPrefix, ExpectFail: true}, 151 | {Name: "none right break not required", Link: linkNoSuffix, ExpectFail: true}, 152 | {Name: "none neither break required", Link: linkNoPrefixNoSuffix}, 153 | 154 | // '(', '[' are not start separators 155 | {Sep: "paren", Name: "2 parens", Prefix: "(", Suffix: ")", ExpectFail: true}, 156 | {Sep: "paren", Name: "2 parens no suffix", Prefix: "(", Suffix: ")", Link: linkNoSuffix, ExpectFail: true}, 157 | {Sep: "paren", Name: "left paren", Prefix: "(", Link: linkNoSuffix, ExpectFail: true}, 158 | {Sep: "sbracket", Name: "2 brackets", Prefix: "[", Suffix: "]", ExpectFail: true}, 159 | {Sep: "lsbracket", Name: "bracket no prefix", Prefix: "[", Link: linkNoPrefix, ExpectFail: true}, 160 | {Sep: "lsbracket", Name: "both breaks", Prefix: "[", ExpectFail: true}, 161 | {Sep: "lsbracket", Name: "bracket no suffix", Prefix: "[", Link: linkNoSuffix, ExpectFail: true}, 162 | 163 | // ']' is not a finish separator 164 | {Sep: "rsbracket", Name: "bracket", Suffix: "]", Link: linkNoPrefix, ExpectFail: true}, 165 | 166 | {Sep: "paren", Name: "2 parens no prefix", Prefix: "(", Suffix: ")", Link: linkNoPrefix}, 167 | {Sep: "paren", Name: "right paren", Suffix: ")", Link: linkNoPrefix}, 168 | {Sep: "lsbracket", Name: "bracket neither prefix suffix", Prefix: "[", Link: linkNoPrefixNoSuffix}, 169 | {Sep: "rand", Name: "random separators", Prefix: "%() ", Suffix: "?! $%^&"}, 170 | } { 171 | orig := fmt.Sprintf("word1%s%s%sword2", tc.Prefix, ref, tc.Suffix) 172 | expected := fmt.Sprintf("word1%s%s%sword2", tc.Prefix, markdown, tc.Suffix) 173 | 174 | pref := tc.Prefix 175 | suff := tc.Suffix 176 | if tc.Sep != "" { 177 | pref = "_" + tc.Sep + "_" 178 | suff = "_" + tc.Sep + "_" 179 | } 180 | name := fmt.Sprintf("word1%s%s%sword2", pref, ref, suff) 181 | if tc.Name != "" { 182 | name = tc.Name + " " + name 183 | } 184 | 185 | t.Run(name, func(t *testing.T) { 186 | l := tc.Link 187 | if l.Pattern == "" { 188 | l = defaultLink 189 | } 190 | p := setupTestPlugin(t, l) 191 | 192 | post, _ := p.MessageWillBePosted(nil, &model.Post{ 193 | Message: orig, 194 | }) 195 | if tc.ExpectFail { 196 | assert.Equal(t, orig, post.Message) 197 | return 198 | } 199 | assert.Equal(t, expected, post.Message) 200 | }) 201 | } 202 | } 203 | 204 | func TestWordMatch(t *testing.T) { 205 | const pattern = "(KEY)(-)(?P\\d+)" 206 | const template = "[KEY-$ID](someurl/KEY-$ID)" 207 | const ref = "KEY-12345" 208 | const markdown = "[KEY-12345](someurl/KEY-12345)" 209 | 210 | var defaultLink = autolink.Autolink{ 211 | Pattern: pattern, 212 | Template: template, 213 | WordMatch: true, 214 | } 215 | 216 | linkNoPrefix := defaultLink 217 | linkNoPrefix.DisableNonWordPrefix = true 218 | 219 | linkNoSuffix := defaultLink 220 | linkNoSuffix.DisableNonWordSuffix = true 221 | 222 | linkNoPrefixNoSuffix := defaultLink 223 | linkNoPrefixNoSuffix.DisableNonWordSuffix = true 224 | linkNoPrefixNoSuffix.DisableNonWordPrefix = true 225 | 226 | for _, tc := range []struct { 227 | Name string 228 | Sep string 229 | Link autolink.Autolink 230 | Prefix string 231 | Suffix string 232 | ExpectFail bool 233 | }{ 234 | {Name: "space both sides both breaks required", Prefix: " ", Suffix: " "}, 235 | {Name: "space both sides left break not required", Prefix: " ", Suffix: " ", Link: linkNoPrefix}, 236 | {Name: "space both sides right break not required", Prefix: " ", Suffix: " ", Link: linkNoSuffix}, 237 | {Name: "space both sides neither break required", Prefix: " ", Suffix: " ", Link: linkNoPrefixNoSuffix}, 238 | 239 | {Name: "space left side both breaks required", Prefix: " ", ExpectFail: true}, 240 | {Name: "space left side left break not required", Prefix: " ", Link: linkNoPrefix, ExpectFail: true}, 241 | {Name: "space left side right break not required", Prefix: " ", Link: linkNoSuffix}, 242 | {Name: "space left side neither break required", Prefix: " ", Link: linkNoPrefixNoSuffix}, 243 | 244 | {Name: "space right side both breaks required", Suffix: " ", ExpectFail: true}, 245 | {Name: "space right side left break not required", Suffix: " ", Link: linkNoPrefix}, 246 | {Name: "space right side right break not required", Suffix: " ", Link: linkNoSuffix, ExpectFail: true}, 247 | {Name: "space right side neither break required", Prefix: " ", Link: linkNoPrefixNoSuffix}, 248 | 249 | {Name: "none both breaks required", ExpectFail: true}, 250 | {Name: "none left break not required", Link: linkNoPrefix, ExpectFail: true}, 251 | {Name: "none right break not required", Link: linkNoSuffix, ExpectFail: true}, 252 | {Name: "none neither break required", Link: linkNoPrefixNoSuffix}, 253 | 254 | {Sep: "paren", Name: "2 parens", Prefix: "(", Suffix: ")"}, 255 | {Sep: "paren", Name: "left paren", Prefix: "(", Link: linkNoSuffix}, 256 | {Sep: "paren", Name: "right paren", Suffix: ")", Link: linkNoPrefix}, 257 | {Sep: "sbracket", Name: "2 brackets", Prefix: "[", Suffix: "]"}, 258 | {Sep: "lsbracket", Name: "both breaks", Prefix: "[", ExpectFail: true}, 259 | {Sep: "lsbracket", Name: "bracket no prefix", Prefix: "[", Link: linkNoPrefix, ExpectFail: true}, 260 | {Sep: "lsbracket", Name: "bracket no suffix", Prefix: "[", Link: linkNoSuffix}, 261 | {Sep: "lsbracket", Name: "bracket neither prefix suffix", Prefix: "[", Link: linkNoPrefixNoSuffix}, 262 | {Sep: "rsbracket", Name: "bracket", Suffix: "]", Link: linkNoPrefix}, 263 | {Sep: "rand", Name: "random separators", Prefix: "% (", Suffix: "-- $%^&"}, 264 | } { 265 | orig := fmt.Sprintf("word1%s%s%sword2", tc.Prefix, ref, tc.Suffix) 266 | expected := fmt.Sprintf("word1%s%s%sword2", tc.Prefix, markdown, tc.Suffix) 267 | 268 | pref := tc.Prefix 269 | suff := tc.Suffix 270 | if tc.Sep != "" { 271 | pref = "_" + tc.Sep + "_" 272 | suff = "_" + tc.Sep + "_" 273 | } 274 | name := fmt.Sprintf("word1%s%s%sword2", pref, ref, suff) 275 | if tc.Name != "" { 276 | name = tc.Name + " " + name 277 | } 278 | 279 | t.Run(name, func(t *testing.T) { 280 | l := tc.Link 281 | if l.Pattern == "" { 282 | l = defaultLink 283 | } 284 | p := setupTestPlugin(t, l) 285 | 286 | post, _ := p.MessageWillBePosted(nil, &model.Post{ 287 | Message: orig, 288 | }) 289 | if tc.ExpectFail { 290 | assert.Equal(t, orig, post.Message) 291 | return 292 | } 293 | assert.Equal(t, expected, post.Message) 294 | }) 295 | } 296 | } 297 | func TestEquals(t *testing.T) { 298 | for _, tc := range []struct { 299 | l1, l2 autolink.Autolink 300 | expectEqual bool 301 | }{ 302 | { 303 | l1: autolink.Autolink{ 304 | Name: "test", 305 | }, 306 | expectEqual: false, 307 | }, 308 | { 309 | l1: autolink.Autolink{ 310 | Name: "test", 311 | }, 312 | l2: autolink.Autolink{ 313 | Name: "test", 314 | }, 315 | expectEqual: true, 316 | }, 317 | } { 318 | t.Run(tc.l1.Name+"-"+tc.l2.Name, func(t *testing.T) { 319 | eq := tc.l1.Equals(tc.l2) 320 | assert.Equal(t, tc.expectEqual, eq) 321 | }) 322 | } 323 | } 324 | 325 | func TestWildcard(t *testing.T) { 326 | for _, tc := range []struct { 327 | Name string 328 | Link autolink.Autolink 329 | }{ 330 | { 331 | Name: ".*", 332 | Link: autolink.Autolink{ 333 | Pattern: ".*", 334 | Template: "My template", 335 | }, 336 | }, 337 | { 338 | Name: ".*.", 339 | Link: autolink.Autolink{ 340 | Pattern: ".*.", 341 | Template: "My template", 342 | }, 343 | }, 344 | } { 345 | p := setupTestPlugin(t, tc.Link) 346 | 347 | message := "Your message" 348 | 349 | var post *model.Post 350 | done := make(chan bool) 351 | go func() { 352 | post, _ = p.MessageWillBePosted(nil, &model.Post{ 353 | Message: message, 354 | }) 355 | 356 | done <- true 357 | }() 358 | 359 | select { 360 | case <-done: 361 | case <-time.After(50 * time.Millisecond): 362 | panic("wildcard regex timed out") 363 | } 364 | 365 | assert.NotNil(t, post, "post is nil") 366 | assert.Equal(t, "My template", post.Message) 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /server/autolinkplugin/command.go: -------------------------------------------------------------------------------- 1 | package autolinkplugin 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/mattermost/mattermost/server/public/model" 9 | "github.com/mattermost/mattermost/server/public/plugin" 10 | "github.com/pkg/errors" 11 | 12 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 13 | ) 14 | 15 | const ( 16 | autolinkCommand = "/autolink" 17 | optName = "Name" 18 | optTemplate = "Template" 19 | optPattern = "Pattern" 20 | optScope = "Scope" 21 | optDisabled = "Disabled" 22 | optProcessBotPosts = "ProcessBotPosts" 23 | optDisableNonWordPrefix = "DisableNonWordPrefix" 24 | optDisableNonWordSuffix = "DisableNonWordSuffix" 25 | optWordMatch = "WordMatch" 26 | ) 27 | 28 | const helpText = "###### Mattermost Autolink Plugin Administration\n" + 29 | " is either the Name of a link, or its number in the `/autolink list` output. A partial Name can be specified, but some commands require it to be uniquely resolved.\n" + 30 | "* `/autolink add ` - add a new link, named .\n" + 31 | "* `/autolink delete ` - delete a link.\n" + 32 | "* `/autolink disable ` - disable a link.\n" + 33 | "* `/autolink enable ` - enable a link.\n" + 34 | "* `/autolink list ` - list a specific link.\n" + 35 | "* `/autolink list value` - list links whose contains value. Here can be Template or Pattern\n" + 36 | "* `/autolink list` - list all configured links.\n" + 37 | "* `/autolink set value...` - sets a link's field to a value. The entire command line after is used for the value, unescaped, leading/trailing whitespace trimmed.\n" + 38 | "* `/autolink test test-text...` - test a link on a sample.\n" + 39 | "\n" + 40 | "Example:\n" + 41 | "```\n" + 42 | "/autolink add Visa\n" + 43 | "/autolink disable Visa\n" + 44 | `/autolink set Visa Pattern (?P(?P4\d{3})[ -]?(?P\d{4})[ -]?(?P\d{4})[ -]?(?P[0-9]{4}))` + "\n" + 45 | "/autolink set Visa Template VISA XXXX-XXXX-XXXX-$LastFour\n" + 46 | "/autolink set Visa WordMatch true\n" + 47 | "/autolink set Visa Scope team/townsquare\n" + 48 | "/autolink set Visa ProcessBotPosts true\n" + 49 | "/autolink test Visa 4356-7891-2345-1111 -- (4111222233334444)\n" + 50 | "/autolink enable Visa\n" + 51 | "```\n" + 52 | "" 53 | 54 | type CommandHandlerFunc func(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse 55 | 56 | type CommandHandler struct { 57 | handlers map[string]CommandHandlerFunc 58 | defaultHandler CommandHandlerFunc 59 | } 60 | 61 | var autolinkCommandHandler = CommandHandler{ 62 | handlers: map[string]CommandHandlerFunc{ 63 | "help": executeHelp, 64 | "list": executeList, 65 | "delete": executeDelete, 66 | "disable": executeDisable, 67 | "enable": executeEnable, 68 | "add": executeAdd, 69 | "set": executeSet, 70 | "test": executeTest, 71 | }, 72 | defaultHandler: executeHelp, 73 | } 74 | 75 | func (ch CommandHandler) Handle(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { 76 | for n := len(args); n > 0; n-- { 77 | h := ch.handlers[strings.Join(args[:n], "/")] 78 | if h != nil { 79 | return h(p, c, header, args[n:]...) 80 | } 81 | } 82 | return ch.defaultHandler(p, c, header, args...) 83 | } 84 | 85 | func (p *Plugin) ExecuteCommand(c *plugin.Context, commandArgs *model.CommandArgs) (*model.CommandResponse, *model.AppError) { 86 | isAdmin, err := p.IsAuthorizedAdmin(commandArgs.UserId) 87 | if err != nil { 88 | return responsef("error occurred while authorizing the command: %v", err), nil 89 | } 90 | if !isAdmin { 91 | return responsef("`/autolink` commands can only be executed by a system administrator or `autolink` plugin admins."), nil 92 | } 93 | 94 | args := strings.Fields(commandArgs.Command) 95 | if len(args) == 0 || args[0] != autolinkCommand { 96 | return responsef(helpText), nil 97 | } 98 | 99 | return autolinkCommandHandler.Handle(p, c, commandArgs, args[1:]...), nil 100 | } 101 | 102 | func executeList(p *Plugin, _ *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { 103 | var links []autolink.Autolink 104 | var refs []int 105 | var err error 106 | 107 | if len(args) > 0 && (args[0] == optTemplate || args[0] == optPattern) { 108 | links, refs, err = searchLinkRefByTemplateOrPattern(p, header, args...) 109 | } else { 110 | links, refs, err = searchLinkRef(p, false, args...) 111 | } 112 | if err != nil { 113 | return responsef("%v", err) 114 | } 115 | 116 | text := "" 117 | if len(refs) > 0 { 118 | for _, i := range refs { 119 | text += links[i].ToMarkdown(i + 1) 120 | } 121 | } else { 122 | if len(links) == 0 { 123 | text += "There are no links to list" 124 | } 125 | for i, l := range links { 126 | text += l.ToMarkdown(i + 1) 127 | } 128 | } 129 | return responsef(text) 130 | } 131 | 132 | func executeDelete(p *Plugin, _ *plugin.Context, _ *model.CommandArgs, args ...string) *model.CommandResponse { 133 | if len(args) != 1 { 134 | return responsef(helpText) 135 | } 136 | oldLinks, refs, err := searchLinkRef(p, true, args...) 137 | if err != nil { 138 | return responsef("%v", err) 139 | } 140 | n := refs[0] 141 | 142 | removed := oldLinks[n] 143 | newLinks := oldLinks[:n] 144 | if n+1 < len(oldLinks) { 145 | newLinks = append(newLinks, oldLinks[n+1:]...) 146 | } 147 | 148 | err = saveConfigLinks(p, newLinks) 149 | if err != nil { 150 | return responsef(err.Error()) 151 | } 152 | 153 | return responsef("removed: \n%v", removed.ToMarkdown(0)) 154 | } 155 | 156 | func executeSet(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { 157 | if len(args) < 3 { 158 | return responsef(helpText) 159 | } 160 | 161 | links, refs, err := searchLinkRef(p, true, args...) 162 | if err != nil { 163 | return responsef("%v", err) 164 | } 165 | l := &links[refs[0]] 166 | 167 | fieldName := args[1] 168 | restOfCommand := header.Command[len(autolinkCommand):] // "/autolink " 169 | restOfCommand = restOfCommand[strings.Index(restOfCommand, args[0])+len(args[0]):] 170 | restOfCommand = restOfCommand[strings.Index(restOfCommand, args[1])+len(args[1]):] 171 | value := strings.TrimSpace(restOfCommand) 172 | 173 | switch fieldName { 174 | case optName: 175 | l.Name = value 176 | case optPattern: 177 | l.Pattern = value 178 | case optTemplate: 179 | l.Template = value 180 | case optScope: 181 | l.Scope = args[2:] 182 | case optDisableNonWordPrefix: 183 | boolValue, e := parseBoolArg(value) 184 | if e != nil { 185 | return responsef("%v", e) 186 | } 187 | l.DisableNonWordPrefix = boolValue 188 | case optDisableNonWordSuffix: 189 | boolValue, e := parseBoolArg(value) 190 | if e != nil { 191 | return responsef("%v", e) 192 | } 193 | l.DisableNonWordSuffix = boolValue 194 | case optWordMatch: 195 | boolValue, e := parseBoolArg(value) 196 | if e != nil { 197 | return responsef("%v", e) 198 | } 199 | l.WordMatch = boolValue 200 | case optDisabled: 201 | boolValue, e := parseBoolArg(value) 202 | if e != nil { 203 | return responsef("%v", e) 204 | } 205 | l.Disabled = boolValue 206 | case optProcessBotPosts: 207 | boolValue, e := parseBoolArg(value) 208 | if e != nil { 209 | return responsef("%v", e) 210 | } 211 | l.ProcessBotPosts = boolValue 212 | default: 213 | return responsef("%q is not a supported field, must be one of %q", fieldName, 214 | []string{optName, optDisabled, optPattern, optTemplate, optScope, optDisableNonWordPrefix, optDisableNonWordSuffix, optWordMatch, optProcessBotPosts}) 215 | } 216 | 217 | err = saveConfigLinks(p, links) 218 | if err != nil { 219 | return responsef(err.Error()) 220 | } 221 | 222 | ref := args[0] 223 | if l.Name != "" { 224 | ref = l.Name 225 | } 226 | return executeList(p, c, header, ref) 227 | } 228 | 229 | func executeTest(p *Plugin, _ *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { 230 | if len(args) < 2 { 231 | return responsef(helpText) 232 | } 233 | 234 | links, refs, err := searchLinkRef(p, false, args...) 235 | if err != nil { 236 | return responsef("%v", err) 237 | } 238 | 239 | restOfCommand := header.Command[len(autolinkCommand):] // "/autolink " 240 | restOfCommand = restOfCommand[strings.Index(restOfCommand, args[0])+len(args[0]):] 241 | orig := strings.TrimSpace(restOfCommand) 242 | out := fmt.Sprintf("- Original: `%s`\n", orig) 243 | 244 | for _, ref := range refs { 245 | l := links[ref] 246 | l.Disabled = false 247 | err = l.Compile() 248 | if err != nil { 249 | return responsef("failed to compile link %s: %v", l.DisplayName(), err) 250 | } 251 | replaced := l.Replace(orig) 252 | if replaced == orig { 253 | out += fmt.Sprintf("- Link %s: _no change_\n", l.DisplayName()) 254 | } else { 255 | out += fmt.Sprintf("- Link %s: changed to `%s`\n", l.DisplayName(), replaced) 256 | orig = replaced 257 | } 258 | } 259 | 260 | return responsef(out) 261 | } 262 | 263 | func executeEnable(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { 264 | if len(args) != 1 { 265 | return responsef(helpText) 266 | } 267 | return executeEnableImpl(p, c, header, args[0], true) 268 | } 269 | 270 | func executeDisable(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { 271 | if len(args) != 1 { 272 | return responsef(helpText) 273 | } 274 | return executeEnableImpl(p, c, header, args[0], false) 275 | } 276 | 277 | func executeEnableImpl(p *Plugin, c *plugin.Context, header *model.CommandArgs, ref string, enabled bool) *model.CommandResponse { 278 | links, refs, err := searchLinkRef(p, true, ref) 279 | if err != nil { 280 | return responsef("%v", err) 281 | } 282 | l := &links[refs[0]] 283 | l.Disabled = !enabled 284 | 285 | err = saveConfigLinks(p, links) 286 | if err != nil { 287 | return responsef(err.Error()) 288 | } 289 | 290 | if l.Name != "" { 291 | ref = l.Name 292 | } 293 | return executeList(p, c, header, ref) 294 | } 295 | 296 | func executeAdd(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { 297 | if len(args) > 1 { 298 | return responsef(helpText) 299 | } 300 | name := "" 301 | if len(args) == 1 { 302 | name = args[0] 303 | } 304 | 305 | err := saveConfigLinks(p, append(p.getConfig().Links, autolink.Autolink{ 306 | Name: name, 307 | })) 308 | if err != nil { 309 | return responsef(err.Error()) 310 | } 311 | 312 | if name == "" { 313 | return executeList(p, c, header) 314 | } 315 | return executeList(p, c, header, name) 316 | } 317 | 318 | func executeHelp(_ *Plugin, _ *plugin.Context, _ *model.CommandArgs, _ ...string) *model.CommandResponse { 319 | return responsef(helpText) 320 | } 321 | 322 | func responsef(format string, args ...interface{}) *model.CommandResponse { 323 | return &model.CommandResponse{ 324 | ResponseType: model.CommandResponseTypeEphemeral, 325 | Text: fmt.Sprintf(format, args...), 326 | Type: model.PostTypeDefault, 327 | } 328 | } 329 | 330 | func searchLinkRef(p *Plugin, requireUnique bool, args ...string) ([]autolink.Autolink, []int, error) { 331 | links := p.getConfig().Sorted().Links 332 | if len(args) == 0 { 333 | if requireUnique { 334 | return nil, nil, errors.New("unreachable") 335 | } 336 | 337 | return links, nil, nil 338 | } 339 | 340 | n, err := strconv.ParseUint(args[0], 10, 32) 341 | if err == nil { 342 | if n < 1 || int(n) > len(links) { 343 | return nil, nil, errors.Errorf("%v is not a valid link number", n) 344 | } 345 | return links, []int{int(n) - 1}, nil 346 | } 347 | 348 | found := []int{} 349 | for i, l := range links { 350 | if strings.Contains(l.Name, args[0]) { 351 | found = append(found, i) 352 | } 353 | } 354 | if len(found) == 0 { 355 | return nil, nil, errors.Errorf("%q not found", args[0]) 356 | } 357 | if requireUnique && len(found) > 1 { 358 | names := []string{} 359 | for _, i := range found { 360 | names = append(names, links[i].Name) 361 | } 362 | return nil, nil, errors.Errorf("%q matched more than one link: %q", args[0], names) 363 | } 364 | 365 | return links, found, nil 366 | } 367 | 368 | func searchLinkRefByTemplateOrPattern(p *Plugin, header *model.CommandArgs, args ...string) ([]autolink.Autolink, []int, error) { 369 | links := p.getConfig().Sorted().Links 370 | if len(args) == 1 { 371 | return links, nil, nil 372 | } 373 | 374 | restOfCommand := header.Command[len(autolinkCommand):] 375 | restOfCommand = restOfCommand[strings.Index(restOfCommand, args[0])+len(args[0]):] 376 | value := strings.TrimSpace(restOfCommand) 377 | 378 | found := []int{} 379 | for i, l := range links { 380 | if (args[0] == optTemplate && strings.Contains(strings.ToLower(l.Template), strings.ToLower(value))) || 381 | (args[0] == optPattern && strings.Contains(strings.ToLower(l.Pattern), strings.ToLower(value))) { 382 | found = append(found, i) 383 | } 384 | } 385 | 386 | if len(found) == 0 { 387 | return nil, nil, errors.Errorf("%q not found", value) 388 | } 389 | 390 | return links, found, nil 391 | } 392 | 393 | func parseBoolArg(arg string) (bool, error) { 394 | switch strings.ToLower(arg) { 395 | case "true", "on": 396 | return true, nil 397 | case "false", "off": 398 | return false, nil 399 | } 400 | return false, errors.Errorf("Not a bool, %q", arg) 401 | } 402 | 403 | func saveConfigLinks(p *Plugin, links []autolink.Autolink) error { 404 | conf := p.getConfig() 405 | conf.Links = links 406 | 407 | configMap, err := p.getConfig().ToMap() 408 | if err != nil { 409 | return err 410 | } 411 | 412 | appErr := p.API.SavePluginConfig(configMap) 413 | if appErr != nil { 414 | return appErr 415 | } 416 | return nil 417 | } 418 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 4 | cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= 5 | dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= 6 | dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= 7 | dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= 8 | dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= 9 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 10 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 11 | git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= 12 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 13 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= 14 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 15 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 16 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 17 | github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= 18 | github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= 19 | github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= 20 | github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= 21 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 22 | github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 23 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 29 | github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a h1:etIrTD8BQqzColk9nKRusM9um5+1q0iOEJLqfBMIK64= 30 | github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go.mod h1:emQhSYTXqB0xxjLITTw4EaWZ+8IIQYw+kx9GqNUKdLg= 31 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 32 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 33 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 34 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 35 | github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= 36 | github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= 37 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 38 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 39 | github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 40 | github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= 41 | github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= 42 | github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= 43 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 44 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 45 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 46 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 47 | github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= 48 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 49 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 50 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 51 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 52 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 53 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 54 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 55 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 56 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 57 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 58 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 59 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 60 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 61 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 62 | github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 63 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 64 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 65 | github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= 66 | github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= 67 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 68 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 69 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 70 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 71 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 72 | github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 73 | github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= 74 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 75 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 76 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 77 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 78 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 79 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 80 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 81 | github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI= 82 | github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= 83 | github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= 84 | github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= 85 | github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= 86 | github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= 87 | github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= 88 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 89 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 90 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 91 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 92 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 93 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 94 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 95 | github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 96 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 97 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 98 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 99 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 100 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 101 | github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 102 | github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 103 | github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= 104 | github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404/go.mod h1:RyS7FDNQlzF1PsjbJWHRI35exqaKGSO9qD4iv8QjE34= 105 | github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb2BTCsOdamENjjWCI6qmfHLbk6OZI= 106 | github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= 107 | github.com/mattermost/logr/v2 v2.0.21 h1:CMHsP+nrbRlEC4g7BwOk1GAnMtHkniFhlSQPXy52be4= 108 | github.com/mattermost/logr/v2 v2.0.21/go.mod h1:kZkB/zqKL9e+RY5gB3vGpsyenC+TpuiOenjMkvJJbzc= 109 | github.com/mattermost/mattermost/server/public v0.1.9 h1:l/OKPRVuFeqL0yqRVC/JpveG5sLNKcT9llxqMkO9e+s= 110 | github.com/mattermost/mattermost/server/public v0.1.9/go.mod h1:SkTKbMul91Rq0v2dIxe8mqzUOY+3KwlwwLmAlxDfGCk= 111 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 112 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 113 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 114 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 115 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 116 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 117 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 118 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 119 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 120 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 121 | github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= 122 | github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= 123 | github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= 124 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 125 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 126 | github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= 127 | github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= 128 | github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= 129 | github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= 130 | github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= 131 | github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= 132 | github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= 133 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 134 | github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 135 | github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 136 | github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986 h1:jYi87L8j62qkXzaYHAQAhEapgukhenIMZRBKTNRLHJ4= 137 | github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 138 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 139 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 140 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 141 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 142 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 143 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 144 | github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 145 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 146 | github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 147 | github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 148 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 149 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 150 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 151 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 152 | github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= 153 | github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= 154 | github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= 155 | github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= 156 | github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= 157 | github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= 158 | github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= 159 | github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= 160 | github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= 161 | github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= 162 | github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= 163 | github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= 164 | github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= 165 | github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= 166 | github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= 167 | github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= 168 | github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= 169 | github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= 170 | github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= 171 | github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 172 | github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= 173 | github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= 174 | github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= 175 | github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= 176 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 177 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 178 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 179 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 180 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 181 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 182 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 183 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 184 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= 185 | github.com/tinylib/msgp v1.2.0 h1:0uKB/662twsVBpYUPbokj4sTSKhWFKB7LopO2kWK8lY= 186 | github.com/tinylib/msgp v1.2.0/go.mod h1:2vIGs3lcUo8izAATNobrCHevYZC/LMsJtw4JPiYPHro= 187 | github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= 188 | github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= 189 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 190 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 191 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 192 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 193 | github.com/wiggin77/merror v1.0.5 h1:P+lzicsn4vPMycAf2mFf7Zk6G9eco5N+jB1qJ2XW3ME= 194 | github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0= 195 | github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8= 196 | github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= 197 | go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= 198 | go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= 199 | golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= 200 | golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 201 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 202 | golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 203 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 204 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 205 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 206 | golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 207 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 208 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 209 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 210 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 211 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 212 | golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 213 | golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 214 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 215 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 216 | golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 217 | golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 218 | golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= 219 | golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 220 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 221 | golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 222 | golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 223 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 224 | golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= 225 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 226 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 227 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 228 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 229 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 230 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 231 | golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 232 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 233 | golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 236 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 237 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 238 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 239 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 240 | golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 241 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 242 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 243 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 244 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 245 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 246 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 247 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 248 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 249 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 250 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 251 | golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 252 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 253 | golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 254 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 255 | golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 256 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 257 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 258 | google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 259 | google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 260 | google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= 261 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 262 | google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 263 | google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 264 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 265 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 266 | google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 267 | google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 268 | google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= 269 | google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 270 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo= 271 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= 272 | google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 273 | google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= 274 | google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= 275 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 276 | google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= 277 | google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= 278 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 279 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 280 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 281 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 282 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 283 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 284 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 285 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 286 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 287 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 288 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 289 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 290 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 291 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 292 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 293 | grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= 294 | honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 295 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 296 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 297 | sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= 298 | sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= 299 | -------------------------------------------------------------------------------- /server/autolinkplugin/plugin_test.go: -------------------------------------------------------------------------------- 1 | package autolinkplugin 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/mattermost/mattermost/server/public/model" 12 | "github.com/mattermost/mattermost/server/public/plugin" 13 | "github.com/mattermost/mattermost/server/public/plugin/plugintest" 14 | "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | "github.com/stretchr/testify/suite" 18 | 19 | "github.com/mattermost-community/mattermost-plugin-autolink/server/autolink" 20 | ) 21 | 22 | func TestPlugin(t *testing.T) { 23 | conf := Config{ 24 | Links: []autolink.Autolink{{ 25 | Pattern: "(Mattermost)", 26 | Template: "[Mattermost](https://mattermost.com)", 27 | }}, 28 | } 29 | 30 | testChannel := model.Channel{ 31 | Name: "TestChanel", 32 | } 33 | 34 | testTeam := model.Team{ 35 | Name: "TestTeam", 36 | } 37 | 38 | api := &plugintest.API{} 39 | 40 | api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 41 | *dest.(*Config) = conf 42 | return nil 43 | }) 44 | api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)) 45 | 46 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 47 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil) 48 | 49 | testUser := model.User{ 50 | IsBot: false, 51 | } 52 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil) 53 | 54 | p := New() 55 | p.SetAPI(api) 56 | err := p.OnConfigurationChange() 57 | require.NoError(t, err) 58 | 59 | post := &model.Post{Message: "Welcome to Mattermost!"} 60 | rpost, _ := p.MessageWillBePosted(&plugin.Context{}, post) 61 | 62 | assert.Equal(t, "Welcome to [Mattermost](https://mattermost.com)!", rpost.Message) 63 | } 64 | 65 | type SuiteAuthorization struct { 66 | suite.Suite 67 | 68 | api *plugintest.API 69 | 70 | adminUsernames string 71 | userInfo map[string]*model.User 72 | } 73 | 74 | func (suite *SuiteAuthorization) SetupTest() { 75 | suite.adminUsernames = "" 76 | suite.userInfo = make(map[string]*model.User) 77 | 78 | suite.api = &plugintest.API{} 79 | suite.api.On( 80 | "LoadPluginConfiguration", 81 | mock.AnythingOfType("*autolinkplugin.Config"), 82 | ).Return( 83 | func(dest interface{}) error { 84 | *dest.(*Config) = Config{ 85 | PluginAdmins: suite.adminUsernames, 86 | } 87 | return nil 88 | }, 89 | ) 90 | suite.api.On( 91 | "GetUser", 92 | mock.AnythingOfType("string"), 93 | ).Return( 94 | func(userID string) *model.User { 95 | return suite.userInfo[userID] 96 | }, 97 | func(userID string) *model.AppError { 98 | if _, ok := suite.userInfo[userID]; !ok { 99 | return &model.AppError{ 100 | Message: fmt.Sprintf("user %s not found", userID), 101 | } 102 | } 103 | 104 | return nil 105 | }, 106 | ) 107 | suite.api.On( 108 | "UnregisterCommand", 109 | mock.AnythingOfType("string"), 110 | mock.AnythingOfType("string"), 111 | ).Return( 112 | (*model.AppError)(nil), 113 | ) 114 | } 115 | 116 | func (suite *SuiteAuthorization) TestSysadminIsAuthorized() { 117 | suite.userInfo["marynaId"] = &model.User{ 118 | Username: "maryna", 119 | Id: "marynaId", 120 | Roles: "smurf,system_admin,reaper", 121 | } 122 | 123 | suite.api.On("LogInfo", mock.AnythingOfType("string")).Return(nil) 124 | 125 | p := New() 126 | p.SetAPI(suite.api) 127 | 128 | err := p.OnConfigurationChange() 129 | require.NoError(suite.T(), err) 130 | 131 | allowed, err := p.IsAuthorizedAdmin("marynaId") 132 | require.NoError(suite.T(), err) 133 | assert.True(suite.T(), allowed) 134 | } 135 | 136 | func (suite *SuiteAuthorization) TestPlainUserIsDenied() { 137 | suite.userInfo["marynaId"] = &model.User{ 138 | Username: "maryna", 139 | Id: "marynaId", 140 | Roles: "smurf,reaper", 141 | } 142 | 143 | p := New() 144 | p.SetAPI(suite.api) 145 | 146 | err := p.OnConfigurationChange() 147 | require.NoError(suite.T(), err) 148 | 149 | allowed, err := p.IsAuthorizedAdmin("marynaId") 150 | require.NoError(suite.T(), err) 151 | assert.False(suite.T(), allowed) 152 | } 153 | 154 | func (suite *SuiteAuthorization) TestAdminUserIsAuthorized() { 155 | suite.userInfo["marynaId"] = &model.User{ 156 | Username: "maryna", 157 | Id: "marynaId", 158 | Roles: "smurf,reaper", 159 | } 160 | suite.adminUsernames = "marynaId" 161 | 162 | suite.api.On("LogInfo", mock.AnythingOfType("string")).Return(nil) 163 | 164 | p := New() 165 | p.SetAPI(suite.api) 166 | 167 | err := p.OnConfigurationChange() 168 | require.NoError(suite.T(), err) 169 | 170 | allowed, err := p.IsAuthorizedAdmin("marynaId") 171 | require.NoError(suite.T(), err) 172 | assert.True(suite.T(), allowed) 173 | } 174 | 175 | func (suite *SuiteAuthorization) TestMultipleUsersAreAuthorized() { 176 | suite.userInfo["marynaId"] = &model.User{ 177 | Username: "maryna", 178 | Id: "marynaId", 179 | Roles: "smurf,reaper", 180 | } 181 | suite.userInfo["borynaId"] = &model.User{ 182 | Username: "boryna", 183 | Id: "borynaId", 184 | Roles: "smurf", 185 | } 186 | suite.userInfo["karynaId"] = &model.User{ 187 | Username: "karyna", 188 | Id: "karynaId", 189 | Roles: "screamer", 190 | } 191 | suite.adminUsernames = "marynaId,karynaId" 192 | 193 | suite.api.On("LogInfo", mock.AnythingOfType("string")).Return(nil) 194 | 195 | p := New() 196 | p.SetAPI(suite.api) 197 | 198 | err := p.OnConfigurationChange() 199 | require.NoError(suite.T(), err) 200 | 201 | allowed, err := p.IsAuthorizedAdmin("marynaId") 202 | require.NoError(suite.T(), err) 203 | assert.True(suite.T(), allowed) 204 | 205 | allowed, err = p.IsAuthorizedAdmin("karynaId") 206 | require.NoError(suite.T(), err) 207 | assert.True(suite.T(), allowed) 208 | 209 | allowed, err = p.IsAuthorizedAdmin("borynaId") 210 | require.NoError(suite.T(), err) 211 | assert.False(suite.T(), allowed) 212 | } 213 | 214 | func (suite *SuiteAuthorization) TestWhitespaceIsIgnored() { 215 | suite.userInfo["marynaId"] = &model.User{ 216 | Username: "maryna", 217 | Id: "marynaId", 218 | Roles: "smurf,reaper", 219 | } 220 | suite.userInfo["borynaId"] = &model.User{ 221 | Username: "boryna", 222 | Id: "borynaId", 223 | Roles: "smurf", 224 | } 225 | suite.userInfo["karynaId"] = &model.User{ 226 | Username: "karyna", 227 | Id: "karynaId", 228 | Roles: "screamer", 229 | } 230 | suite.adminUsernames = "marynaId , karynaId, borynaId " 231 | 232 | suite.api.On("LogInfo", mock.AnythingOfType("string")).Return(nil) 233 | 234 | p := New() 235 | p.SetAPI(suite.api) 236 | 237 | err := p.OnConfigurationChange() 238 | require.NoError(suite.T(), err) 239 | 240 | allowed, err := p.IsAuthorizedAdmin("marynaId") 241 | require.NoError(suite.T(), err) 242 | assert.True(suite.T(), allowed) 243 | 244 | allowed, err = p.IsAuthorizedAdmin("karynaId") 245 | require.NoError(suite.T(), err) 246 | assert.True(suite.T(), allowed) 247 | 248 | allowed, err = p.IsAuthorizedAdmin("borynaId") 249 | require.NoError(suite.T(), err) 250 | assert.True(suite.T(), allowed) 251 | } 252 | 253 | func (suite *SuiteAuthorization) TestNonExistantUsersAreIgnored() { 254 | suite.userInfo["marynaId"] = &model.User{ 255 | Username: "maryna", 256 | Id: "marynaId", 257 | Roles: "smurf,reaper", 258 | } 259 | suite.adminUsernames = "marynaId,karynaId" 260 | 261 | suite.api.On("LogWarn", mock.AnythingOfType("string"), 262 | "userID", 263 | "karynaId", 264 | "error", 265 | mock.AnythingOfType("*model.AppError"), 266 | ).Return(nil) 267 | suite.api.On("LogInfo", mock.AnythingOfType("string")).Return(nil) 268 | 269 | p := New() 270 | p.SetAPI(suite.api) 271 | 272 | err := p.OnConfigurationChange() 273 | require.NoError(suite.T(), err) 274 | 275 | allowed, err := p.IsAuthorizedAdmin("marynaId") 276 | require.NoError(suite.T(), err) 277 | assert.True(suite.T(), allowed) 278 | 279 | allowed, err = p.IsAuthorizedAdmin("karynaId") 280 | require.Error(suite.T(), err) 281 | require.False(suite.T(), allowed) 282 | } 283 | 284 | func TestSuiteAuthorization(t *testing.T) { 285 | suite.Run(t, new(SuiteAuthorization)) 286 | } 287 | 288 | func TestSpecialCases(t *testing.T) { 289 | links := make([]autolink.Autolink, 0) 290 | links = append(links, autolink.Autolink{ 291 | Pattern: "(Mattermost)", 292 | Template: "[Mattermost](https://mattermost.com)", 293 | }, autolink.Autolink{ 294 | Pattern: "(Example)", 295 | Template: "[Example](https://example.com)", 296 | }, autolink.Autolink{ 297 | Pattern: "MM-(?P\\d+)", 298 | Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 299 | }, autolink.Autolink{ 300 | Pattern: "https://mattermost.atlassian.net/browse/MM-(?P\\d+)", 301 | Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", 302 | }, autolink.Autolink{ 303 | Pattern: "(foo!bar)", 304 | Template: "fb", 305 | }, autolink.Autolink{ 306 | Pattern: "(example)", 307 | Template: "test", 308 | Scope: []string{"team/off-topic"}, 309 | }, autolink.Autolink{ 310 | Pattern: "(example)", 311 | Template: "test", 312 | Scope: []string{"other-team/town-square"}, 313 | }) 314 | validConfig := Config{ 315 | EnableAdminCommand: false, 316 | EnableOnUpdate: true, 317 | Links: links, 318 | } 319 | 320 | testChannel := model.Channel{ 321 | Name: "TestChanel", 322 | } 323 | 324 | testTeam := model.Team{ 325 | Name: "TestTeam", 326 | } 327 | 328 | api := &plugintest.API{} 329 | 330 | api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 331 | *dest.(*Config) = validConfig 332 | return nil 333 | }) 334 | api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)) 335 | 336 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 337 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil) 338 | 339 | testUser := model.User{ 340 | IsBot: false, 341 | } 342 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil) 343 | 344 | p := New() 345 | p.SetAPI(api) 346 | err := p.OnConfigurationChange() 347 | require.NoError(t, err) 348 | 349 | var tests = []struct { 350 | inputMessage string 351 | expectedMessage string 352 | }{ 353 | { 354 | "hello ``` Mattermost ``` goodbye", 355 | "hello ``` Mattermost ``` goodbye", 356 | }, { 357 | "hello\n```\nMattermost\n```\ngoodbye", 358 | "hello\n```\nMattermost\n```\ngoodbye", 359 | }, { 360 | "Mattermost ``` Mattermost ``` goodbye", 361 | "[Mattermost](https://mattermost.com) ``` Mattermost ``` goodbye", 362 | }, { 363 | "``` Mattermost ``` Mattermost", 364 | "``` Mattermost ``` [Mattermost](https://mattermost.com)", 365 | }, { 366 | "Mattermost ``` Mattermost ```", 367 | "[Mattermost](https://mattermost.com) ``` Mattermost ```", 368 | }, { 369 | "Mattermost ``` Mattermost ```\n\n", 370 | "[Mattermost](https://mattermost.com) ``` Mattermost ```\n\n", 371 | }, { 372 | "hello ` Mattermost ` goodbye", 373 | "hello ` Mattermost ` goodbye", 374 | }, { 375 | "hello\n`\nMattermost\n`\ngoodbye", 376 | "hello\n`\nMattermost\n`\ngoodbye", 377 | }, { 378 | "Mattermost ` Mattermost ` goodbye", 379 | "[Mattermost](https://mattermost.com) ` Mattermost ` goodbye", 380 | }, { 381 | "` Mattermost ` Mattermost", 382 | "` Mattermost ` [Mattermost](https://mattermost.com)", 383 | }, { 384 | "Mattermost ` Mattermost `", 385 | "[Mattermost](https://mattermost.com) ` Mattermost `", 386 | }, { 387 | "Mattermost ` Mattermost `\n\n", 388 | "[Mattermost](https://mattermost.com) ` Mattermost `\n\n", 389 | }, { 390 | "hello ``` Mattermost ``` goodbye ` Mattermost ` end", 391 | "hello ``` Mattermost ``` goodbye ` Mattermost ` end", 392 | }, { 393 | "hello\n```\nMattermost\n```\ngoodbye ` Mattermost ` end", 394 | "hello\n```\nMattermost\n```\ngoodbye ` Mattermost ` end", 395 | }, { 396 | "Mattermost ``` Mattermost ``` goodbye ` Mattermost ` end", 397 | "[Mattermost](https://mattermost.com) ``` Mattermost ``` goodbye ` Mattermost ` end", 398 | }, { 399 | "``` Mattermost ``` Mattermost", 400 | "``` Mattermost ``` [Mattermost](https://mattermost.com)", 401 | }, { 402 | "```\n` Mattermost `\n```\nMattermost", 403 | "```\n` Mattermost `\n```\n[Mattermost](https://mattermost.com)", 404 | }, { 405 | " Mattermost", 406 | " [Mattermost](https://mattermost.com)", 407 | }, { 408 | " Mattermost", 409 | " Mattermost", 410 | }, { 411 | " ```\nMattermost\n ```", 412 | " ```\n[Mattermost](https://mattermost.com)\n ```", 413 | }, { 414 | "` ``` `\nMattermost\n` ``` `", 415 | "` ``` `\n[Mattermost](https://mattermost.com)\n` ``` `", 416 | }, { 417 | "Mattermost \n Mattermost", 418 | "[Mattermost](https://mattermost.com) \n [Mattermost](https://mattermost.com)", 419 | }, { 420 | "[Mattermost](https://mattermost.com)", 421 | "[Mattermost](https://mattermost.com)", 422 | }, { 423 | "[ Mattermost ](https://mattermost.com)", 424 | "[ Mattermost ](https://mattermost.com)", 425 | }, { 426 | "[ Mattermost ][1]\n\n[1]: https://mattermost.com", 427 | "[ Mattermost ][1]\n\n[1]: https://mattermost.com", 428 | }, { 429 | "![ Mattermost ](https://mattermost.com/example.png)", 430 | "![ Mattermost ](https://mattermost.com/example.png)", 431 | }, { 432 | "![ Mattermost ][1]\n\n[1]: https://mattermost.com/example.png", 433 | "![ Mattermost ][1]\n\n[1]: https://mattermost.com/example.png", 434 | }, { 435 | "foo!bar\nExample\nfoo!bar Mattermost", 436 | "fb\n[Example](https://example.com)\nfb [Mattermost](https://mattermost.com)", 437 | }, { 438 | "foo!bar", 439 | "fb", 440 | }, { 441 | "foo!barfoo!bar", 442 | "foo!barfoo!bar", 443 | }, { 444 | "foo!bar & foo!bar", 445 | "fb & fb", 446 | }, { 447 | "foo!bar & foo!bar\nfoo!bar & foo!bar\nfoo!bar & foo!bar", 448 | "fb & fb\nfb & fb\nfb & fb", 449 | }, { 450 | "https://mattermost.atlassian.net/browse/MM-12345", 451 | "[MM-12345](https://mattermost.atlassian.net/browse/MM-12345)", 452 | }, { 453 | "Welcome https://mattermost.atlassian.net/browse/MM-12345", 454 | "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345)", 455 | }, { 456 | "text https://mattermost.atlassian.net/browse/MM-12345 other text", 457 | "text [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) other text", 458 | }, { 459 | "check out MM-12345 too", 460 | "check out [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) too", 461 | }, 462 | } 463 | 464 | for _, tt := range tests { 465 | t.Run(tt.inputMessage, func(t *testing.T) { 466 | { 467 | // user creates a new post 468 | 469 | post := &model.Post{ 470 | Message: tt.inputMessage, 471 | } 472 | 473 | rpost, _ := p.MessageWillBePosted(&plugin.Context{}, post) 474 | 475 | assert.Equal(t, tt.expectedMessage, rpost.Message) 476 | } 477 | { 478 | // user updates the modified post but with no changes 479 | 480 | post := &model.Post{ 481 | Message: tt.expectedMessage, 482 | } 483 | 484 | rpost, _ := p.MessageWillBeUpdated(&plugin.Context{}, post, post) 485 | 486 | assert.Equal(t, tt.expectedMessage, rpost.Message) 487 | } 488 | { 489 | // user updates the modified post and sets it back to the original text 490 | 491 | originalPost := &model.Post{ 492 | Message: tt.expectedMessage, 493 | } 494 | post := &model.Post{ 495 | Message: tt.inputMessage, 496 | } 497 | 498 | rpost, _ := p.MessageWillBeUpdated(&plugin.Context{}, originalPost, post) 499 | 500 | assert.Equal(t, tt.expectedMessage, rpost.Message) 501 | } 502 | { 503 | // user updates an empty post to the original text 504 | 505 | emptyPost := &model.Post{} 506 | post := &model.Post{ 507 | Message: tt.inputMessage, 508 | } 509 | 510 | rpost, _ := p.MessageWillBeUpdated(&plugin.Context{}, post, emptyPost) 511 | 512 | assert.Equal(t, tt.expectedMessage, rpost.Message) 513 | } 514 | }) 515 | } 516 | } 517 | 518 | func TestBotMessagesAreRewritenWhenGetUserFails(t *testing.T) { 519 | conf := Config{ 520 | Links: []autolink.Autolink{{ 521 | Pattern: "(Mattermost)", 522 | Template: "[Mattermost](https://mattermost.com)", 523 | }}, 524 | } 525 | 526 | testChannel := model.Channel{ 527 | Name: "TestChanel", 528 | } 529 | 530 | testTeam := model.Team{ 531 | Name: "TestTeam", 532 | } 533 | 534 | api := &plugintest.API{} 535 | 536 | api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 537 | *dest.(*Config) = conf 538 | return nil 539 | }).Once() 540 | api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)).Once() 541 | 542 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil).Once() 543 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil).Once() 544 | api.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("*model.AppError")) 545 | 546 | api.On("GetUser", mock.AnythingOfType("string")).Return(nil, &model.AppError{ 547 | Message: "foo error!", 548 | }).Once() 549 | 550 | p := New() 551 | p.SetAPI(api) 552 | err := p.OnConfigurationChange() 553 | require.NoError(t, err) 554 | 555 | post := &model.Post{Message: "Welcome to Mattermost!"} 556 | rpost, _ := p.MessageWillBePosted(&plugin.Context{}, post) 557 | 558 | assert.Equal(t, "Welcome to [Mattermost](https://mattermost.com)!", rpost.Message) 559 | } 560 | 561 | func TestGetUserApiCallIsNotExecutedWhenThereAreNoChanges(t *testing.T) { 562 | conf := Config{ 563 | Links: []autolink.Autolink{{ 564 | Pattern: "(Mattermost)", 565 | Template: "[Mattermost](https://mattermost.com)", 566 | }}, 567 | } 568 | 569 | testChannel := model.Channel{ 570 | Name: "TestChanel", 571 | } 572 | 573 | testTeam := model.Team{ 574 | Name: "TestTeam", 575 | } 576 | 577 | api := &plugintest.API{} 578 | 579 | api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 580 | *dest.(*Config) = conf 581 | return nil 582 | }).Once() 583 | api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)).Once() 584 | 585 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil).Once() 586 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil).Once() 587 | api.On("LogDebug", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")) 588 | 589 | p := New() 590 | p.SetAPI(api) 591 | err := p.OnConfigurationChange() 592 | require.NoError(t, err) 593 | 594 | post := &model.Post{Message: "Welcome to FooBarism!"} 595 | rpost, _ := p.MessageWillBePosted(&plugin.Context{}, post) 596 | 597 | assert.Equal(t, "Welcome to FooBarism!", rpost.Message) 598 | } 599 | 600 | func TestBotMessagesAreNotRewriten(t *testing.T) { 601 | conf := Config{ 602 | Links: []autolink.Autolink{{ 603 | Pattern: "(Mattermost)", 604 | Template: "[Mattermost](https://mattermost.com)", 605 | }}, 606 | } 607 | 608 | testChannel := model.Channel{ 609 | Name: "TestChanel", 610 | } 611 | 612 | testTeam := model.Team{ 613 | Name: "TestTeam", 614 | } 615 | 616 | api := &plugintest.API{} 617 | 618 | api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 619 | *dest.(*Config) = conf 620 | return nil 621 | }).Once() 622 | api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)).Once() 623 | 624 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil).Once() 625 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil).Once() 626 | testUser := model.User{ 627 | IsBot: true, 628 | } 629 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil).Once() 630 | api.On("LogDebug", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")) 631 | 632 | p := New() 633 | p.SetAPI(api) 634 | err := p.OnConfigurationChange() 635 | require.NoError(t, err) 636 | 637 | post := &model.Post{Message: "Welcome to Mattermost!"} 638 | rpost, _ := p.MessageWillBePosted(&plugin.Context{}, post) 639 | 640 | assert.Equal(t, post.Message, rpost.Message) 641 | } 642 | 643 | func TestBotMessagesAreRewritenWhenConfigAllows(t *testing.T) { 644 | conf := Config{ 645 | Links: []autolink.Autolink{{ 646 | Pattern: "(Mattermost)", 647 | Template: "[Mattermost](https://mattermost.com)", 648 | ProcessBotPosts: true, 649 | }}, 650 | } 651 | 652 | testChannel := model.Channel{ 653 | Name: "TestChanel", 654 | } 655 | 656 | testTeam := model.Team{ 657 | Name: "TestTeam", 658 | } 659 | 660 | api := &plugintest.API{} 661 | 662 | api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 663 | *dest.(*Config) = conf 664 | return nil 665 | }).Once() 666 | api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)).Once() 667 | 668 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil).Once() 669 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil).Once() 670 | api.On("LogDebug", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")) 671 | 672 | testUser := model.User{ 673 | IsBot: true, 674 | } 675 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil).Once() 676 | 677 | p := New() 678 | p.SetAPI(api) 679 | err := p.OnConfigurationChange() 680 | require.NoError(t, err) 681 | 682 | post := &model.Post{Message: "Welcome to Mattermost!"} 683 | rpost, _ := p.MessageWillBePosted(&plugin.Context{}, post) 684 | require.NotNil(t, rpost) 685 | 686 | assert.Equal(t, "Welcome to [Mattermost](https://mattermost.com)!", rpost.Message) 687 | } 688 | 689 | func TestHashtags(t *testing.T) { 690 | conf := Config{ 691 | Links: []autolink.Autolink{{ 692 | Pattern: "foo", 693 | Template: "#bar", 694 | }, { 695 | Pattern: "hash tags", 696 | Template: "#hash #tags", 697 | }}, 698 | } 699 | 700 | testChannel := model.Channel{ 701 | Name: "TestChanel", 702 | } 703 | 704 | testTeam := model.Team{ 705 | Name: "TestTeam", 706 | } 707 | 708 | api := &plugintest.API{} 709 | 710 | api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 711 | *dest.(*Config) = conf 712 | return nil 713 | }) 714 | api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)) 715 | 716 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 717 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil) 718 | 719 | testUser := model.User{ 720 | IsBot: false, 721 | } 722 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil) 723 | 724 | p := New() 725 | p.SetAPI(api) 726 | err := p.OnConfigurationChange() 727 | require.NoError(t, err) 728 | 729 | post := &model.Post{Message: "foo"} 730 | rpost, _ := p.MessageWillBePosted(&plugin.Context{}, post) 731 | 732 | assert.Equal(t, "#bar", rpost.Hashtags) 733 | 734 | post.Message = "hash tags" 735 | rpost, _ = p.MessageWillBePosted(&plugin.Context{}, post) 736 | 737 | assert.Equal(t, "#hash #tags", rpost.Hashtags) 738 | } 739 | 740 | func TestAPI(t *testing.T) { 741 | conf := Config{ 742 | Links: []autolink.Autolink{{ 743 | Name: "existing", 744 | Pattern: "thing", 745 | Template: "otherthing", 746 | }}, 747 | } 748 | 749 | testChannel := model.Channel{ 750 | Name: "TestChanel", 751 | } 752 | 753 | testTeam := model.Team{ 754 | Name: "TestTeam", 755 | } 756 | 757 | api := &plugintest.API{} 758 | api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 759 | *dest.(*Config) = conf 760 | return nil 761 | }) 762 | api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)) 763 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 764 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil) 765 | api.On("SavePluginConfig", mock.AnythingOfType("map[string]interface {}")).Return(nil) 766 | 767 | p := New() 768 | p.SetAPI(api) 769 | err := p.OnConfigurationChange() 770 | require.NoError(t, err) 771 | err = p.OnActivate() 772 | require.NoError(t, err) 773 | 774 | jbyte, err := json.Marshal(&autolink.Autolink{Name: "new", Pattern: "newpat", Template: "newtemp"}) 775 | require.NoError(t, err) 776 | recorder := httptest.NewRecorder() 777 | req, err := http.NewRequest("POST", "/api/v1/link", bytes.NewReader(jbyte)) 778 | require.NoError(t, err) 779 | req.Header.Set("Mattermost-Plugin-ID", "somthing") 780 | p.ServeHTTP(&plugin.Context{}, recorder, req) 781 | resp := recorder.Result() 782 | require.NotNil(t, resp) 783 | assert.Equal(t, http.StatusOK, resp.StatusCode) 784 | assert.Len(t, p.conf.Links, 2) 785 | assert.Equal(t, "new", p.conf.Links[1].Name) 786 | } 787 | 788 | func TestResolveScope(t *testing.T) { 789 | t.Run("resolve channel name and team name", func(t *testing.T) { 790 | testChannel := model.Channel{ 791 | Name: "TestChannel", 792 | TeamId: "TestId", 793 | } 794 | 795 | testTeam := model.Team{ 796 | Name: "TestTeam", 797 | } 798 | 799 | api := &plugintest.API{} 800 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 801 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil) 802 | 803 | p := Plugin{} 804 | p.SetAPI(api) 805 | channelName, teamName, _ := p.resolveScope("TestId") 806 | assert.Equal(t, "TestChannel", channelName) 807 | assert.Equal(t, "TestTeam", teamName) 808 | }) 809 | 810 | t.Run("resolve channel name and returns empty team name", func(t *testing.T) { 811 | testChannel := model.Channel{ 812 | Name: "TestChannel", 813 | } 814 | 815 | api := &plugintest.API{} 816 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 817 | 818 | p := Plugin{} 819 | p.SetAPI(api) 820 | 821 | channelName, teamName, _ := p.resolveScope("TestId") 822 | assert.Equal(t, "TestChannel", channelName) 823 | assert.Equal(t, "", teamName) 824 | }) 825 | 826 | t.Run("error when api fails to get channel", func(t *testing.T) { 827 | api := &plugintest.API{} 828 | 829 | api.On("GetChannel", 830 | mock.AnythingOfType("string")).Return(nil, &model.AppError{}) 831 | 832 | p := Plugin{} 833 | p.SetAPI(api) 834 | 835 | channelName, teamName, err := p.resolveScope("TestId") 836 | assert.Error(t, err) 837 | assert.Equal(t, teamName, "") 838 | assert.Equal(t, channelName, "") 839 | }) 840 | 841 | t.Run("error when api fails to get team", func(t *testing.T) { 842 | testChannel := model.Channel{ 843 | Name: "TestChannel", 844 | TeamId: "TestId", 845 | } 846 | 847 | api := &plugintest.API{} 848 | 849 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 850 | api.On("GetTeam", mock.AnythingOfType("string")).Return(nil, &model.AppError{}) 851 | 852 | p := Plugin{} 853 | p.SetAPI(api) 854 | 855 | channelName, teamName, err := p.resolveScope("TestId") 856 | assert.Error(t, err) 857 | assert.Equal(t, channelName, "") 858 | assert.Equal(t, teamName, "") 859 | }) 860 | } 861 | 862 | func TestProcessPost(t *testing.T) { 863 | t.Run("cannot resolve scope", func(t *testing.T) { 864 | conf := Config{ 865 | Links: []autolink.Autolink{ 866 | { 867 | Pattern: "(Mattermost)", 868 | Template: "[Mattermost](https://mattermost.com)", 869 | Scope: []string{"TestTeam/TestChannel"}, 870 | }, 871 | }, 872 | } 873 | 874 | api := &plugintest.API{} 875 | 876 | api.On("LoadPluginConfiguration", 877 | mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 878 | *dest.(*Config) = conf 879 | return nil 880 | }) 881 | api.On("UnregisterCommand", mock.AnythingOfType("string"), 882 | mock.AnythingOfType("string")).Return((*model.AppError)(nil)) 883 | 884 | api.On("GetChannel", mock.AnythingOfType("string")).Return(nil, &model.AppError{}) 885 | 886 | api.On("LogError", 887 | mock.AnythingOfType("string"), 888 | mock.AnythingOfType("string"), 889 | mock.AnythingOfType("string")).Return(nil) 890 | 891 | testUser := model.User{ 892 | IsBot: false, 893 | } 894 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil) 895 | 896 | p := New() 897 | p.SetAPI(api) 898 | _ = p.OnConfigurationChange() 899 | 900 | post := &model.Post{Message: "Welcome to Mattermost!"} 901 | rpost, _ := p.ProcessPost(&plugin.Context{}, post) 902 | 903 | assert.Equal(t, "Welcome to Mattermost!", rpost.Message) 904 | }) 905 | 906 | t.Run("team name is empty", func(t *testing.T) { 907 | conf := Config{ 908 | Links: []autolink.Autolink{ 909 | { 910 | Pattern: "(Mattermost)", 911 | Template: "[Mattermost](https://mattermost.com)", 912 | Scope: []string{"TestTeam/TestChannel"}, 913 | }, 914 | }, 915 | } 916 | 917 | testChannel := model.Channel{ 918 | Name: "TestChannel", 919 | } 920 | 921 | api := &plugintest.API{} 922 | 923 | api.On("LoadPluginConfiguration", 924 | mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 925 | *dest.(*Config) = conf 926 | return nil 927 | }) 928 | api.On("UnregisterCommand", mock.AnythingOfType("string"), 929 | mock.AnythingOfType("string")).Return((*model.AppError)(nil)) 930 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 931 | api.On("LogError", 932 | mock.AnythingOfType("string"), 933 | mock.AnythingOfType("string"), 934 | mock.AnythingOfType("string")).Return(nil) 935 | 936 | testUser := model.User{ 937 | IsBot: false, 938 | } 939 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil) 940 | 941 | p := New() 942 | p.SetAPI(api) 943 | _ = p.OnConfigurationChange() 944 | 945 | post := &model.Post{Message: "Welcome to Mattermost!"} 946 | rpost, _ := p.ProcessPost(&plugin.Context{}, post) 947 | 948 | assert.Equal(t, "Welcome to Mattermost!", rpost.Message) 949 | }) 950 | 951 | t.Run("valid scope replaces text", func(t *testing.T) { 952 | conf := Config{ 953 | Links: []autolink.Autolink{ 954 | { 955 | Pattern: "(Mattermost)", 956 | Template: "[Mattermost](https://mattermost.com)", 957 | Scope: []string{"TestTeam/TestChannel"}, 958 | }, 959 | }, 960 | } 961 | 962 | testChannel := model.Channel{ 963 | Name: "TestChannel", 964 | TeamId: "TestId", 965 | } 966 | 967 | testTeam := model.Team{ 968 | Name: "TestTeam", 969 | } 970 | 971 | api := &plugintest.API{} 972 | 973 | api.On("LoadPluginConfiguration", 974 | mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { 975 | *dest.(*Config) = conf 976 | return nil 977 | }) 978 | api.On("UnregisterCommand", mock.AnythingOfType("string"), 979 | mock.AnythingOfType("string")).Return((*model.AppError)(nil)) 980 | 981 | api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) 982 | api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil) 983 | 984 | testUser := model.User{ 985 | IsBot: false, 986 | } 987 | api.On("GetUser", mock.AnythingOfType("string")).Return(&testUser, nil) 988 | 989 | p := New() 990 | p.SetAPI(api) 991 | _ = p.OnConfigurationChange() 992 | 993 | post := &model.Post{Message: "Welcome to Mattermost!"} 994 | rpost, _ := p.ProcessPost(&plugin.Context{}, post) 995 | 996 | assert.Equal(t, "Welcome to [Mattermost](https://mattermost.com)!", rpost.Message) 997 | }) 998 | } 999 | 1000 | func TestInScope(t *testing.T) { 1001 | t.Run("returns true if scope array is empty", func(t *testing.T) { 1002 | p := &Plugin{} 1003 | result := p.inScope([]string{}, "TestChannel", "TestTeam") 1004 | assert.Equal(t, true, result) 1005 | }) 1006 | 1007 | t.Run("returns true when team and channels are valid", func(t *testing.T) { 1008 | p := &Plugin{} 1009 | result := p.inScope([]string{"TestTeam/TestChannel"}, "TestChannel", "TestTeam") 1010 | assert.Equal(t, true, result) 1011 | }) 1012 | 1013 | t.Run("returns false when channel is empty", func(t *testing.T) { 1014 | p := &Plugin{} 1015 | result := p.inScope([]string{"TestTeam/"}, "TestChannel", "TestTeam") 1016 | assert.Equal(t, false, result) 1017 | }) 1018 | 1019 | t.Run("returns false when team is empty", func(t *testing.T) { 1020 | p := &Plugin{} 1021 | result := p.inScope([]string{"TestTeam/TestChannel"}, "TestChannel", "") 1022 | assert.Equal(t, false, result) 1023 | }) 1024 | 1025 | t.Run("returns false on empty scope", func(t *testing.T) { 1026 | p := &Plugin{} 1027 | result := p.inScope([]string{""}, "TestChannel", "TestTeam") 1028 | assert.Equal(t, false, result) 1029 | }) 1030 | 1031 | t.Run("returns true on team scope only", func(t *testing.T) { 1032 | p := &Plugin{} 1033 | result := p.inScope([]string{"TestTeam"}, "TestChannel", "TestTeam") 1034 | assert.Equal(t, true, result) 1035 | }) 1036 | } 1037 | --------------------------------------------------------------------------------